feat(admin): overview dashboard sections + system temperature/load sensors (0.85.0) (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user