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

@@ -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 = {