mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-29 16:15:00 +00:00
feat(ui): phase 5 — graphs, widgets, theming, PWA, boards, i18n
P5a: 24h uptime bar strip + latency chart on monitor detail, iframe
embed widget, generic JSON-path widget, accent/wallpaper/custom CSS
settings, PWA manifest + service worker, demo GIF.
P5b: transport picker + per-transport fields + test button in
settings; per-monitor alert-rule fields in dialogs.
P5c: container cpu/mem columns, SMART/ZFS/GPU sections on system
detail, subdomains section on domain detail.
P5d: login/setup page + auth middleware client handling, board
switcher + /b/:slug routes, full string extraction to typed t() keys
with {param} interpolation, complete cs locale, language select.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f3143af351
commit
e0ef60eca6
49 files changed
+2131
-426
No files matched your search
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api";
|
||||
import type { DomainCheck, DomainView } from "@/types";
|
||||
import type { DomainCheck, DomainView, Subdomain } from "@/types";
|
||||
import { DomainDialog } from "@/components/DomainDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
|
||||
if (v === undefined || v === null || v === "") return null;
|
||||
@@ -34,28 +35,32 @@ function fmtDate(iso: string | null | undefined) {
|
||||
export function DomainDetailPage({ id }: { id: string }) {
|
||||
const [d, setD] = useState<DomainView | null>(null);
|
||||
const [checks, setChecks] = useState<DomainCheck[]>([]);
|
||||
const [subs, setSubs] = useState<Subdomain[]>([]);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [subScanning, setSubScanning] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [{ data: dv }, { data: ck }] = await Promise.all([
|
||||
const [{ data: dv }, { data: ck }, { data: sb }] = await Promise.all([
|
||||
api.GET("/api/domains/{id}", { params: { path: { id } } }),
|
||||
api.GET("/api/domains/{id}/checks", { params: { path: { id } } }),
|
||||
api.GET("/api/domains/{id}/subdomains", { params: { path: { id } } }),
|
||||
]);
|
||||
if (dv) setD(dv);
|
||||
if (ck) setChecks(ck);
|
||||
if (sb) setSubs(sb);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
|
||||
|
||||
const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<Link href="/domains" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||
<ArrowLeft size={12} /> Domains
|
||||
<ArrowLeft size={12} /> {t("nav.domains")}
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
@@ -66,9 +71,9 @@ export function DomainDetailPage({ id }: { id: string }) {
|
||||
<p className="mt-0.5 flex items-center gap-2 font-mono text-[11px] text-text-faint">
|
||||
{d.tld ? `.${d.tld}` : ""}
|
||||
{d.dnssec === "signed" && <span className="flex items-center gap-1 text-up"><Shield size={10} /> dnssec</span>}
|
||||
{d.transferLock ? <span>transfer-locked</span> : null}
|
||||
{d.privacyEnabled ? <span>whois privacy</span> : null}
|
||||
{d.autoRenew ? <span>auto-renew</span> : null}
|
||||
{d.transferLock ? <span>{t("domains.transferLock")}</span> : null}
|
||||
{d.privacyEnabled ? <span>{t("domains.whoisPrivacy")}</span> : null}
|
||||
{d.autoRenew ? <span>{t("domains.autoRenew")}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,16 +84,16 @@ export function DomainDetailPage({ id }: { id: string }) {
|
||||
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
|
||||
.then(load).finally(() => setRefreshing(false));
|
||||
}}>
|
||||
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
|
||||
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> {t("common.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => void api.PATCH("/api/domains/{id}", { params: { path: { id } }, body: { active: !d.active } }).then(load)}>
|
||||
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? "Pause" : "Resume"}
|
||||
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? t("monitors.pause") : t("monitors.resume")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
|
||||
<Button variant="outline" size="sm" aria-label="Delete"
|
||||
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
|
||||
<Button variant="outline" size="sm" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||
if (confirm(t("domains.deleteConfirm", { name: d.name ?? "" }))) {
|
||||
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
|
||||
}
|
||||
}}>
|
||||
@@ -105,86 +110,86 @@ export function DomainDetailPage({ id }: { id: string }) {
|
||||
|
||||
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Domain expires</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.domainExpires")}</div>
|
||||
<div className={cn("mt-1 font-mono text-[17px] font-medium", expiring && "text-down")}>
|
||||
{d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined
|
||||
? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`)
|
||||
? (d.daysUntilExpiry < 0 ? t("domains.expired") : `${d.daysUntilExpiry}d`)
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.expiryDate)}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">SSL expires</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.sslExpires")}</div>
|
||||
<div className={cn("mt-1 font-mono text-[17px] font-medium", d.sslExpiring && "text-down")}>
|
||||
{d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined
|
||||
? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`)
|
||||
? (d.sslDaysUntilExpiry < 0 ? t("domains.expired") : `${d.sslDaysUntilExpiry}d`)
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.sslValidTo)}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Registrar</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.registrar")}</div>
|
||||
<div className="mt-1 truncate text-[13px] font-medium">{d.registrarName || "—"}</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Providers</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.providers")}</div>
|
||||
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
|
||||
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
|
||||
<Section title="Registration">
|
||||
<Row k="Created" v={fmtDate(d.creationDate)} />
|
||||
<Row k="Updated" v={fmtDate(d.updatedDate)} />
|
||||
<Row k="Registrar" v={d.registrarName} />
|
||||
<Section title={t("domains.registration")}>
|
||||
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
|
||||
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
|
||||
<Row k={t("domains.registrar")} v={d.registrarName} />
|
||||
<Row k="IANA ID" v={d.registrarId} mono />
|
||||
<Row k="Registry ID" v={d.registryDomainId} mono />
|
||||
<Row k="Registrant" v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
|
||||
<Row k="Country" v={d.registrantCountry} />
|
||||
<Row k="Abuse" v={d.abuseEmail} mono />
|
||||
{d.statuses && d.statuses.length > 0 ? <Row k="Status" v={d.statuses.join(", ")} mono /> : null}
|
||||
<Row k={t("domains.registrant")} v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
|
||||
<Row k={t("domains.country")} v={d.registrantCountry} />
|
||||
<Row k={t("domains.abuse")} v={d.abuseEmail} mono />
|
||||
{d.statuses && d.statuses.length > 0 ? <Row k={t("domains.statusLbl")} v={d.statuses.join(", ")} mono /> : null}
|
||||
</Section>
|
||||
|
||||
<Section title="DNS">
|
||||
<Section title={t("domains.dns")}>
|
||||
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
|
||||
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
|
||||
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
|
||||
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
|
||||
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
|
||||
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
|
||||
{d.dnsProvider ? <Row k="DNS provider" v={d.dnsProvider} /> : null}
|
||||
{d.emailProvider ? <Row k="Email" v={d.emailProvider} /> : null}
|
||||
{d.dnsProvider ? <Row k={t("domains.dnsProvider")} v={d.dnsProvider} /> : null}
|
||||
{d.emailProvider ? <Row k={t("domains.email")} v={d.emailProvider} /> : null}
|
||||
</Section>
|
||||
|
||||
<Section title="TLS certificate">
|
||||
<Row k="Issuer" v={d.sslIssuer} />
|
||||
<Section title={t("domains.tlsCert")}>
|
||||
<Row k={t("domains.issuer")} v={d.sslIssuer} />
|
||||
<Row k="CA" v={d.caProvider} />
|
||||
<Row k="Valid" v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
||||
<Row k="Subject" v={d.sslSubject} mono />
|
||||
<Row k="Key" v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
||||
<Row k="Algorithm" v={d.sslSigAlgo} mono />
|
||||
<Row k={t("domains.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
|
||||
<Row k={t("domains.subject")} v={d.sslSubject} mono />
|
||||
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
|
||||
<Row k={t("domains.algorithm")} v={d.sslSigAlgo} mono />
|
||||
{d.sslAltNames && d.sslAltNames.length > 0 ? (
|
||||
<Row k="SANs" v={d.sslAltNames.slice(0, 5).join(", ") + (d.sslAltNames.length > 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono />
|
||||
) : null}
|
||||
<Row k="SHA-256" v={d.sslFingerprint ? d.sslFingerprint.slice(0, 29) + "…" : ""} mono />
|
||||
</Section>
|
||||
|
||||
<Section title="Host">
|
||||
<Row k="Location" v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
|
||||
<Row k="ISP" v={d.hostIsp} />
|
||||
<Row k="Org" v={d.hostOrg} />
|
||||
<Section title={t("domains.host")}>
|
||||
<Row k={t("domains.location")} v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
|
||||
<Row k={t("domains.isp")} v={d.hostIsp} />
|
||||
<Row k={t("domains.org")} v={d.hostOrg} />
|
||||
<Row k="AS" v={d.hostAs} mono />
|
||||
<Row k="Hosting" v={d.hostingProvider} />
|
||||
<Row k="Checked" v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
||||
<Row k={t("domains.hosting")} v={d.hostingProvider} />
|
||||
<Row k={t("domains.checked")} v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{Object.keys(d.headers ?? {}).length > 0 ? (
|
||||
<details className="mt-3 overflow-hidden rounded-[10px] border border-border bg-surface">
|
||||
<summary className="cursor-pointer px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
|
||||
Response headers ({Object.keys(d.headers ?? {}).length})
|
||||
{t("domains.responseHeaders", { n: Object.keys(d.headers ?? {}).length })}
|
||||
</summary>
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
{Object.entries(d.headers ?? {}).map(([k, v]) => (
|
||||
@@ -197,20 +202,57 @@ export function DomainDetailPage({ id }: { id: string }) {
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Check history</h2>
|
||||
<div className="mb-2 mt-6 flex items-center justify-between">
|
||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
|
||||
{t("domains.subdomains")}{subs.length > 0 ? ` (${subs.length})` : ""}
|
||||
</h2>
|
||||
<Button variant="ghost" size="sm" disabled={subScanning}
|
||||
onClick={() => {
|
||||
setSubScanning(true);
|
||||
void api.POST("/api/domains/{id}/subdomains/refresh", { params: { path: { id } } })
|
||||
.then(({ data }) => { if (data) setSubs(data); })
|
||||
.finally(() => setSubScanning(false));
|
||||
}}>
|
||||
<RefreshCw size={11} className={cn(subScanning && "animate-spin")} /> {t("domains.scanShort")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{subs.map((s) => (
|
||||
<tr key={s.name} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[11px]">{s.name}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
|
||||
{(s.ips ?? []).join(", ") || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-[10.5px] text-text-faint">
|
||||
{fmtDate(s.lastSeen)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && (
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">
|
||||
{t("domains.noSubsFound")}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("domains.checkHistory")}</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
<tr key={c.id} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">{new Date(c.checkedAt ?? "").toLocaleString()}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">exp {fmtDate(c.expiryDate)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">ssl {fmtDate(c.sslValidTo)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{t("domains.expPrefix")} {fmtDate(c.expiryDate)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{t("domains.sslPrefix")} {fmtDate(c.sslValidTo)}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{(c.ipv4 ?? []).join(", ")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{checks.length === 0 && (
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">No snapshots yet.</td></tr>
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">{t("domains.noSnapshots")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -6,11 +6,12 @@ import type { DomainView } from "@/types";
|
||||
import { DomainDialog } from "@/components/DomainDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
function daysLabel(days: number | null | undefined, warn?: boolean) {
|
||||
if (days === null || days === undefined) return "—";
|
||||
const cls = warn || days < 0 ? "text-down" : days <= 30 ? "text-amber-500" : "text-text-dim";
|
||||
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? "expired" : `${days}d`}</span>;
|
||||
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? t("domains.expired") : `${days}d`}</span>;
|
||||
}
|
||||
|
||||
export function DomainsPage() {
|
||||
@@ -38,19 +39,19 @@ export function DomainsPage() {
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Domains</h1>
|
||||
<p className="text-[12px] text-text-faint">WHOIS, DNS, and certificate watch.</p>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">{t("domains.title")}</h1>
|
||||
<p className="text-[12px] text-text-faint">{t("domains.subtitle")}</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
|
||||
<Plus size={13} strokeWidth={2.2} /> Track domain
|
||||
<Plus size={13} strokeWidth={2.2} /> {t("domains.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && domains.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No domains tracked yet.</p>
|
||||
<p className="text-[13px] text-text-dim">{t("domains.empty")}</p>
|
||||
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||
Expiry, registrar, DNS records, SSL certificates, and provider detection.
|
||||
{t("domains.emptyHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -58,11 +59,11 @@ export function DomainsPage() {
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
<th className="px-4 py-2.5 font-medium">Domain</th>
|
||||
<th className="px-4 py-2.5 font-medium">Registrar</th>
|
||||
<th className="px-4 py-2.5 font-medium">Expires</th>
|
||||
<th className="px-4 py-2.5 font-medium">SSL</th>
|
||||
<th className="px-4 py-2.5 font-medium">Providers</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("domains.domain")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("domains.registrar")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("domains.expires")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("domains.ssl")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("domains.providers")}</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -76,7 +77,7 @@ export function DomainsPage() {
|
||||
) : null}
|
||||
<Link href={`/domains/${d.id}`} className="hover:underline">{d.name}</Link>
|
||||
{d.lookupError ? (
|
||||
<span className="font-mono text-[10px] text-down" title={d.lookupError}>lookup error</span>
|
||||
<span className="font-mono text-[10px] text-down" title={d.lookupError}>{t("domains.lookupError")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
@@ -88,7 +89,7 @@ export function DomainsPage() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="icon" aria-label="Refresh now" disabled={refreshing === d.id}
|
||||
<Button variant="outline" size="icon" aria-label={t("domains.refreshNow")} disabled={refreshing === d.id}
|
||||
onClick={() => {
|
||||
if (!d.id) return;
|
||||
setRefreshing(d.id);
|
||||
@@ -97,20 +98,19 @@ export function DomainsPage() {
|
||||
}}>
|
||||
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
|
||||
<Button variant="outline" size="icon" aria-label={d.active ? t("monitors.pause") : t("monitors.resume")}
|
||||
onClick={() => void act(d.id, () => api.PATCH("/api/domains/{id}", { params: { path: { id: d.id! } }, body: { active: !d.active } }))}>
|
||||
{d.active ? <Pause size={12} /> : <Play size={12} />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
<Button variant="outline" size="icon" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (confirm(`Stop tracking "${d.name}"?`)) {
|
||||
if (confirm(t("domains.deleteConfirm", { name: d.name ?? "" }))) {
|
||||
void act(d.id, () => api.DELETE("/api/domains/{id}", { params: { path: { id: d.id! } } }));
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>
|
||||
Edit
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>{t("common.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
const SEV_COLOR = {
|
||||
critical: "text-down",
|
||||
@@ -19,9 +20,9 @@ const SEV_COLOR = {
|
||||
|
||||
type Status = NonNullable<Incident["status"]>;
|
||||
const NEXT: Partial<Record<Status, { label: string; to: Status }[]>> = {
|
||||
open: [{ label: "Acknowledge", to: "ack" }, { label: "Resolve", to: "resolved" }],
|
||||
ack: [{ label: "Resolve", to: "resolved" }],
|
||||
resolved: [{ label: "Close", to: "closed" }],
|
||||
open: [{ label: t("incidents.acknowledge"), to: "ack" }, { label: t("incidents.resolve"), to: "resolved" }],
|
||||
ack: [{ label: t("incidents.resolve"), to: "resolved" }],
|
||||
resolved: [{ label: t("incidents.close"), to: "closed" }],
|
||||
};
|
||||
|
||||
export function IncidentsPage() {
|
||||
@@ -83,22 +84,22 @@ export function IncidentsPage() {
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Incidents</h1>
|
||||
<p className="text-[12px] text-text-faint">Auto-opened on monitor outages, or tracked manually.</p>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">{t("incidents.title")}</h1>
|
||||
<p className="text-[12px] text-text-faint">{t("incidents.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? "Open only" : "Show all"}
|
||||
{showAll ? t("incidents.openOnly") : t("incidents.showAll")}
|
||||
</Button>
|
||||
<Button onClick={() => setDlgOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New incident
|
||||
<Plus size={13} strokeWidth={2.2} /> {t("incidents.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loaded && incidents.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No open incidents.</p>
|
||||
<p className="text-[13px] text-text-dim">{t("incidents.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
@@ -122,9 +123,9 @@ export function IncidentsPage() {
|
||||
{n.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
<Button variant="outline" size="icon" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
|
||||
if (inc.id && confirm(t("incidents.deleteConfirm", { title: inc.title ?? "" }))) {
|
||||
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
@@ -144,14 +145,14 @@ export function IncidentsPage() {
|
||||
</div>
|
||||
))}
|
||||
{(inc.updates ?? []).length === 0 && (
|
||||
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
|
||||
<p className="text-[11.5px] text-text-faint">{t("incidents.noUpdates")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a note…" className="h-8 text-[12px]"
|
||||
placeholder={t("incidents.notePlaceholder")} className="h-8 text-[12px]"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} />
|
||||
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>Post</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>{t("incidents.post")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -163,37 +164,37 @@ export function IncidentsPage() {
|
||||
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New incident</DialogTitle>
|
||||
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
|
||||
<DialogTitle>{t("incidents.newTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("incidents.desc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="inc-title">Title</Label>
|
||||
<Label htmlFor="inc-title">{t("common.title")}</Label>
|
||||
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="NAS offline" autoFocus />
|
||||
placeholder={t("incidents.titlePlaceholder")} autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Severity</Label>
|
||||
<Label>{t("incidents.severity")}</Label>
|
||||
<Select value={sev} onValueChange={setSev}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="minor">minor</SelectItem>
|
||||
<SelectItem value="major">major</SelectItem>
|
||||
<SelectItem value="critical">critical</SelectItem>
|
||||
<SelectItem value="minor">{t("incidents.sevMinor")}</SelectItem>
|
||||
<SelectItem value="major">{t("incidents.sevMajor")}</SelectItem>
|
||||
<SelectItem value="critical">{t("incidents.sevCritical")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="inc-msg">Note (optional)</Label>
|
||||
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="First observed…" />
|
||||
<Label htmlFor="inc-msg">{t("incidents.noteOptional")}</Label>
|
||||
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder={t("incidents.noteFirst")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
|
||||
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
{busy ? t("common.creating") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
/** Login/setup gate shown when auth is enabled and no session exists. */
|
||||
export function LoginPage({ setup, onDone }: { setup: boolean; onDone: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
setBusy(true);
|
||||
const body = { username: username.trim(), password };
|
||||
const { error, response } = setup
|
||||
? await api.POST("/api/auth/setup", { body })
|
||||
: await api.POST("/api/auth/login", { body });
|
||||
setBusy(false);
|
||||
if (error) {
|
||||
setErr(response.status === 429 ? t("auth.rateLimited") : t("auth.invalid"));
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg p-6">
|
||||
<form
|
||||
onSubmit={(e) => void submit(e)}
|
||||
className="w-full max-w-[320px] rounded-xl border border-border bg-surface p-6 shadow-lg"
|
||||
>
|
||||
<div className="mb-5 flex items-center gap-2.5 text-[15px] font-semibold tracking-tight">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
|
||||
<rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor" />
|
||||
<rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
|
||||
<rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
|
||||
<rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
Dash
|
||||
</div>
|
||||
<h1 className="mb-1 text-[14px] font-medium">{setup ? t("auth.setupTitle") : t("auth.signInTitle")}</h1>
|
||||
<p className="mb-4 text-[12px] text-text-faint">{setup ? t("auth.setupHint") : t("auth.signInHint")}</p>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="auth-user">{t("auth.username")}</Label>
|
||||
<Input
|
||||
id="auth-user" autoComplete="username" autoFocus
|
||||
value={username} onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="auth-pass">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="auth-pass" type="password"
|
||||
autoComplete={setup ? "new-password" : "current-password"}
|
||||
value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{err && <p className="text-[12px] text-danger">{err}</p>}
|
||||
<Button type="submit" disabled={busy || !username.trim() || password.length < 8} className="mt-1">
|
||||
{setup ? t("auth.createAccount") : t("auth.signIn")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { Heartbeat, MonitorView } from "@/types";
|
||||
import { MonitorDialog } from "@/components/MonitorDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
export function MonitorDetailPage({ id }: { id: string }) {
|
||||
const [m, setM] = useState<MonitorView | null>(null);
|
||||
@@ -29,7 +30,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
|
||||
|
||||
const chart = hbs.map((h) => ({
|
||||
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
@@ -40,7 +41,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<Link href="/monitors" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||
<ArrowLeft size={12} /> Monitors
|
||||
<ArrowLeft size={12} /> {t("nav.monitors")}
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
@@ -51,21 +52,21 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{m.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
|
||||
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s
|
||||
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · {t("monitors.everyInterval", { interval: m.intervalS ?? 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void api.POST("/api/monitors/{id}/check", { params: { path: { id } } }).then(load)}>
|
||||
<RefreshCw size={12} /> Check now
|
||||
<RefreshCw size={12} /> {t("monitors.checkNow")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => void api.PATCH("/api/monitors/{id}", { params: { path: { id } }, body: { active: !m.active } }).then(load)}>
|
||||
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? "Pause" : "Resume"}
|
||||
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? t("monitors.pause") : t("monitors.resume")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
|
||||
<Button variant="outline" size="sm" aria-label="Delete"
|
||||
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
|
||||
<Button variant="outline" size="sm" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete monitor "${m.name}"?`)) {
|
||||
if (confirm(t("monitors.deleteConfirm", { name: m.name ?? "" }))) {
|
||||
void api.DELETE("/api/monitors/{id}", { params: { path: { id } } }).then(() => { location.href = "/monitors"; });
|
||||
}
|
||||
}}>
|
||||
@@ -76,10 +77,10 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
|
||||
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||
{[
|
||||
["Uptime 24h", `${m.stats?.uptime24h?.toFixed(2)}%`],
|
||||
["Uptime 30d", `${m.stats?.uptime30d?.toFixed(2)}%`],
|
||||
["Avg ping 24h", m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
|
||||
["Checks", String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
|
||||
[t("monitors.uptime24h"), `${m.stats?.uptime24h?.toFixed(2)}%`],
|
||||
[t("monitors.uptime30d"), `${m.stats?.uptime30d?.toFixed(2)}%`],
|
||||
[t("monitors.avgLatency"), m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
|
||||
[t("monitors.checks"), String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
|
||||
@@ -88,8 +89,13 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.uptime")}</h2>
|
||||
<div className="mb-6 rounded-[10px] border border-border bg-surface px-3 py-2.5">
|
||||
<UptimeBar hbs={hbs} hours={hours} />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Response time</h2>
|
||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</h2>
|
||||
<div className="flex gap-1">
|
||||
{[24, 168, 720].map((h) => (
|
||||
<button key={h} type="button" onClick={() => setHours(h)}
|
||||
@@ -115,7 +121,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
@@ -134,7 +140,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
</tr>
|
||||
))}
|
||||
{hbs.length === 0 && (
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">No checks in range.</td></tr>
|
||||
<tr><td className="px-4 py-6 text-center text-text-faint">{t("monitors.noChecks")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -144,3 +150,42 @@ export function MonitorDetailPage({ id }: { id: string }) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** UptimeBar renders the check history as a strip of status-colored bars —
|
||||
* the window is split into fixed buckets; a bucket is "down" if any check in
|
||||
* it failed, so outages stay visible at long ranges. */
|
||||
function UptimeBar({ hbs, hours }: { hbs: Heartbeat[]; hours: number }) {
|
||||
const BUCKETS = 90;
|
||||
const now = Date.now();
|
||||
const span = hours * 3600_000;
|
||||
const start = now - span;
|
||||
const buckets: { status: "up" | "down" | "empty"; from: number; to: number; n: number }[] = [];
|
||||
for (let i = 0; i < BUCKETS; i++) {
|
||||
buckets.push({ status: "empty", from: start + (i * span) / BUCKETS, to: start + ((i + 1) * span) / BUCKETS, n: 0 });
|
||||
}
|
||||
for (const h of hbs) {
|
||||
const t = new Date(h.checkedAt ?? "").getTime();
|
||||
if (Number.isNaN(t) || t < start) continue;
|
||||
const i = Math.min(BUCKETS - 1, Math.floor(((t - start) / span) * BUCKETS));
|
||||
const b = buckets[i];
|
||||
if (!b) continue;
|
||||
b.n++;
|
||||
if (h.status === "down") b.status = "down";
|
||||
else if (b.status === "empty") b.status = "up";
|
||||
}
|
||||
const fmt = (t: number) => new Date(t).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
return (
|
||||
<div className="flex h-7 items-stretch gap-[2px]">
|
||||
{buckets.map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${fmt(b.from)} – ${fmt(b.to)} · ${b.n === 0 ? t("monitors.noChecksBucket") : t("monitors.bucketChecks", { status: b.status, n: b.n })}`}
|
||||
className={cn(
|
||||
"flex-1 rounded-[2px]",
|
||||
b.status === "down" ? "bg-down" : b.status === "up" ? "bg-up/80 hover:bg-up" : "bg-border/50",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { MonitorView } from "@/types";
|
||||
import { MonitorDialog } from "@/components/MonitorDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
function StatusDot({ status }: { status?: string }) {
|
||||
return (
|
||||
@@ -46,19 +47,19 @@ export function MonitorsPage() {
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Monitors</h1>
|
||||
<p className="text-[12px] text-text-faint">Uptime checks on a schedule.</p>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">{t("monitors.title")}</h1>
|
||||
<p className="text-[12px] text-text-faint">{t("monitors.desc")}</p>
|
||||
</div>
|
||||
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New monitor
|
||||
<Plus size={13} strokeWidth={2.2} /> {t("monitors.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && monitors.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No monitors yet.</p>
|
||||
<p className="text-[13px] text-text-dim">{t("monitors.empty")}</p>
|
||||
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||
HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.
|
||||
{t("monitors.checksHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -66,13 +67,13 @@ export function MonitorsPage() {
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
<th className="px-4 py-2.5 font-medium">Name</th>
|
||||
<th className="px-4 py-2.5 font-medium">Type</th>
|
||||
<th className="px-4 py-2.5 font-medium">Uptime 24h</th>
|
||||
<th className="px-4 py-2.5 font-medium">Uptime 30d</th>
|
||||
<th className="px-4 py-2.5 font-medium">Ping</th>
|
||||
<th className="px-4 py-2.5 font-medium">Every</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("monitors.status")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("common.name")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("common.type")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime24h")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime30d")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("monitors.ping")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("monitors.every")}</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -101,24 +102,24 @@ export function MonitorsPage() {
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.intervalS}s</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="icon" aria-label="Check now"
|
||||
<Button variant="outline" size="icon" aria-label={t("monitors.checkNow")}
|
||||
onClick={() => void act(m.id, () => api.POST("/api/monitors/{id}/check", { params: { path: { id: m.id! } } }))}>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label={m.active ? "Pause" : "Resume"}
|
||||
<Button variant="outline" size="icon" aria-label={m.active ? t("monitors.pause") : t("monitors.resume")}
|
||||
onClick={() => void act(m.id, () => api.PATCH("/api/monitors/{id}", { params: { path: { id: m.id! } }, body: { active: !m.active } }))}>
|
||||
{m.active ? <Pause size={12} /> : <Play size={12} />}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
<Button variant="outline" size="icon" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete monitor "${m.name}"?`)) {
|
||||
if (confirm(t("monitors.deleteConfirm", { name: m.name ?? "" }))) {
|
||||
void act(m.id, () => api.DELETE("/api/monitors/{id}", { params: { path: { id: m.id! } } }));
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(m); setDlgOpen(true); }}>
|
||||
Edit
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Admin for public status pages + maintenance windows. The public view
|
||||
// itself lives at /status/:slug.
|
||||
@@ -63,7 +64,7 @@ export function StatusPagesPage() {
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (error) { setErr("Could not create — slug may be taken."); return; }
|
||||
if (error) { setErr(t("statusPages.createError")); return; }
|
||||
setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]);
|
||||
void load();
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export function StatusPagesPage() {
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (error) { setErr("Invalid window — check times."); return; }
|
||||
if (error) { setErr(t("maintenance.createError")); return; }
|
||||
setWinOpen(false); setWTitle(""); setWStart(""); setWEnd("");
|
||||
void load();
|
||||
}
|
||||
@@ -88,17 +89,17 @@ export function StatusPagesPage() {
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Status pages</h1>
|
||||
<p className="text-[12px] text-text-faint">Public live views at <code className="font-mono">/status/<slug></code>.</p>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">{t("statusPages.title")}</h1>
|
||||
<p className="text-[12px] text-text-faint">{t("statusPages.publicAt")} <code className="font-mono">/status/<slug></code>.</p>
|
||||
</div>
|
||||
<Button onClick={() => setPageOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New page
|
||||
<Plus size={13} strokeWidth={2.2} /> {t("statusPages.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && pages.length === 0 ? (
|
||||
<div className="mb-8 rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||
<p className="text-[13px] text-text-dim">No status pages yet.</p>
|
||||
<p className="text-[13px] text-text-dim">{t("statusPages.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-8 flex flex-col gap-2">
|
||||
@@ -107,11 +108,11 @@ export function StatusPagesPage() {
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{p.title}</span>
|
||||
<code className="font-mono text-[11px] text-text-faint">/status/{p.slug}</code>
|
||||
<Link href={`/status/${p.slug}`} className="shrink-0">
|
||||
<Button variant="outline" size="icon" aria-label="Open public page"><ExternalLink size={12} /></Button>
|
||||
<Button variant="outline" size="icon" aria-label={t("statusPages.open")}><ExternalLink size={12} /></Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
<Button variant="outline" size="icon" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
|
||||
if (p.id && confirm(t("statusPages.deleteConfirm", { title: p.title ?? "" }))) {
|
||||
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
@@ -124,17 +125,17 @@ export function StatusPagesPage() {
|
||||
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Maintenance windows</h2>
|
||||
<p className="text-[12px] text-text-faint">Active windows mute alerts + auto-incidents and mark covered monitors.</p>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">{t("maintenance.title")}</h2>
|
||||
<p className="text-[12px] text-text-faint">{t("maintenance.subtitle")}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setWinOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> Schedule
|
||||
<Plus size={13} strokeWidth={2.2} /> {t("maintenance.schedule")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && windows.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||
<p className="text-[13px] text-text-dim">No maintenance scheduled.</p>
|
||||
<p className="text-[13px] text-text-dim">{t("maintenance.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -146,11 +147,11 @@ export function StatusPagesPage() {
|
||||
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
|
||||
</span>
|
||||
<span className={cn("font-mono text-[10px] uppercase", w.active ? "text-up" : "text-text-faint")}>
|
||||
{w.active ? "active" : "scheduled"}
|
||||
{w.active ? t("maintenance.active") : t("maintenance.scheduled")}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
<Button variant="outline" size="icon" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (w.id && confirm(`Delete window "${w.title}"?`)) {
|
||||
if (w.id && confirm(t("maintenance.deleteConfirm", { title: w.title ?? "" }))) {
|
||||
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
@@ -164,26 +165,26 @@ export function StatusPagesPage() {
|
||||
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New status page</DialogTitle>
|
||||
<DialogDescription>Public live view over selected monitors.</DialogDescription>
|
||||
<DialogTitle>{t("statusPages.newTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("statusPages.newDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-title">Title</Label>
|
||||
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder="Homelab status" autoFocus />
|
||||
<Label htmlFor="sp-title">{t("common.title")}</Label>
|
||||
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder={t("statusPages.titlePlaceholder")} autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-slug">Slug</Label>
|
||||
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" />
|
||||
<Label htmlFor="sp-slug">{t("common.slug")}</Label>
|
||||
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder={t("statusPages.slugPlaceholder")} className="font-mono text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-desc">Description</Label>
|
||||
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder="optional" />
|
||||
<Label htmlFor="sp-desc">{t("common.description")}</Label>
|
||||
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder={t("common.optional")} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Monitors</Label>
|
||||
<Label>{t("statusPages.monitors")}</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-[7px] border border-border p-2">
|
||||
{monitors.map((m) => (
|
||||
<label key={m.id} className="flex items-center gap-2 px-1 py-1 text-[12px]">
|
||||
@@ -194,16 +195,16 @@ export function StatusPagesPage() {
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">No monitors yet — empty selection shows all.</p>}
|
||||
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">{t("statusPages.noMonitors")}</p>}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
|
||||
<p className="text-[11px] text-text-faint">{t("statusPages.allHint")}</p>
|
||||
</div>
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
|
||||
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
{busy ? t("common.creating") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -212,30 +213,30 @@ export function StatusPagesPage() {
|
||||
<Dialog open={winOpen} onOpenChange={setWinOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule maintenance</DialogTitle>
|
||||
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
|
||||
<DialogTitle>{t("maintenance.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("maintenance.dialogDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-title">Title</Label>
|
||||
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus />
|
||||
<Label htmlFor="mw-title">{t("common.title")}</Label>
|
||||
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder={t("maintenance.titlePlaceholder")} autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-start">Starts</Label>
|
||||
<Label htmlFor="mw-start">{t("maintenance.starts")}</Label>
|
||||
<Input id="mw-start" type="datetime-local" value={wStart} onChange={(e) => setWStart(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-end">Ends</Label>
|
||||
<Label htmlFor="mw-end">{t("maintenance.ends")}</Label>
|
||||
<Input id="mw-end" type="datetime-local" value={wEnd} onChange={(e) => setWEnd(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
|
||||
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
|
||||
{busy ? "Saving…" : "Schedule"}
|
||||
{busy ? t("common.saving") : t("maintenance.schedule")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/api";
|
||||
import type { PublicStatus } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Public status page — clean read-only view; refreshes every 30s.
|
||||
// Route: /status/:slug -> GET /api/status-pages/:slug/public.
|
||||
@@ -9,10 +10,10 @@ import { cn } from "@/lib/utils";
|
||||
// status strings arrive as plain `string` — map with functions, not
|
||||
// open dictionaries, so the lookup type stays honest.
|
||||
function overallBanner(overall?: string) {
|
||||
if (overall === "down") return { label: "Major outage", cls: "bg-down" };
|
||||
if (overall === "maintenance") return { label: "Under maintenance", cls: "bg-text-faint/60" };
|
||||
if (overall === "up") return { label: "All systems operational", cls: "bg-up" };
|
||||
return { label: "Checking…", cls: "bg-text-faint/40" };
|
||||
if (overall === "down") return { label: t("statusPublic.outage"), cls: "bg-down" };
|
||||
if (overall === "maintenance") return { label: t("statusPublic.underMaintenance"), cls: "bg-text-faint/60" };
|
||||
if (overall === "up") return { label: t("statusPublic.operational"), cls: "bg-up" };
|
||||
return { label: t("statusPublic.checking"), cls: "bg-text-faint/40" };
|
||||
}
|
||||
|
||||
function dotCls(status?: string) {
|
||||
@@ -38,8 +39,8 @@ export function StatusPublicPage({ slug }: { slug: string }) {
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Status page not found.</main>;
|
||||
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Loading…</main>;
|
||||
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("statusPublic.notFound")}</main>;
|
||||
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("common.loading")}</main>;
|
||||
|
||||
const banner = overallBanner(st.overall);
|
||||
|
||||
@@ -56,7 +57,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
|
||||
|
||||
{(st.monitors ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Services</h2>
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.services")}</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.monitors ?? []).map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
@@ -72,7 +73,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
|
||||
|
||||
{(st.systems ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Systems</h2>
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.systems")}</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.systems ?? []).map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
@@ -87,7 +88,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
|
||||
|
||||
{(st.maintenance ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Maintenance</h2>
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.maintenance")}</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.maintenance ?? []).map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
@@ -103,7 +104,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
|
||||
|
||||
{(st.incidents ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Incidents</h2>
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.incidents")}</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(st.incidents ?? []).map((inc) => (
|
||||
<div key={inc.id} className="rounded-[10px] border border-border px-4 py-3">
|
||||
@@ -122,7 +123,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
|
||||
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">{t("statusPublic.footer")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { System, SystemStat } from "@/types";
|
||||
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
export function SystemDetailPage({ id }: { id: string }) {
|
||||
const [sys, setSys] = useState<System | null>(null);
|
||||
@@ -32,11 +33,11 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
|
||||
if (notFound) return (
|
||||
<main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">
|
||||
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> Systems</Link>
|
||||
<p className="mt-4">System not found.</p>
|
||||
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> {t("nav.systems")}</Link>
|
||||
<p className="mt-4">{t("systems.notFound")}</p>
|
||||
</main>
|
||||
);
|
||||
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
|
||||
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
|
||||
|
||||
const l = sys.latest;
|
||||
const chart = stats.map((s) => ({
|
||||
@@ -53,7 +54,7 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<Link href="/systems" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
|
||||
<ArrowLeft size={12} /> Systems
|
||||
<ArrowLeft size={12} /> {t("nav.systems")}
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
@@ -66,14 +67,14 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
|
||||
{sys.host ? `${sys.host} · ` : ""}{sys.os}/{sys.arch}
|
||||
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${sys.cores} cores` : ""}
|
||||
{sys.lastSeenAt ? ` · seen ${new Date(sys.lastSeenAt).toLocaleTimeString()}` : " · never seen"}
|
||||
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${t("systems.cores", { n: sys.cores })}` : ""}
|
||||
{sys.lastSeenAt ? ` · ${t("systems.seenAt", { time: new Date(sys.lastSeenAt).toLocaleTimeString() })}` : ` · ${t("systems.neverSeen")}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" aria-label="Delete"
|
||||
<Button variant="outline" size="sm" aria-label={t("common.delete")}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete system "${sys.name}"?`)) {
|
||||
if (confirm(t("systems.deleteConfirm", { name: sys.name ?? "" }))) {
|
||||
void api.DELETE("/api/systems/{id}", { params: { path: { id } } }).then(() => { location.href = "/systems"; });
|
||||
}
|
||||
}}>
|
||||
@@ -84,10 +85,10 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
|
||||
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
|
||||
{[
|
||||
["CPU", l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
|
||||
["Memory", l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
|
||||
["Disk /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
|
||||
["Uptime", l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
|
||||
[t("systems.cpu"), l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
|
||||
[t("systems.mem"), l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
|
||||
[t("systems.disk") + " /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
|
||||
[t("systems.uptime"), l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
|
||||
@@ -97,7 +98,7 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Usage</h2>
|
||||
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("systems.usage")}</h2>
|
||||
<div className="flex gap-1">
|
||||
{[1, 6, 24, 168].map((h) => (
|
||||
<button key={h} type="button" onClick={() => setHours(h)}
|
||||
@@ -111,7 +112,7 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">CPU %</div>
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.cpuPct")}</div>
|
||||
<ResponsiveContainer width="100%" height="85%">
|
||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
@@ -123,7 +124,7 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Memory %</div>
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.memPct")}</div>
|
||||
<ResponsiveContainer width="100%" height="85%">
|
||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
@@ -135,7 +136,7 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Network KB/s</div>
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.netKbs")}</div>
|
||||
<ResponsiveContainer width="100%" height="85%">
|
||||
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
@@ -148,7 +149,7 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Load 1m</div>
|
||||
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.load1m")}</div>
|
||||
<ResponsiveContainer width="100%" height="85%">
|
||||
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
@@ -165,7 +166,7 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
{l?.temps && Object.keys(l.temps).length > 0 && (
|
||||
<div className="rounded-[10px] border border-border">
|
||||
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
Temperatures
|
||||
{t("systems.temperatures")}
|
||||
</h2>
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
@@ -182,13 +183,23 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
{l?.containers && l.containers.length > 0 && (
|
||||
<div className="rounded-[10px] border border-border">
|
||||
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
Containers
|
||||
{t("systems.containers")}
|
||||
</h2>
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{l.containers.map((ctr) => (
|
||||
<tr key={ctr.name} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{ctr.name}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-[11px] text-text-faint">
|
||||
{ctr.state === "running" && (ctr.cpu || ctr.memUsed) ? (
|
||||
<>
|
||||
{(ctr.cpu ?? 0).toFixed(1)}%
|
||||
{" · "}
|
||||
{fmtBytes(ctr.memUsed ?? 0)}
|
||||
{ctr.memLimit ? ` / ${fmtBytes(ctr.memLimit)}` : ""}
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={cn("font-mono text-[10.5px] uppercase",
|
||||
ctr.state === "running" ? "text-up" : "text-text-faint")}>
|
||||
@@ -201,15 +212,92 @@ export function SystemDetailPage({ id }: { id: string }) {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{l?.smart && l.smart.length > 0 && (
|
||||
<div className="rounded-[10px] border border-border">
|
||||
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
{t("systems.drivesSmart")}
|
||||
</h2>
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{l.smart.map((d) => (
|
||||
<tr key={d.device} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{d.device}</td>
|
||||
<td className="px-4 py-2 text-[11px] text-text-dim">{d.model}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-[11px]">
|
||||
{d.tempC ? `${d.tempC.toFixed(0)}°C` : ""}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={cn("font-mono text-[10.5px] uppercase",
|
||||
d.passed === false ? "text-destructive" : "text-up")}>
|
||||
{d.passed === false ? t("systems.failing") : d.passed ? "ok" : "—"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{l?.zfs && l.zfs.length > 0 && (
|
||||
<div className="rounded-[10px] border border-border">
|
||||
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
{t("systems.zfs")}
|
||||
</h2>
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{l.zfs.map((p) => (
|
||||
<tr key={p.name} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{p.name}</td>
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
|
||||
{p.size ? `${fmtBytes((p.size ?? 0) - (p.free ?? 0))} / ${fmtBytes(p.size)}` : ""}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={cn("font-mono text-[10.5px] uppercase",
|
||||
p.health === "ONLINE" ? "text-up" : "text-destructive")}>
|
||||
{p.health}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{l?.gpu && l.gpu.length > 0 && (
|
||||
<div className="rounded-[10px] border border-border">
|
||||
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
|
||||
GPU
|
||||
</h2>
|
||||
<table className="w-full text-[12px]">
|
||||
<tbody>
|
||||
{l.gpu.map((g) => (
|
||||
<tr key={g.name} className="border-b border-border/60 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{g.name}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-[11px] text-text-dim">
|
||||
{[
|
||||
g.utilPct ? `${g.utilPct.toFixed(0)}%` : "",
|
||||
g.tempC ? `${g.tempC.toFixed(0)}°C` : "",
|
||||
g.memTotal ? `${fmtBytes(g.memUsed ?? 0)} / ${fmtBytes(g.memTotal)}` : "",
|
||||
].filter(Boolean).join(" · ")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Agent command</div>
|
||||
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("systems.agentCmd")}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-text-dim">
|
||||
{agentCmd(sys)}
|
||||
</code>
|
||||
<Button variant="outline" size="icon" aria-label="Copy"
|
||||
<Button variant="outline" size="icon" aria-label={t("systems.copy")}
|
||||
onClick={() => void navigator.clipboard.writeText(agentCmd(sys))}>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
const TB = 2 ** 40;
|
||||
const GB = 2 ** 30;
|
||||
@@ -79,7 +80,7 @@ export function SystemsPage() {
|
||||
}
|
||||
|
||||
async function remove(id: string | undefined, n: string | undefined) {
|
||||
if (!id || !confirm(`Delete system "${n}"?`)) return;
|
||||
if (!id || !confirm(t("systems.deleteConfirm", { name: n ?? "" }))) return;
|
||||
await api.DELETE("/api/systems/{id}", { params: { path: { id } } });
|
||||
await load();
|
||||
}
|
||||
@@ -88,19 +89,19 @@ export function SystemsPage() {
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Systems</h1>
|
||||
<p className="text-[12px] text-text-faint">Servers and PCs reporting via dash-agent.</p>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">{t("systems.title")}</h1>
|
||||
<p className="text-[12px] text-text-faint">{t("systems.subtitle")}</p>
|
||||
</div>
|
||||
<Button onClick={() => { setCreated(null); setDlgOpen(true); }}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New system
|
||||
<Plus size={13} strokeWidth={2.2} /> {t("systems.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && systems.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No systems yet.</p>
|
||||
<p className="text-[13px] text-text-dim">{t("systems.empty")}</p>
|
||||
<p className="mt-1 text-[11.5px] text-text-faint">
|
||||
Add one, then run <code className="font-mono">dash-agent</code> on the host with the issued token.
|
||||
{t("systems.emptyHintPre")} <code className="font-mono">dash-agent</code> {t("systems.emptyHintPost")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -122,18 +123,18 @@ export function SystemsPage() {
|
||||
<Link href={`/systems/${sys.id}`} className="min-w-0 flex-1 truncate text-[13px] font-medium hover:underline">
|
||||
{sys.name}
|
||||
</Link>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
<Button variant="outline" size="icon" aria-label={t("common.delete")}
|
||||
onClick={() => void remove(sys.id, sys.name)}>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</div>
|
||||
{sys.host ? (
|
||||
<p className="mb-2.5 truncate font-mono text-[10.5px] text-text-faint">
|
||||
{sys.host} · {sys.cpuModel || `${sys.cores} cores`}
|
||||
{sys.host} · {sys.cpuModel || t("systems.cores", { n: sys.cores ?? 0 })}
|
||||
{l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">waiting for agent…</p>
|
||||
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">{t("systems.waiting")}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{rows.map(([label, pct, txt]) => (
|
||||
@@ -153,43 +154,43 @@ export function SystemsPage() {
|
||||
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New system</DialogTitle>
|
||||
<DialogDescription>Name the host, then run dash-agent on it with the issued token.</DialogDescription>
|
||||
<DialogTitle>{t("systems.newTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("systems.newDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{created ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-[12px] text-text-dim">
|
||||
Agent command for <span className="font-medium text-text">{created.name}</span>:
|
||||
{t("systems.agentCmdFor")} <span className="font-medium text-text">{created.name}</span>:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded-[7px] border border-border bg-bg px-3 py-2 font-mono text-[11px]">
|
||||
{agentCmd(created)}
|
||||
</code>
|
||||
<Button variant="outline" size="icon" aria-label="Copy"
|
||||
<Button variant="outline" size="icon" aria-label={t("systems.copy")}
|
||||
onClick={() => void navigator.clipboard.writeText(agentCmd(created))}>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">
|
||||
The token identifies this system — keep it out of public repos.
|
||||
{t("systems.tokenWarning")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sys-name">Name</Label>
|
||||
<Label htmlFor="sys-name">{t("common.name")}</Label>
|
||||
<Input id="sys-name" value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="nas" autoFocus
|
||||
placeholder={t("systems.namePlaceholder")} autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void create(); }} />
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{created ? (
|
||||
<Button onClick={() => setDlgOpen(false)}>Done</Button>
|
||||
<Button onClick={() => setDlgOpen(false)}>{t("systems.done")}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
|
||||
<Button onClick={() => void create()} disabled={busy || !name.trim()}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
{busy ? t("common.creating") : t("common.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user