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
|
||||
- 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
|
||||
six integrations: DNS (domain/record counts per provider, cached records
|
||||
broken down by type), Secrets (monitored/expiring/expired counts, broken
|
||||
down by type), and the existing live per-integration widgets
|
||||
(Proxmox/Synology/Semaphore/Tailscale/Gitea/Dockhand). Breakdowns render
|
||||
as a stacked proportion bar with a legend — no charting library, matching
|
||||
the rest of the app's plain-Tabler-CSS approach.
|
||||
- **Dashboard** — an overview of every system this app tracks: DNS (domain/
|
||||
record counts per provider, cached records broken down by type), Secrets
|
||||
(monitored/expiring/expired counts, broken down by type), and a widget per
|
||||
integration with a small stat row plus its own breakdown — Tailscale by
|
||||
OS, Proxmox by node (VM/LXC counts too), Dockhand by container state (plus
|
||||
host count), Semaphore and Gitea by last-run status (plus private-repo
|
||||
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
|
||||
provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea,
|
||||
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",
|
||||
};
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<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);
|
||||
if (total === 0) return null;
|
||||
return (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{title}</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="progress mb-3" style={{ height: 10 }}>
|
||||
<div>
|
||||
<div className="progress" style={{ height: compact ? 6 : 10 }}>
|
||||
{data.map((d) => (
|
||||
<div
|
||||
key={d.label}
|
||||
@@ -77,26 +133,50 @@ function TypeBreakdown({ title, data, colors }: { title: string; data: { label:
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-3">
|
||||
<div className={`d-flex flex-wrap gap-${compact ? "2" : "3"} mt-2`}>
|
||||
{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
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
width: compact ? 8 : 10,
|
||||
height: compact ? 8 : 10,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors[d.label] ?? "#94a3b8",
|
||||
display: "inline-block",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{d.label} <span className="text-secondary">({d.count})</span>
|
||||
<span className={compact ? "text-secondary" : ""}>
|
||||
{d.label} {compact ? `(${d.count})` : <span className="text-secondary">({d.count})</span>}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -112,33 +192,62 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [
|
||||
|
||||
interface GiteaSummary {
|
||||
repoCount: number;
|
||||
privateCount: number;
|
||||
failing: number;
|
||||
statusBreakdown: { label: string; count: number }[];
|
||||
}
|
||||
|
||||
interface DockhandSummary {
|
||||
running: number;
|
||||
total: number;
|
||||
hostCount: number;
|
||||
stateBreakdown: { label: string; count: number }[];
|
||||
}
|
||||
|
||||
interface SemaphoreSummary {
|
||||
total: number;
|
||||
failing: number;
|
||||
statusBreakdown: { label: string; count: number }[];
|
||||
}
|
||||
|
||||
interface ProxmoxSummary {
|
||||
running: number;
|
||||
total: number;
|
||||
vmCount: number;
|
||||
lxcCount: number;
|
||||
nodeBreakdown: { label: string; count: number }[];
|
||||
}
|
||||
|
||||
interface SynologySummary {
|
||||
volumeCount: number;
|
||||
volumesNotNormal: 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 }) {
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | 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 [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
|
||||
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
||||
@@ -172,8 +281,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
api.integrations.tailscale
|
||||
.devices(tailscale.id)
|
||||
.then((res) => setTailscaleSummary(res.summary))
|
||||
.catch(() => setTailscaleSummary(null));
|
||||
.then((res) => {
|
||||
setTailscaleSummary(res.summary);
|
||||
setTailscaleExtra({ osBreakdown: breakdownFrom(res.devices, (d) => d.os || "unknown") });
|
||||
})
|
||||
.catch(() => {
|
||||
setTailscaleSummary(null);
|
||||
setTailscaleExtra(null);
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -187,7 +302,9 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
.then((res) =>
|
||||
setGiteaSummary({
|
||||
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,
|
||||
statusBreakdown: breakdownFrom(res.repos, (r) => (r.latestRun ? r.latestRun.conclusion || r.latestRun.status : "no runs")),
|
||||
}),
|
||||
)
|
||||
.catch(() => setGiteaSummary(null));
|
||||
@@ -201,7 +318,14 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
api.integrations.dockhand
|
||||
.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));
|
||||
}, [integrations]);
|
||||
|
||||
@@ -213,7 +337,12 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
api.integrations.semaphore
|
||||
.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));
|
||||
}, [integrations]);
|
||||
|
||||
@@ -225,7 +354,15 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
api.integrations.proxmox
|
||||
.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));
|
||||
}, [integrations]);
|
||||
|
||||
@@ -235,14 +372,32 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
setSynologySummary(null);
|
||||
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
|
||||
.storage(synology.id)
|
||||
.then((res) =>
|
||||
setSynologySummary({
|
||||
setSynologySummary((prev) => ({
|
||||
cpuLoadPercent: prev?.cpuLoadPercent ?? null,
|
||||
memUsedBytes: prev?.memUsedBytes ?? null,
|
||||
memTotalBytes: prev?.memTotalBytes ?? null,
|
||||
volumeCount: res.summary.volumeCount,
|
||||
volumesNotNormal: res.summary.volumesNotNormal,
|
||||
disksNotNormal: res.summary.disksNotNormal,
|
||||
}),
|
||||
diskStatusBreakdown: breakdownFrom(res.disks, (d) => d.status),
|
||||
})),
|
||||
)
|
||||
.catch(() => setSynologySummary(null));
|
||||
}, [integrations]);
|
||||
@@ -376,10 +531,10 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
|
||||
|
||||
return (
|
||||
<div className="col-sm-6 col-lg-4" key={type}>
|
||||
<div className="card">
|
||||
<div className="col-md-6" key={type}>
|
||||
<div className="card h-100">
|
||||
<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="ms-auto">
|
||||
{isLive ? (
|
||||
@@ -392,60 +547,148 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
</div>
|
||||
{isLiveTailscale ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">
|
||||
{tailscaleSummary!.online}/{tailscaleSummary!.total}
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-4">
|
||||
<MiniStat label="Online / Total" value={`${tailscaleSummary!.online}/${tailscaleSummary!.total}`} />
|
||||
</div>
|
||||
<div className="text-secondary">devices online</div>
|
||||
{tailscaleSummary!.unauthorized > 0 && (
|
||||
<div className="text-yellow small mt-1">{tailscaleSummary!.unauthorized} awaiting authorization</div>
|
||||
)}
|
||||
{tailscaleSummary!.expiringSoon > 0 && (
|
||||
<div className="text-yellow small mt-1">{tailscaleSummary!.expiringSoon} key(s) expiring soon</div>
|
||||
)}
|
||||
<div className="col-4">
|
||||
<MiniStat
|
||||
label="Unauthorized"
|
||||
value={tailscaleSummary!.unauthorized}
|
||||
accent={tailscaleSummary!.unauthorized > 0 ? "#f59e0b" : undefined}
|
||||
/>
|
||||
</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 ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">{giteaSummary!.repoCount}</div>
|
||||
<div className="text-secondary">repositories</div>
|
||||
{giteaSummary!.failing > 0 && (
|
||||
<div className="text-red small mt-1">{giteaSummary!.failing} with a failing build</div>
|
||||
)}
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-4">
|
||||
<MiniStat label="Repositories" value={giteaSummary!.repoCount} />
|
||||
</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 ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">
|
||||
{dockhandSummary!.running}/{dockhandSummary!.total}
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-4">
|
||||
<MiniStat label="Running / Total" value={`${dockhandSummary!.running}/${dockhandSummary!.total}`} />
|
||||
</div>
|
||||
<div className="text-secondary">containers running</div>
|
||||
<div className="col-4">
|
||||
<MiniStat label="Hosts" value={dockhandSummary!.hostCount} />
|
||||
</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 ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">{semaphoreSummary!.total}</div>
|
||||
<div className="text-secondary">templates</div>
|
||||
{semaphoreSummary!.failing > 0 && (
|
||||
<div className="text-red small mt-1">{semaphoreSummary!.failing} last failed</div>
|
||||
)}
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-6">
|
||||
<MiniStat label="Templates" value={semaphoreSummary!.total} />
|
||||
</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 ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">
|
||||
{proxmoxSummary!.running}/{proxmoxSummary!.total}
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-4">
|
||||
<MiniStat label="Running / Total" value={`${proxmoxSummary!.running}/${proxmoxSummary!.total}`} />
|
||||
</div>
|
||||
<div className="text-secondary">VMs/containers running</div>
|
||||
<div className="col-4">
|
||||
<MiniStat label="VMs" value={proxmoxSummary!.vmCount} />
|
||||
</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 ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">{synologySummary!.volumeCount}</div>
|
||||
<div className="text-secondary">volumes</div>
|
||||
{(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && (
|
||||
<div className="text-red small mt-1">
|
||||
{synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`}
|
||||
{synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "}
|
||||
{synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`}
|
||||
<>
|
||||
<div className="row g-2 mb-3">
|
||||
<div className="col-4">
|
||||
<MiniStat label="Volumes" value={synologySummary!.volumeCount} />
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<MiniStat
|
||||
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>
|
||||
{synologySummary!.diskStatusBreakdown.length > 0 && (
|
||||
<BreakdownBar
|
||||
data={synologySummary!.diskStatusBreakdown}
|
||||
colors={healthColors(synologySummary!.diskStatusBreakdown.map((d) => d.label))}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-secondary mt-2">
|
||||
{integration ? (
|
||||
|
||||
Reference in new issue
Block a user