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

+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>
);
}