Enrich the Dashboard integration widgets to match the new DNS/Secrets sections
The six integration cards only ever showed one headline number each (e.g. "3/5 devices online") -- thin compared to the new DNS/Secrets sections' stat-row-plus-breakdown layout. Each widget now shows 2-3 mini stats plus a compact breakdown bar, using data these endpoints already return (so no new API calls except one extra Synology call for CPU/RAM, matching what the Synology page itself already fetches): - Tailscale: online/unauthorized/expiring-soon stats + devices by OS - Proxmox: running/total + VM vs LXC counts + guests by node - Dockhand: running/total + host count + containers by state - Semaphore: template/failing counts + templates by last-run status - Gitea: repo/private/failing counts + repos by last-run status - Synology: volumes/unhealthy/CPU load + RAM used-of-total + disks by health status Extracted the breakdown-bar rendering out of the DNS/Secrets-only TypeBreakdown into a bare BreakdownBar (no card wrapper, optional compact sizing) so it drops into each integration card directly, and factored the per-item counting into a small breakdownFrom() helper used by all six. Status/state colors reuse the same palette each integration's own dedicated page already uses for its badges (Docker's container-state colors, Semaphore/Gitea's run-status colors); open- ended categories (OS names, Proxmox node names) get a generated palette instead since there's no fixed enum to hardcode against. Widget cards moved from a 3-column to a 2-column grid to fit the extra content without feeling cramped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5ec099c13
commit
16d64c42d8
2 files changed
+314
-69
No files matched your search
@@ -17,13 +17,15 @@ All modules from the original plan are built:
|
|||||||
|
|
||||||
- Monorepo scaffold, Tabler-themed app shell/navigation
|
- Monorepo scaffold, Tabler-themed app shell/navigation
|
||||||
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log
|
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log
|
||||||
- **Dashboard** — an overview of every system this app tracks, not just the
|
- **Dashboard** — an overview of every system this app tracks: DNS (domain/
|
||||||
six integrations: DNS (domain/record counts per provider, cached records
|
record counts per provider, cached records broken down by type), Secrets
|
||||||
broken down by type), Secrets (monitored/expiring/expired counts, broken
|
(monitored/expiring/expired counts, broken down by type), and a widget per
|
||||||
down by type), and the existing live per-integration widgets
|
integration with a small stat row plus its own breakdown — Tailscale by
|
||||||
(Proxmox/Synology/Semaphore/Tailscale/Gitea/Dockhand). Breakdowns render
|
OS, Proxmox by node (VM/LXC counts too), Dockhand by container state (plus
|
||||||
as a stacked proportion bar with a legend — no charting library, matching
|
host count), Semaphore and Gitea by last-run status (plus private-repo
|
||||||
the rest of the app's plain-Tabler-CSS approach.
|
count), and Synology's CPU/RAM alongside its disk-health breakdown.
|
||||||
|
Breakdowns render as a stacked proportion bar with a legend — no charting
|
||||||
|
library, matching the rest of the app's plain-Tabler-CSS approach.
|
||||||
- **Diagnostic Log** (admin-only) — every call this app makes to a DNS
|
- **Diagnostic Log** (admin-only) — every call this app makes to a DNS
|
||||||
provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea,
|
provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea,
|
||||||
Dockhand), success or failure, with latency and the error message if it
|
Dockhand), success or failure, with latency and the error message if it
|
||||||
|
|||||||
+305
-62
@@ -35,6 +35,57 @@ const SECRET_TYPE_COLORS: Record<SecretRecord["type"], string> = {
|
|||||||
generic: "#94a3b8",
|
generic: "#94a3b8",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONTAINER_STATE_COLORS: Record<string, string> = {
|
||||||
|
running: "#4ade80",
|
||||||
|
exited: "#f87171",
|
||||||
|
dead: "#f87171",
|
||||||
|
paused: "#fbbf24",
|
||||||
|
restarting: "#60a5fa",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_STATUS_COLORS: Record<string, string> = {
|
||||||
|
success: "#4ade80",
|
||||||
|
error: "#f87171",
|
||||||
|
stopped: "#94a3b8",
|
||||||
|
rejected: "#94a3b8",
|
||||||
|
waiting: "#fbbf24",
|
||||||
|
starting: "#fbbf24",
|
||||||
|
waiting_confirmation: "#fbbf24",
|
||||||
|
confirmed: "#fbbf24",
|
||||||
|
running: "#60a5fa",
|
||||||
|
stopping: "#60a5fa",
|
||||||
|
"never run": "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPO_STATUS_COLORS: Record<string, string> = {
|
||||||
|
success: "#4ade80",
|
||||||
|
failure: "#f87171",
|
||||||
|
cancelled: "#94a3b8",
|
||||||
|
skipped: "#94a3b8",
|
||||||
|
waiting: "#fbbf24",
|
||||||
|
queued: "#fbbf24",
|
||||||
|
pending: "#fbbf24",
|
||||||
|
running: "#60a5fa",
|
||||||
|
in_progress: "#60a5fa",
|
||||||
|
"no runs": "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GENERIC_PALETTE = ["#6366f1", "#22d3ee", "#f472b6", "#4ade80", "#fb923c", "#a3e635", "#c084fc", "#facc15", "#60a5fa"];
|
||||||
|
|
||||||
|
function paletteColors(labels: string[]): Record<string, string> {
|
||||||
|
const colors: Record<string, string> = {};
|
||||||
|
labels.forEach((label, i) => {
|
||||||
|
colors[label] = GENERIC_PALETTE[i % GENERIC_PALETTE.length];
|
||||||
|
});
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthColors(labels: string[]): Record<string, string> {
|
||||||
|
const colors: Record<string, string> = {};
|
||||||
|
for (const label of labels) colors[label] = label === "normal" ? "#4ade80" : "#f87171";
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) {
|
function SectionHeader({ title, hint }: { title: string; hint?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 mt-4">
|
<div className="mb-3 mt-4">
|
||||||
@@ -58,16 +109,21 @@ function StatTile({ label, value, sub, accent }: { label: string; value: ReactNo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TypeBreakdown({ title, data, colors }: { title: string; data: { label: string; count: number }[]; colors: Record<string, string> }) {
|
function breakdownFrom<T>(items: T[], keyOf: (item: T) => string): { label: string; count: number }[] {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = keyOf(item);
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakdownBar({ data, colors, compact }: { data: { label: string; count: number }[]; colors: Record<string, string>; compact?: boolean }) {
|
||||||
const total = data.reduce((s, d) => s + d.count, 0);
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="card mb-3">
|
<div>
|
||||||
<div className="card-header">
|
<div className="progress" style={{ height: compact ? 6 : 10 }}>
|
||||||
<h3 className="card-title">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="progress mb-3" style={{ height: 10 }}>
|
|
||||||
{data.map((d) => (
|
{data.map((d) => (
|
||||||
<div
|
<div
|
||||||
key={d.label}
|
key={d.label}
|
||||||
@@ -77,26 +133,50 @@ function TypeBreakdown({ title, data, colors }: { title: string; data: { label:
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex flex-wrap gap-3">
|
<div className={`d-flex flex-wrap gap-${compact ? "2" : "3"} mt-2`}>
|
||||||
{data.map((d) => (
|
{data.map((d) => (
|
||||||
<div key={d.label} className="d-flex align-items-center gap-2 small">
|
<div key={d.label} className={`d-flex align-items-center gap-1 ${compact ? "small" : ""}`}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
width: compact ? 8 : 10,
|
||||||
height: 10,
|
height: compact ? 8 : 10,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: colors[d.label] ?? "#94a3b8",
|
backgroundColor: colors[d.label] ?? "#94a3b8",
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span className={compact ? "text-secondary" : ""}>
|
||||||
{d.label} <span className="text-secondary">({d.count})</span>
|
{d.label} {compact ? `(${d.count})` : <span className="text-secondary">({d.count})</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBreakdown({ title, data, colors }: { title: string; data: { label: string; count: number }[]; colors: Record<string, string> }) {
|
||||||
|
if (data.reduce((s, d) => s + d.count, 0) === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<BreakdownBar data={data} colors={colors} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniStat({ label, value, accent }: { label: string; value: ReactNode; accent?: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="h4 mb-0" style={accent ? { color: accent } : undefined}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<div className="text-secondary small">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,33 +192,62 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [
|
|||||||
|
|
||||||
interface GiteaSummary {
|
interface GiteaSummary {
|
||||||
repoCount: number;
|
repoCount: number;
|
||||||
|
privateCount: number;
|
||||||
failing: number;
|
failing: number;
|
||||||
|
statusBreakdown: { label: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DockhandSummary {
|
interface DockhandSummary {
|
||||||
running: number;
|
running: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
hostCount: number;
|
||||||
|
stateBreakdown: { label: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SemaphoreSummary {
|
interface SemaphoreSummary {
|
||||||
total: number;
|
total: number;
|
||||||
failing: number;
|
failing: number;
|
||||||
|
statusBreakdown: { label: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxmoxSummary {
|
interface ProxmoxSummary {
|
||||||
running: number;
|
running: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
vmCount: number;
|
||||||
|
lxcCount: number;
|
||||||
|
nodeBreakdown: { label: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SynologySummary {
|
interface SynologySummary {
|
||||||
volumeCount: number;
|
volumeCount: number;
|
||||||
volumesNotNormal: number;
|
volumesNotNormal: number;
|
||||||
disksNotNormal: number;
|
disksNotNormal: number;
|
||||||
|
cpuLoadPercent: number | null;
|
||||||
|
memUsedBytes: number | null;
|
||||||
|
memTotalBytes: number | null;
|
||||||
|
diskStatusBreakdown: { label: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TailscaleExtra {
|
||||||
|
osBreakdown: { label: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null): string {
|
||||||
|
if (bytes === null) return "—";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(1)} ${units[unit]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard({ user }: { user: CurrentUser }) {
|
export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||||
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
||||||
|
const [tailscaleExtra, setTailscaleExtra] = useState<TailscaleExtra | null>(null);
|
||||||
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
|
const [giteaSummary, setGiteaSummary] = useState<GiteaSummary | null>(null);
|
||||||
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
|
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
|
||||||
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
||||||
@@ -172,8 +281,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
api.integrations.tailscale
|
api.integrations.tailscale
|
||||||
.devices(tailscale.id)
|
.devices(tailscale.id)
|
||||||
.then((res) => setTailscaleSummary(res.summary))
|
.then((res) => {
|
||||||
.catch(() => setTailscaleSummary(null));
|
setTailscaleSummary(res.summary);
|
||||||
|
setTailscaleExtra({ osBreakdown: breakdownFrom(res.devices, (d) => d.os || "unknown") });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setTailscaleSummary(null);
|
||||||
|
setTailscaleExtra(null);
|
||||||
|
});
|
||||||
}, [integrations]);
|
}, [integrations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -187,7 +302,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
.then((res) =>
|
.then((res) =>
|
||||||
setGiteaSummary({
|
setGiteaSummary({
|
||||||
repoCount: res.repos.length,
|
repoCount: res.repos.length,
|
||||||
|
privateCount: res.repos.filter((r) => r.private).length,
|
||||||
failing: res.repos.filter((r) => r.latestRun?.conclusion === "failure" || r.latestRun?.status === "failure").length,
|
failing: res.repos.filter((r) => r.latestRun?.conclusion === "failure" || r.latestRun?.status === "failure").length,
|
||||||
|
statusBreakdown: breakdownFrom(res.repos, (r) => (r.latestRun ? r.latestRun.conclusion || r.latestRun.status : "no runs")),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.catch(() => setGiteaSummary(null));
|
.catch(() => setGiteaSummary(null));
|
||||||
@@ -201,7 +318,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
api.integrations.dockhand
|
api.integrations.dockhand
|
||||||
.containers(dockhand.id)
|
.containers(dockhand.id)
|
||||||
.then((res) => setDockhandSummary({ running: res.summary.running, total: res.summary.total }))
|
.then((res) =>
|
||||||
|
setDockhandSummary({
|
||||||
|
running: res.summary.running,
|
||||||
|
total: res.summary.total,
|
||||||
|
hostCount: new Set(res.containers.map((c) => c.environmentName)).size,
|
||||||
|
stateBreakdown: breakdownFrom(res.containers, (c) => c.state),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch(() => setDockhandSummary(null));
|
.catch(() => setDockhandSummary(null));
|
||||||
}, [integrations]);
|
}, [integrations]);
|
||||||
|
|
||||||
@@ -213,7 +337,12 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
api.integrations.semaphore
|
api.integrations.semaphore
|
||||||
.templates(semaphore.id)
|
.templates(semaphore.id)
|
||||||
.then((res) => setSemaphoreSummary(res.summary))
|
.then((res) =>
|
||||||
|
setSemaphoreSummary({
|
||||||
|
...res.summary,
|
||||||
|
statusBreakdown: breakdownFrom(res.templates, (t) => t.lastTask?.status ?? "never run"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch(() => setSemaphoreSummary(null));
|
.catch(() => setSemaphoreSummary(null));
|
||||||
}, [integrations]);
|
}, [integrations]);
|
||||||
|
|
||||||
@@ -225,7 +354,15 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
}
|
}
|
||||||
api.integrations.proxmox
|
api.integrations.proxmox
|
||||||
.guests(proxmox.id)
|
.guests(proxmox.id)
|
||||||
.then((res) => setProxmoxSummary({ running: res.summary.running, total: res.summary.total }))
|
.then((res) =>
|
||||||
|
setProxmoxSummary({
|
||||||
|
running: res.summary.running,
|
||||||
|
total: res.summary.total,
|
||||||
|
vmCount: res.guests.filter((g) => g.type === "qemu").length,
|
||||||
|
lxcCount: res.guests.filter((g) => g.type === "lxc").length,
|
||||||
|
nodeBreakdown: breakdownFrom(res.guests, (g) => g.node),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.catch(() => setProxmoxSummary(null));
|
.catch(() => setProxmoxSummary(null));
|
||||||
}, [integrations]);
|
}, [integrations]);
|
||||||
|
|
||||||
@@ -235,14 +372,32 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
setSynologySummary(null);
|
setSynologySummary(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
api.integrations.synology
|
||||||
|
.system(synology.id)
|
||||||
|
.then((sys) =>
|
||||||
|
setSynologySummary((prev) => ({
|
||||||
|
volumeCount: prev?.volumeCount ?? 0,
|
||||||
|
volumesNotNormal: prev?.volumesNotNormal ?? 0,
|
||||||
|
disksNotNormal: prev?.disksNotNormal ?? 0,
|
||||||
|
diskStatusBreakdown: prev?.diskStatusBreakdown ?? [],
|
||||||
|
cpuLoadPercent: sys.cpu.loadPercent,
|
||||||
|
memUsedBytes: sys.memory.usedBytes,
|
||||||
|
memTotalBytes: sys.memory.totalBytes,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
api.integrations.synology
|
api.integrations.synology
|
||||||
.storage(synology.id)
|
.storage(synology.id)
|
||||||
.then((res) =>
|
.then((res) =>
|
||||||
setSynologySummary({
|
setSynologySummary((prev) => ({
|
||||||
|
cpuLoadPercent: prev?.cpuLoadPercent ?? null,
|
||||||
|
memUsedBytes: prev?.memUsedBytes ?? null,
|
||||||
|
memTotalBytes: prev?.memTotalBytes ?? null,
|
||||||
volumeCount: res.summary.volumeCount,
|
volumeCount: res.summary.volumeCount,
|
||||||
volumesNotNormal: res.summary.volumesNotNormal,
|
volumesNotNormal: res.summary.volumesNotNormal,
|
||||||
disksNotNormal: res.summary.disksNotNormal,
|
disksNotNormal: res.summary.disksNotNormal,
|
||||||
}),
|
diskStatusBreakdown: breakdownFrom(res.disks, (d) => d.status),
|
||||||
|
})),
|
||||||
)
|
)
|
||||||
.catch(() => setSynologySummary(null));
|
.catch(() => setSynologySummary(null));
|
||||||
}, [integrations]);
|
}, [integrations]);
|
||||||
@@ -376,10 +531,10 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
|
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-sm-6 col-lg-4" key={type}>
|
<div className="col-md-6" key={type}>
|
||||||
<div className="card">
|
<div className="card h-100">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="d-flex align-items-center">
|
<div className="d-flex align-items-center mb-2">
|
||||||
<div className="subheader">{label}</div>
|
<div className="subheader">{label}</div>
|
||||||
<div className="ms-auto">
|
<div className="ms-auto">
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
@@ -392,60 +547,148 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLiveTailscale ? (
|
{isLiveTailscale ? (
|
||||||
<div className="mt-2">
|
<>
|
||||||
<div className="h3 mb-0">
|
<div className="row g-2 mb-3">
|
||||||
{tailscaleSummary!.online}/{tailscaleSummary!.total}
|
<div className="col-4">
|
||||||
|
<MiniStat label="Online / Total" value={`${tailscaleSummary!.online}/${tailscaleSummary!.total}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-secondary">devices online</div>
|
<div className="col-4">
|
||||||
{tailscaleSummary!.unauthorized > 0 && (
|
<MiniStat
|
||||||
<div className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div>
|
label="Unauthorized"
|
||||||
)}
|
value={tailscaleSummary!.unauthorized}
|
||||||
{tailscaleSummary!.expiringSoon > 0 && (
|
accent={tailscaleSummary!.unauthorized > 0 ? "#f59e0b" : undefined}
|
||||||
<div className="text-yellow small mt-1">{tailscaleSummary!.expiringSoon} key(s) expiring soon</div>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat
|
||||||
|
label="Keys expiring"
|
||||||
|
value={tailscaleSummary!.expiringSoon}
|
||||||
|
accent={tailscaleSummary!.expiringSoon > 0 ? "#f59e0b" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tailscaleExtra && tailscaleExtra.osBreakdown.length > 0 && (
|
||||||
|
<BreakdownBar
|
||||||
|
data={tailscaleExtra.osBreakdown}
|
||||||
|
colors={paletteColors(tailscaleExtra.osBreakdown.map((d) => d.label))}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : isLiveGitea ? (
|
) : isLiveGitea ? (
|
||||||
<div className="mt-2">
|
<>
|
||||||
<div className="h3 mb-0">{giteaSummary!.repoCount}</div>
|
<div className="row g-2 mb-3">
|
||||||
<div className="text-secondary">repositories</div>
|
<div className="col-4">
|
||||||
{giteaSummary!.failing > 0 && (
|
<MiniStat label="Repositories" value={giteaSummary!.repoCount} />
|
||||||
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat label="Private" value={giteaSummary!.privateCount} />
|
||||||
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat
|
||||||
|
label="Failing builds"
|
||||||
|
value={giteaSummary!.failing}
|
||||||
|
accent={giteaSummary!.failing > 0 ? "#ef4444" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{giteaSummary!.statusBreakdown.length > 0 && (
|
||||||
|
<BreakdownBar data={giteaSummary!.statusBreakdown} colors={REPO_STATUS_COLORS} compact />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : isLiveDockhand ? (
|
) : isLiveDockhand ? (
|
||||||
<div className="mt-2">
|
<>
|
||||||
<div className="h3 mb-0">
|
<div className="row g-2 mb-3">
|
||||||
{dockhandSummary!.running}/{dockhandSummary!.total}
|
<div className="col-4">
|
||||||
|
<MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-secondary">containers running</div>
|
<div className="col-4">
|
||||||
|
<MiniStat label="Hosts" value={dockhandSummary!.hostCount} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat
|
||||||
|
label="Not running"
|
||||||
|
value={dockhandSummary!.total - dockhandSummary!.running}
|
||||||
|
accent={dockhandSummary!.total - dockhandSummary!.running > 0 ? "#f59e0b" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dockhandSummary!.stateBreakdown.length > 0 && (
|
||||||
|
<BreakdownBar data={dockhandSummary!.stateBreakdown} colors={CONTAINER_STATE_COLORS} compact />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : isLiveSemaphore ? (
|
) : isLiveSemaphore ? (
|
||||||
<div className="mt-2">
|
<>
|
||||||
<div className="h3 mb-0">{semaphoreSummary!.total}</div>
|
<div className="row g-2 mb-3">
|
||||||
<div className="text-secondary">templates</div>
|
<div className="col-6">
|
||||||
{semaphoreSummary!.failing > 0 && (
|
<MiniStat label="Templates" value={semaphoreSummary!.total} />
|
||||||
<div className="text-red small mt-1">{semaphoreSummary!.failing} last failed</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-6">
|
||||||
|
<MiniStat
|
||||||
|
label="Last failed"
|
||||||
|
value={semaphoreSummary!.failing}
|
||||||
|
accent={semaphoreSummary!.failing > 0 ? "#ef4444" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{semaphoreSummary!.statusBreakdown.length > 0 && (
|
||||||
|
<BreakdownBar data={semaphoreSummary!.statusBreakdown} colors={TASK_STATUS_COLORS} compact />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : isLiveProxmox ? (
|
) : isLiveProxmox ? (
|
||||||
<div className="mt-2">
|
<>
|
||||||
<div className="h3 mb-0">
|
<div className="row g-2 mb-3">
|
||||||
{proxmoxSummary!.running}/{proxmoxSummary!.total}
|
<div className="col-4">
|
||||||
|
<MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-secondary">VMs/containers running</div>
|
<div className="col-4">
|
||||||
|
<MiniStat label="VMs" value={proxmoxSummary!.vmCount} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat label="LXCs" value={proxmoxSummary!.lxcCount} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{proxmoxSummary!.nodeBreakdown.length > 1 && (
|
||||||
|
<BreakdownBar
|
||||||
|
data={proxmoxSummary!.nodeBreakdown}
|
||||||
|
colors={paletteColors(proxmoxSummary!.nodeBreakdown.map((d) => d.label))}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : isLiveSynology ? (
|
) : isLiveSynology ? (
|
||||||
<div className="mt-2">
|
<>
|
||||||
<div className="h3 mb-0">{synologySummary!.volumeCount}</div>
|
<div className="row g-2 mb-3">
|
||||||
<div className="text-secondary">volumes</div>
|
<div className="col-4">
|
||||||
{(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && (
|
<MiniStat label="Volumes" value={synologySummary!.volumeCount} />
|
||||||
<div className="text-red small mt-1">
|
</div>
|
||||||
{synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`}
|
<div className="col-4">
|
||||||
{synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "}
|
<MiniStat
|
||||||
{synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`}
|
label="Unhealthy"
|
||||||
|
value={synologySummary!.volumesNotNormal + synologySummary!.disksNotNormal}
|
||||||
|
accent={synologySummary!.volumesNotNormal + synologySummary!.disksNotNormal > 0 ? "#ef4444" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-4">
|
||||||
|
<MiniStat
|
||||||
|
label="CPU load"
|
||||||
|
value={synologySummary!.cpuLoadPercent !== null ? `${synologySummary!.cpuLoadPercent.toFixed(0)}%` : "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{synologySummary!.memTotalBytes !== null && (
|
||||||
|
<div className="text-secondary small mb-2">
|
||||||
|
RAM: {formatBytes(synologySummary!.memUsedBytes)} / {formatBytes(synologySummary!.memTotalBytes)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{synologySummary!.diskStatusBreakdown.length > 0 && (
|
||||||
|
<BreakdownBar
|
||||||
|
data={synologySummary!.diskStatusBreakdown}
|
||||||
|
colors={healthColors(synologySummary!.diskStatusBreakdown.map((d) => d.label))}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-secondary mt-2">
|
<div className="text-secondary mt-2">
|
||||||
{integration ? (
|
{integration ? (
|
||||||
|
|||||||
Reference in new issue
Block a user