feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) (#11)
All checks were successful
deploy / test-backend (push) Successful in 25m9s
deploy / test-frontend (push) Successful in 10m13s
deploy / build-and-push (push) Successful in 10m25s
deploy / deploy (push) Successful in 12s

This commit was merged in pull request #11.
This commit is contained in:
2026-06-16 18:46:28 +00:00
parent 8445f338f6
commit cde4aca98b
17 changed files with 1270 additions and 59 deletions

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { pickCpuTemp } from './temps';
import type { TempStat } from '$lib/api/admin';
function t(label: string, celsius: number): TempStat {
return { label, celsius, max_celsius: celsius, critical_celsius: 100 };
}
describe('pickCpuTemp', () => {
it('returns null when there are no sensors', () => {
expect(pickCpuTemp([])).toBeNull();
});
it('prefers the Intel package sensor over individual cores', () => {
const temps = [
t('coretemp Core 0', 50),
t('coretemp Core 1', 49),
t('coretemp Package id 0', 51),
t('acpitz temp1', 28)
];
expect(pickCpuTemp(temps)?.label).toBe('coretemp Package id 0');
});
it('falls back to the AMD Tctl/Tdie sensor when there is no package', () => {
const temps = [t('k10temp Tctl', 55), t('nvme Composite', 41)];
expect(pickCpuTemp(temps)?.label).toBe('k10temp Tctl');
});
it('falls back to the hottest core when only per-core sensors exist', () => {
const temps = [t('coretemp Core 0', 50), t('coretemp Core 3', 58), t('coretemp Core 1', 49)];
expect(pickCpuTemp(temps)?.label).toBe('coretemp Core 3');
});
it('falls back to any cpu/coretemp-labelled sensor as a last resort', () => {
const temps = [t('nvme Composite', 41), t('cpu_thermal', 47)];
expect(pickCpuTemp(temps)?.label).toBe('cpu_thermal');
});
it('returns null when nothing looks like a CPU sensor', () => {
const temps = [t('nvme Composite', 41), t('acpitz temp1', 28)];
expect(pickCpuTemp(temps)).toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
import type { TempStat } from '$lib/api/admin';
/** Pick the sensor that best represents the CPU die/package temperature
* from a heterogeneous component list. Intel exposes
* `coretemp Package id 0`; AMD exposes `k10temp Tctl`/`Tdie`. When neither
* is present we fall back to the hottest individual core, then to any
* CPU-ish sensor, and finally `null` (nothing CPU-related — e.g. only NVMe
* / chipset sensors are exposed). Used to keep the System tab focused on
* the one reading operators care about, with the rest tucked away. */
export function pickCpuTemp(temps: TempStat[]): TempStat | null {
const byLabel = (re: RegExp) => temps.find((t) => re.test(t.label)) ?? null;
return (
byLabel(/package/i) ??
byLabel(/tctl|tdie|k10temp/i) ??
hottestCore(temps) ??
byLabel(/cpu|coretemp/i) ??
null
);
}
function hottestCore(temps: TempStat[]): TempStat | null {
const cores = temps.filter((t) => /core\s*\d+/i.test(t.label));
if (cores.length === 0) return null;
return cores.reduce((a, b) => (b.celsius > a.celsius ? b : a));
}

View File

@@ -15,6 +15,7 @@ import {
listAdminMangas,
listAdminChapters,
getSystemStats,
getOverviewStats,
getStorageStats,
backfillStorage,
resyncManga,
@@ -88,7 +89,14 @@ const systemFixture = {
percent_used: 50.0
},
memory: { total_bytes: 8_000_000, used_bytes: 4_000_000, percent_used: 50.0 },
cpu: { percent_used: 12.3 },
cpu: {
percent_used: 12.3,
load_avg: { one: 0.81, five: 0.66, fifteen: 0.59 },
per_core: [10.0, 14.5, 8.2, 20.1]
},
temperatures: [
{ label: 'CPU Package', celsius: 52.0, max_celsius: 71.0, critical_celsius: 100.0 }
],
alerts: []
};
@@ -258,6 +266,10 @@ describe('admin api client', () => {
expect(s.disk?.percent_used).toBe(50);
expect(s.memory.percent_used).toBe(50);
expect(s.cpu.percent_used).toBe(12.3);
expect(s.cpu.load_avg.one).toBe(0.81);
expect(s.cpu.per_core).toHaveLength(4);
expect(s.temperatures[0].label).toBe('CPU Package');
expect(s.temperatures[0].critical_celsius).toBe(100);
expect(s.alerts).toEqual([]);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/system$/);
@@ -269,6 +281,43 @@ describe('admin api client', () => {
expect(s.disk).toBeNull();
});
it('getSystemStats tolerates an empty temperatures array (sensors unavailable)', async () => {
fetchSpy.mockResolvedValueOnce(ok({ ...systemFixture, temperatures: [] }));
const s = await getSystemStats();
expect(s.temperatures).toEqual([]);
});
it('getOverviewStats GETs /v1/admin/overview and parses the nested envelope', async () => {
const fixture = {
users: {
total: 7,
admins: 2,
newest_username: 'kenji',
newest_created_at: '2026-06-15T00:00:00Z'
},
mangas: {
total: 12,
synced: 10,
in_progress: 1,
dropped: 1,
total_chapters: 340,
total_pages: 9001,
newest_title: 'Sakamoto Days',
newest_seen_at: '2026-06-16T00:00:00Z'
},
analysis: { analyzed_pages: 5760, total_pages: 9001 }
};
fetchSpy.mockResolvedValueOnce(ok(fixture));
const s = await getOverviewStats();
expect(s.users.total).toBe(7);
expect(s.users.admins).toBe(2);
expect(s.mangas.synced).toBe(10);
expect(s.mangas.total_pages).toBe(9001);
expect(s.analysis.analyzed_pages).toBe(5760);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/overview$/);
});
// ---- force resync ----
it('resyncManga POSTs to /v1/admin/mangas/{id}/resync and returns the envelope', async () => {

View File

@@ -160,8 +160,24 @@ export type MemoryStats = {
percent_used: number;
};
export type LoadAvg = {
one: number;
five: number;
fifteen: number;
};
export type CpuStats = {
percent_used: number;
load_avg: LoadAvg;
/** Per-core usage percentages, one entry per logical core. */
per_core: number[];
};
export type TempStat = {
label: string;
celsius: number;
max_celsius: number | null;
critical_celsius: number | null;
};
export type Alert = {
@@ -173,6 +189,9 @@ export type SystemStats = {
disk: DiskStats | null;
memory: MemoryStats;
cpu: CpuStats;
/** Hardware temperature sensors. Empty when none are exposed (e.g.
* inside a container without `/sys` access). */
temperatures: TempStat[];
alerts: Alert[];
};
@@ -180,6 +199,41 @@ export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>('/v1/admin/system');
}
// ---- overview aggregates ---------------------------------------------------
export type UsersOverview = {
total: number;
admins: number;
newest_username: string | null;
newest_created_at: string | null;
};
export type MangasOverview = {
total: number;
synced: number;
in_progress: number;
dropped: number;
total_chapters: number;
total_pages: number;
newest_title: string | null;
newest_seen_at: string | null;
};
export type AnalysisOverview = {
analyzed_pages: number;
total_pages: number;
};
export type OverviewStats = {
users: UsersOverview;
mangas: MangasOverview;
analysis: AnalysisOverview;
};
export async function getOverviewStats(): Promise<OverviewStats> {
return request<OverviewStats>('/v1/admin/overview');
}
// ---- storage usage ---------------------------------------------------------
export type TopManga = {

View File

@@ -1,58 +1,431 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
getSystemStats,
getOverviewStats,
getCrawlerStatus,
crawlerStatusStreamUrl,
getAnalysisMetrics,
analysisStatusStreamUrl,
getCrawlerSettings,
getAnalysisSettings,
type SystemStats,
type OverviewStats,
type CrawlerStatus,
type AnalysisMetrics,
type AnalysisEvent
} from '$lib/api/admin';
import { formatBytes as fmtBytes } from '$lib/upload-validation';
import type { LayoutData } from './$types';
let { data }: { data: LayoutData } = $props();
const stats = $derived(data.stats);
function fmtPercent(n: number): string {
return `${n.toFixed(1)}%`;
let { data }: { data: LayoutData } = $props();
// System stats are loaded once by the layout gate; seed from there and
// keep polling so the Overview's System card stays live. Capturing the
// initial `data.stats` is intentional — we own `sys` after mount.
// svelte-ignore state_referenced_locally
let sys: SystemStats = $state(data.stats);
let overview: OverviewStats | null = $state(null);
let crawler: CrawlerStatus | null = $state(null);
let metrics: AnalysisMetrics | null = $state(null);
let nowAnalyzing: {
title: string;
chapter: number;
page: number;
phase: 'analyzing' | 'done' | 'failed';
} | null = $state(null);
// Settings strip — daemon state + a couple of effective values.
let crawlerDaemon = $state<boolean | null>(null);
let crawlerPassAt = $state<string | null>(null);
let analysisEnabled = $state<boolean | null>(null);
let analysisModel = $state<string | null>(null);
let analysisEndpointOk = $state<boolean | null>(null);
let liveCrawler = $state(false);
let liveAnalysis = $state(false);
let sysTimer: ReturnType<typeof setInterval> | null = null;
let overviewTimer: ReturnType<typeof setInterval> | null = null;
let crawlerSource: EventSource | null = null;
let analysisSource: EventSource | null = null;
const coverage = $derived.by(() => {
if (!overview) return 0;
const a = overview.analysis;
return a.total_pages > 0 ? (a.analyzed_pages / a.total_pages) * 100 : 0;
});
// Alerts rail: system thresholds plus crawler health signals.
const extraAlerts = $derived.by(() => {
const out: string[] = [];
if (crawler) {
if (crawler.queue.dead > 0) out.push(`crawler: ${crawler.queue.dead} dead job(s)`);
if (crawler.session.expired) out.push('crawler: session expired');
}
return out;
});
async function refreshSys() {
try {
sys = await getSystemStats();
} catch {
// keep the last good snapshot; transient errors self-heal next poll
}
}
async function refreshOverview() {
try {
overview = await getOverviewStats();
} catch {
// ignore; retried on the next tick
}
}
async function refreshMetrics() {
try {
metrics = await getAnalysisMetrics(1);
} catch {
// ignore
}
}
async function loadSettings() {
try {
const c = await getCrawlerSettings();
crawlerDaemon = c.editable.daemon_enabled;
crawlerPassAt = c.editable.daily_at;
} catch {
// strip degrades to "—"
}
try {
const a = await getAnalysisSettings();
analysisEnabled = a.editable.enabled;
analysisModel = a.editable.model;
analysisEndpointOk = a.env_only.api_key_configured && a.editable.endpoint.length > 0;
} catch {
// strip degrades to "—"
}
}
function fmtBytes(n: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let i = 0;
let v = n;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
function applyAnalysisEvent(ev: AnalysisEvent) {
if (ev.kind === 'enqueued') return;
const phase =
ev.kind === 'started' ? 'analyzing' : ev.kind === 'completed' ? 'done' : 'failed';
nowAnalyzing = {
title: ev.manga_title,
chapter: ev.chapter_number,
page: ev.page_number,
phase
};
}
function openCrawlerStream() {
if (crawlerSource) return;
const es = new EventSource(crawlerStatusStreamUrl(), { withCredentials: true });
es.addEventListener('status', (e) => {
try {
crawler = JSON.parse((e as MessageEvent).data) as CrawlerStatus;
liveCrawler = true;
} catch {
// skip a malformed frame; the next replaces it
}
});
es.onopen = () => (liveCrawler = true);
es.onerror = () => (liveCrawler = false);
crawlerSource = es;
}
function openAnalysisStream() {
if (analysisSource) return;
const es = new EventSource(analysisStatusStreamUrl(), { withCredentials: true });
es.addEventListener('analysis', (e) => {
try {
applyAnalysisEvent(JSON.parse((e as MessageEvent).data) as AnalysisEvent);
liveAnalysis = true;
} catch {
// skip malformed frame
}
});
// Dropped frames — pull a fresh coverage snapshot. (The 30s poll
// would catch up anyway; this just tightens the gap.)
es.addEventListener('lagged', () => {
refreshOverview();
refreshMetrics();
});
es.onopen = () => (liveAnalysis = true);
es.onerror = () => (liveAnalysis = false);
analysisSource = es;
}
function closeStreams() {
crawlerSource?.close();
crawlerSource = null;
analysisSource?.close();
analysisSource = null;
liveCrawler = false;
liveAnalysis = false;
}
function onVisibilityChange() {
if (typeof document === 'undefined') return;
if (document.visibilityState === 'hidden') {
closeStreams();
} else {
openCrawlerStream();
openAnalysisStream();
}
return `${v.toFixed(1)} ${units[i]}`;
}
// Close the streams entering BFCache and reopen on restore, so a
// back/forward navigation doesn't leave a stale connection behind.
function onPageHide() {
closeStreams();
}
function onPageShow() {
openCrawlerStream();
openAnalysisStream();
}
onMount(() => {
refreshSys();
refreshOverview();
refreshMetrics();
loadSettings();
getCrawlerStatus()
.then((s) => (crawler = s))
.catch(() => {});
openCrawlerStream();
openAnalysisStream();
sysTimer = setInterval(refreshSys, 5000);
overviewTimer = setInterval(() => {
refreshOverview();
refreshMetrics();
}, 30000);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
}
});
onDestroy(() => {
if (sysTimer) clearInterval(sysTimer);
if (overviewTimer) clearInterval(overviewTimer);
closeStreams();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
}
});
function fmtPercent(pct: number): string {
return `${pct.toFixed(0)}%`;
}
function fmtInt(n: number): string {
return n.toLocaleString();
}
function ago(iso: string | null): string {
if (!iso) return '';
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return `${Math.floor(s)}s ago`;
const mins = s / 60;
if (mins < 60) return `${Math.floor(mins)}m ago`;
const hrs = mins / 60;
if (hrs < 24) return `${Math.floor(hrs)}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function secs(ms: number | null): string {
return ms === null ? '—' : `${(ms / 1000).toFixed(1)}s`;
}
</script>
<h1>Overview</h1>
{#if stats.alerts.length > 0}
{#if sys.alerts.length > 0 || extraAlerts.length > 0}
<section class="alerts" data-testid="admin-alerts">
{#each stats.alerts as a (a.message)}
{#each sys.alerts as a (a.message)}
<div class="alert" data-level={a.level}>{a.message}</div>
{/each}
{#each extraAlerts as msg (msg)}
<div class="alert" data-level="warning">{msg}</div>
{/each}
</section>
{/if}
<section class="cards">
{#if stats.disk}
<article class="card">
<h3>Disk</h3>
<p class="metric">{fmtPercent(stats.disk.percent_used)}</p>
<p class="sub">{fmtBytes(stats.disk.used_bytes)} of {fmtBytes(stats.disk.total_bytes)} used</p>
</article>
{:else}
<article class="card muted">
<h3>Disk</h3>
<p>n/a (non-local storage)</p>
</article>
{/if}
<article class="card">
<h3>Memory</h3>
<p class="metric">{fmtPercent(stats.memory.percent_used)}</p>
<p class="sub">{fmtBytes(stats.memory.used_bytes)} of {fmtBytes(stats.memory.total_bytes)} used</p>
</article>
<article class="card">
<h3>CPU</h3>
<p class="metric">{fmtPercent(stats.cpu.percent_used)}</p>
<p class="sub">global load</p>
</article>
</section>
<div class="sections">
<!-- System -->
<section class="card">
<header>
<h2>System</h2>
<a class="view" href="/admin/system">View →</a>
</header>
{#if sys.disk}
{@render MeterRow(
'Disk',
sys.disk.percent_used,
`${fmtBytes(sys.disk.used_bytes)} / ${fmtBytes(sys.disk.total_bytes)}`
)}
{:else}
{@render MeterRow('Disk', 0, 'n/a — non-local storage')}
{/if}
{@render MeterRow(
'Memory',
sys.memory.percent_used,
`${fmtBytes(sys.memory.used_bytes)} / ${fmtBytes(sys.memory.total_bytes)}`
)}
{@render MeterRow('CPU', sys.cpu.percent_used, `load ${sys.cpu.load_avg.one.toFixed(2)}`)}
</section>
<!-- Crawler -->
<section class="card">
<header>
<h2>Crawler</h2>
<span
class="live"
class:on={liveCrawler}
title={liveCrawler ? 'live' : 'reconnecting'}
></span>
<a class="view" href="/admin/crawler">View →</a>
</header>
{#if crawler}
<p class="line">
<span class="dot" class:ok={crawler.daemon === 'running'}></span>
{crawler.daemon} · phase {crawler.phase?.state ?? 'idle'} · {crawler.worker_count} workers
</p>
<p class="line">
Queue pending {fmtInt(crawler.queue.pending)} · running {fmtInt(crawler.queue.running)}
· <span class:warn={crawler.queue.dead > 0}>dead {fmtInt(crawler.queue.dead)}</span>
· covers {fmtInt(crawler.covers_queued)}
</p>
<p class="line muted">
browser {crawler.browser} · session {crawler.session.configured ? '✓' : '—'} · last
pass +{fmtInt(crawler.last_pass.upserted)} / {fmtInt(crawler.last_pass.discovered)} seen
/ {fmtInt(crawler.last_pass.mangas_failed)} failed
</p>
{:else}
<p class="muted">Loading…</p>
{/if}
</section>
<!-- Analysis -->
<section class="card">
<header>
<h2>Analysis</h2>
<span
class="live"
class:on={liveAnalysis}
title={liveAnalysis ? 'live' : 'reconnecting'}
></span>
<a class="view" href="/admin/analysis">View →</a>
</header>
{#if overview}
{@render MeterRow(
'Coverage',
coverage,
`${fmtInt(overview.analysis.analyzed_pages)} / ${fmtInt(overview.analysis.total_pages)} pages`
)}
{/if}
{#if nowAnalyzing}
<p class="line">
{nowAnalyzing.phase === 'analyzing' ? 'now' : 'last'}:
<strong>{nowAnalyzing.title}</strong> ch.{nowAnalyzing.chapter} p.{nowAnalyzing.page}
· {nowAnalyzing.phase}
</p>
{/if}
{#if metrics}
<p class="line muted">
last 24h {fmtInt(metrics.ok)} ok · {fmtInt(metrics.failed)} failed · ⌀ {secs(
metrics.avg_ms
)}/page
</p>
{/if}
</section>
<!-- Mangas -->
<section class="card">
<header>
<h2>Mangas</h2>
<a class="view" href="/admin/mangas">View →</a>
</header>
{#if overview}
<p class="line big">
{fmtInt(overview.mangas.total)} mangas · {fmtInt(overview.mangas.total_chapters)} chapters
· {fmtInt(overview.mangas.total_pages)} pages
</p>
<p class="line">
synced {fmtInt(overview.mangas.synced)} · in progress {fmtInt(
overview.mangas.in_progress
)} · dropped {fmtInt(overview.mangas.dropped)}
</p>
{#if overview.mangas.newest_title}
<p class="line muted">
newest: {overview.mangas.newest_title}{overview.mangas.newest_seen_at
? ` (seen ${ago(overview.mangas.newest_seen_at)})`
: ''}
</p>
{/if}
{:else}
<p class="muted">Loading…</p>
{/if}
</section>
<!-- Users -->
<section class="card">
<header>
<h2>Users</h2>
<a class="view" href="/admin/users">View →</a>
</header>
{#if overview}
<p class="line big">
{fmtInt(overview.users.total)} users · {fmtInt(overview.users.admins)} admins
</p>
{#if overview.users.newest_username}
<p class="line muted">
newest: {overview.users.newest_username}{overview.users.newest_created_at
? ` (created ${ago(overview.users.newest_created_at)})`
: ''}
</p>
{/if}
{:else}
<p class="muted">Loading…</p>
{/if}
</section>
<!-- Settings (thin daemon-status strip) -->
<section class="card strip">
<div class="strip-body">
<span>
<span class="dot" class:ok={crawlerDaemon === true}></span>
Crawler {crawlerDaemon === null ? '—' : crawlerDaemon ? 'enabled' : 'disabled'}
{#if crawlerPassAt}· pass {crawlerPassAt}{/if}
</span>
<span class="sep">|</span>
<span>
<span class="dot" class:ok={analysisEnabled === true}></span>
Analysis {analysisEnabled === null ? '—' : analysisEnabled ? 'enabled' : 'disabled'}
{#if analysisModel}· {analysisModel}{/if}
{#if analysisEndpointOk !== null}· endpoint {analysisEndpointOk ? '✓' : '—'}{/if}
</span>
</div>
<a class="view" href="/admin/settings">View →</a>
</section>
</div>
{#snippet MeterRow(label: string, percent: number, sub: string)}
<div class="meter">
<span class="meter-label">{label}</span>
<div
class="bar"
role="progressbar"
aria-valuenow={percent}
aria-valuemin="0"
aria-valuemax="100"
aria-label="{label} {fmtPercent(percent)}"
>
<div
class="fill"
class:high={percent >= 90}
class:mid={percent >= 70 && percent < 90}
style:width="{Math.min(100, Math.max(0, percent))}%"
></div>
<span class="bar-label">{fmtPercent(percent)}</span>
</div>
<span class="meter-sub">{sub}</span>
</div>
{/snippet}
<style>
h1 {
@@ -68,37 +441,141 @@
padding: var(--space-3);
border-radius: var(--radius-md);
background: var(--surface-elevated);
border-left: 4px solid var(--warning, #f59e0b);
border-left: 4px solid #f59e0b;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
.sections {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.card {
padding: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.card.muted {
opacity: 0.6;
.card header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.card h3 {
margin: 0 0 var(--space-2) 0;
.card header h2 {
margin: 0;
font-size: var(--font-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric {
margin: 0;
font-size: var(--font-xl, 1.5rem);
font-weight: var(--weight-semibold);
.view {
margin-left: auto;
font-size: var(--font-sm);
color: var(--text-muted);
text-decoration: none;
}
.sub {
margin: var(--space-1) 0 0 0;
.view:hover {
color: var(--text);
}
.live {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.4;
}
.live.on {
background: #22c55e;
opacity: 1;
}
.meter {
display: grid;
grid-template-columns: 5rem 1fr minmax(8rem, max-content);
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.meter:last-child {
margin-bottom: 0;
}
.meter-label {
font-size: var(--font-sm);
color: var(--text-muted);
}
.meter-sub {
font-size: var(--font-sm);
color: var(--text-muted);
font-family: var(--font-mono, monospace);
text-align: right;
}
.bar {
position: relative;
background: var(--surface-elevated);
border-radius: var(--radius-sm, 4px);
height: 1.25rem;
overflow: hidden;
}
.fill {
height: 100%;
background: #22c55e;
transition: width 0.3s ease, background 0.3s ease;
}
.fill.mid {
background: #f59e0b;
}
.fill.high {
background: #dc2626;
}
.bar-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-xs, 0.75rem);
font-weight: var(--weight-semibold);
color: var(--text);
}
.line {
margin: 0 0 var(--space-2) 0;
font-size: var(--font-sm);
}
.line:last-child {
margin-bottom: 0;
}
.line.big {
font-size: var(--font-md, 1rem);
font-weight: var(--weight-semibold);
}
.muted {
color: var(--text-muted);
}
.warn {
color: #f59e0b;
font-weight: var(--weight-semibold);
}
.dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--text-muted);
margin-right: var(--space-1);
}
.dot.ok {
background: #22c55e;
}
.strip {
display: flex;
align-items: center;
gap: var(--space-3);
}
.strip-body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
font-size: var(--font-sm);
}
.strip .sep {
color: var(--border);
}
</style>

View File

@@ -8,8 +8,16 @@
type StorageStats
} from '$lib/api/admin';
import { formatBytes as fmtBytes } from '$lib/upload-validation';
import { pickCpuTemp } from '$lib/admin/temps';
let stats: SystemStats | null = $state(null);
// The component list is noisy (every core, chipset, NVMe…). Surface the
// CPU die/package reading and tuck the rest behind a disclosure.
const cpuTemp = $derived.by(() => (stats ? pickCpuTemp(stats.temperatures) : null));
const otherTemps = $derived.by(() =>
stats ? stats.temperatures.filter((t) => t !== cpuTemp) : []
);
let error: string | null = $state(null);
let timer: ReturnType<typeof setInterval> | null = null;
let storageTimer: ReturnType<typeof setInterval> | null = null;
@@ -132,6 +140,57 @@
<article>
<h2>CPU</h2>
{@render Bar({ percent: stats.cpu.percent_used })}
<dl>
<dt>Load avg</dt>
<dd>
{stats.cpu.load_avg.one.toFixed(2)} / {stats.cpu.load_avg.five.toFixed(2)} / {stats.cpu.load_avg.fifteen.toFixed(
2
)}
</dd>
</dl>
{#if stats.cpu.per_core.length > 0}
<div class="cores" aria-label="per-core usage">
{#each stats.cpu.per_core as core, i (i)}
<div
class="core"
title="core {i}: {core.toFixed(0)}%"
role="progressbar"
aria-valuenow={core}
aria-valuemin="0"
aria-valuemax="100"
aria-label="core {i} {core.toFixed(0)}%"
>
<div
class="core-fill"
class:high={core >= 90}
class:mid={core >= 70 && core < 90}
style:height="{Math.min(100, Math.max(0, core))}%"
></div>
</div>
{/each}
</div>
{/if}
</article>
<article>
<h2>Temperatures</h2>
{#if stats.temperatures.length === 0}
<p class="muted">Sensor data unavailable (not exposed in this environment).</p>
{:else if cpuTemp}
{@render Temp(cpuTemp)}
{#if otherTemps.length > 0}
<details class="more-temps">
<summary>{otherTemps.length} other sensor{otherTemps.length === 1 ? '' : 's'}</summary>
{#each otherTemps as t (t.label)}
{@render Temp(t)}
{/each}
</details>
{/if}
{:else}
{#each stats.temperatures as t (t.label)}
{@render Temp(t)}
{/each}
{/if}
</article>
</section>
@@ -231,6 +290,37 @@
{/if}
</section>
{#snippet Temp(t: { label: string; celsius: number; max_celsius: number | null; critical_celsius: number | null })}
{@const limit = t.critical_celsius ?? 100}
{@const pct = Math.min(100, Math.max(0, (t.celsius / limit) * 100))}
<div class="temp">
<div class="temp-head">
<span class="temp-label">{t.label}</span>
<span class="temp-val">{t.celsius.toFixed(0)}°C</span>
</div>
<div
class="bar"
role="progressbar"
aria-valuenow={t.celsius}
aria-valuemin="0"
aria-valuemax={limit}
aria-label="{t.label} {t.celsius.toFixed(0)}°C"
>
<div
class="fill"
class:high={pct >= 90}
class:mid={pct >= 70 && pct < 90}
style:width="{pct}%"
></div>
</div>
<p class="sub">
{#if t.max_celsius !== null}max {t.max_celsius.toFixed(0)}°C{/if}
{#if t.critical_celsius !== null}
· crit {t.critical_celsius.toFixed(0)}°C{/if}
</p>
</div>
{/snippet}
{#snippet Bar({ percent }: { percent: number })}
<div
class="bar"
@@ -300,6 +390,63 @@
font-weight: var(--weight-semibold);
color: var(--text);
}
.cores {
display: flex;
align-items: flex-end;
gap: 3px;
height: 2.5rem;
margin-top: var(--space-2);
}
.core {
position: relative;
flex: 1 1 0;
min-width: 4px;
height: 100%;
background: var(--surface-elevated);
border-radius: var(--radius-sm, 4px);
overflow: hidden;
display: flex;
align-items: flex-end;
}
.core-fill {
width: 100%;
background: #22c55e;
transition: height 0.3s ease, background 0.3s ease;
}
.core-fill.mid {
background: #f59e0b;
}
.core-fill.high {
background: #dc2626;
}
.temp + .temp {
margin-top: var(--space-3);
}
.more-temps {
margin-top: var(--space-3);
}
.more-temps summary {
cursor: pointer;
font-size: var(--font-sm);
color: var(--text-muted);
}
.more-temps .temp:first-of-type {
margin-top: var(--space-3);
}
.temp-head {
display: flex;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-1);
font-size: var(--font-sm);
}
.temp-label {
color: var(--text-muted);
}
.temp-val {
font-family: var(--font-mono, monospace);
font-weight: var(--weight-semibold);
}
dl {
display: grid;
grid-template-columns: max-content 1fr;