mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-29 00:01:24 +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
@@ -1,5 +1,6 @@
|
||||
import type { Item } from "@/types";
|
||||
import type { WidgetData } from "@/widgets/index";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Shared DNS-blocker layout: big block-rate, queries + blocked counts.
|
||||
// Pi-hole and AdGuard return the same fields from their fetchers.
|
||||
@@ -23,12 +24,12 @@ function BlockerWidget({ item, data, error }: { item: Item; data: WidgetData | u
|
||||
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||
{data ? `${pct.toFixed(1)}%` : "…"}
|
||||
</span>
|
||||
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">blocked</span>
|
||||
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.blockedLbl")}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
|
||||
<span>{fmt(num(data, "queriesToday"))} queries</span>
|
||||
<span>{fmt(num(data, "blockedToday"))} blocked</span>
|
||||
{typeof data?.avgProcessMs === "number" && <span>{num(data, "avgProcessMs").toFixed(0)} ms avg</span>}
|
||||
<span>{t("widget.queries", { n: fmt(num(data, "queriesToday")) })}</span>
|
||||
<span>{t("widget.blocked", { n: fmt(num(data, "blockedToday")) })}</span>
|
||||
{typeof data?.avgProcessMs === "number" && <span>{t("widget.msAvg", { n: num(data, "avgProcessMs").toFixed(0) })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WidgetData } from "@/widgets";
|
||||
import type { Item } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Board tile for a tracked domain: favicon, name, expiry countdown.
|
||||
export function DomainWidget({ item, data, error }: {
|
||||
@@ -29,7 +30,7 @@ export function DomainWidget({ item, data, error }: {
|
||||
"shrink-0 font-mono text-[10.5px]",
|
||||
error ? "text-down" : expiring || (days !== undefined && days < 0) ? "text-down" : "text-text-faint",
|
||||
)}>
|
||||
{error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`}
|
||||
{error ? t("widget.err") : days === undefined ? "…" : days < 0 ? t("domains.expired") : `${days}d`}
|
||||
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Item } from "@/types";
|
||||
import { cfgStr } from "@/widgets/index";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Embed — sandboxed iframe. Fully client-side; the tile chrome still shows
|
||||
// the item name above the frame.
|
||||
export function EmbedWidget({ item }: { item: Item }) {
|
||||
const url = cfgStr(item, "url");
|
||||
// Block script-capable schemes; http(s)/relative URLs are fine.
|
||||
const bad = /^\s*(javascript|data|vbscript|file):/i.test(url);
|
||||
if (!url || bad) {
|
||||
return <span className="text-[11px] text-text-faint">{t("widget.embedHint")}</span>;
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
src={url}
|
||||
title={item.name || "embed"}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
className="pointer-events-auto h-full min-h-[120px] w-full rounded-[6px] border border-border bg-bg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Item } from "@/types";
|
||||
import type { WidgetData } from "@/widgets/index";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Immich — photo/video counts and library footprint.
|
||||
|
||||
@@ -24,10 +25,10 @@ export function ImmichWidget({ item, data, error }: { item: Item; data: WidgetDa
|
||||
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
|
||||
{data ? fmtNum(num(data, "photos")) : "…"}
|
||||
</span>
|
||||
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">photos</span>
|
||||
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.photosLbl")}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
|
||||
<span>{fmtNum(num(data, "videos"))} videos</span>
|
||||
<span>{t("widget.videos", { n: fmtNum(num(data, "videos")) })}</span>
|
||||
<span>{fmtBytes(num(data, "usageBytes"))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Item } from "@/types";
|
||||
import { t } from "@/i18n";
|
||||
import { ClockWidget } from "@/widgets/clock";
|
||||
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
|
||||
import { ImmichWidget } from "@/widgets/immich";
|
||||
import { MonitorWidget } from "@/widgets/monitor";
|
||||
import { DomainWidget } from "@/widgets/domain";
|
||||
import { SystemWidget } from "@/widgets/system";
|
||||
import { EmbedWidget } from "@/widgets/embed";
|
||||
import { JsonPathWidget } from "@/widgets/jsonpath";
|
||||
|
||||
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
|
||||
// the component that fills it. Adding a widget = one fetcher in Go + one
|
||||
@@ -34,6 +37,8 @@ const registry: WidgetRegistry = {
|
||||
monitor: { view: MonitorWidget },
|
||||
domain: { view: DomainWidget },
|
||||
system: { view: SystemWidget },
|
||||
embed: { local: true, view: EmbedWidget },
|
||||
jsonpath: { view: JsonPathWidget },
|
||||
};
|
||||
|
||||
export function cfgStr(item: Item, key: string): string {
|
||||
@@ -66,7 +71,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
|
||||
const body: unknown = await res.json();
|
||||
if (dead) return;
|
||||
if (!res.ok) {
|
||||
let msg = "fetch failed";
|
||||
let msg = t("widget.fetchFailed");
|
||||
if (typeof body === "object" && body !== null && "error" in body) {
|
||||
msg = String(body.error);
|
||||
}
|
||||
@@ -79,7 +84,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
|
||||
setState({ data, error: undefined });
|
||||
}
|
||||
} catch {
|
||||
if (!dead) setState((s) => ({ data: s.data, error: "offline" }));
|
||||
if (!dead) setState((s) => ({ data: s.data, error: t("widget.offline") }));
|
||||
}
|
||||
if (!dead) timer = setTimeout(tick, 30_000);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { WidgetData } from "@/widgets";
|
||||
import type { Item } from "@/types";
|
||||
|
||||
// JSON value — renders the scalar the backend extracted at config.path.
|
||||
export function JsonPathWidget({ item, data, error }: {
|
||||
item: Item;
|
||||
data: WidgetData | undefined;
|
||||
error: string | undefined;
|
||||
}) {
|
||||
const label = typeof data?.label === "string" && data.label ? data.label : item.name;
|
||||
const value = data?.value;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col justify-center gap-0.5">
|
||||
<span className="truncate text-[10.5px] uppercase tracking-[0.1em] text-text-faint">
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[18px] font-medium leading-tight">
|
||||
{error ? "err" : value === undefined ? "…" : typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WidgetData } from "@/widgets";
|
||||
import type { Item } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Board tile for an uptime monitor: status dot, uptime %, avg ping.
|
||||
// config.monitorId binds it; data comes from the monitor fetcher.
|
||||
@@ -30,7 +31,7 @@ export function MonitorWidget({ item, data, error }: {
|
||||
{typeof data?.name === "string" ? data.name : item.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">
|
||||
{error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
|
||||
{error ? t("widget.err") : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
|
||||
{ping ? ` · ${Math.round(ping)}ms` : ""}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WidgetData } from "@/widgets";
|
||||
import type { Item } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/i18n";
|
||||
|
||||
// Board tile for a monitored system: status dot + cpu/mem/disk mini bars.
|
||||
// config.systemId binds it; data comes from the system fetcher.
|
||||
@@ -47,7 +48,7 @@ export function SystemWidget({ item, data, error }: {
|
||||
{typeof data?.name === "string" ? data.name : item.name}
|
||||
</span>
|
||||
{error ? (
|
||||
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">err</span>
|
||||
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">{t("widget.err")}</span>
|
||||
) : (
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{bar(cpu, "cpu")}
|
||||
|
||||
Reference in new issue
Block a user