mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-29 08:04:58 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user