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:
Tomas DvorakandDevin committed 2026-09-22 12:49:51 +02:00
1 parent f3143af351
commit e0ef60eca6
49 files changed
+2131 -426

No files matched your search

+88 -46
View File
@@ -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>
+18 -18
View File
@@ -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>
+26 -25
View File
@@ -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>
+72
View File
@@ -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>
);
}
+60 -15
View File
@@ -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>
);
}
+18 -17
View File
@@ -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>
+38 -37
View File
@@ -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/&lt;slug&gt;</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/&lt;slug&gt;</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>
+12 -11
View File
@@ -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>
);
}
+109 -21
View File
@@ -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>
+20 -19
View File
@@ -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>
</>
)}