[verified] fix: normalize analytics skill payloads

Normalize /api/analytics/usage responses so older backends that omit
skill analytics do not crash the dashboard.

- add normalizeAnalyticsResponse at the API boundary
- type the page state against the normalized payload
- cover missing and populated skill payloads with tests
This commit is contained in:
Jean Clawd 2026-04-23 21:59:13 +02:00
parent e91be4d7dc
commit 6e4c8f2aa7
3 changed files with 105 additions and 4 deletions

View file

@ -0,0 +1,74 @@
/// <reference types="node" />
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeAnalyticsResponse, type AnalyticsResponse } from "./api.ts";
test("normalizeAnalyticsResponse fills in missing skill analytics for older backends", () => {
const raw = {
daily: [],
by_model: [],
totals: {
total_input: 0,
total_output: 0,
total_cache_read: 0,
total_reasoning: 0,
total_estimated_cost: 0,
total_actual_cost: 0,
total_sessions: 0,
total_api_calls: 0,
},
} as AnalyticsResponse;
const normalized = normalizeAnalyticsResponse(raw);
assert.deepEqual(normalized.skills, {
summary: {
total_skill_loads: 0,
total_skill_edits: 0,
total_skill_actions: 0,
distinct_skills_used: 0,
},
top_skills: [],
});
});
test("normalizeAnalyticsResponse preserves populated skill analytics", () => {
const raw: AnalyticsResponse = {
daily: [],
by_model: [],
totals: {
total_input: 0,
total_output: 0,
total_cache_read: 0,
total_reasoning: 0,
total_estimated_cost: 0,
total_actual_cost: 0,
total_sessions: 0,
total_api_calls: 0,
},
skills: {
summary: {
total_skill_loads: 2,
total_skill_edits: 1,
total_skill_actions: 3,
distinct_skills_used: 2,
},
top_skills: [
{
skill: "systematic-debugging",
view_count: 2,
manage_count: 1,
total_count: 3,
percentage: 100,
last_used_at: 1713900000,
},
],
},
};
const normalized = normalizeAnalyticsResponse(raw);
assert.deepEqual(normalized.skills, raw.skills);
});

View file

@ -52,8 +52,8 @@ export const api = {
if (params.component && params.component !== "all") qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getAnalytics: (days: number): Promise<NormalizedAnalyticsResponse> =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`).then(normalizeAnalyticsResponse),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
@ -355,10 +355,32 @@ export interface AnalyticsResponse {
total_sessions: number;
total_api_calls: number;
};
skills?: {
summary?: AnalyticsSkillsSummary;
top_skills?: AnalyticsSkillEntry[];
};
}
export type NormalizedAnalyticsResponse = Omit<AnalyticsResponse, "skills"> & {
skills: {
summary: AnalyticsSkillsSummary;
top_skills: AnalyticsSkillEntry[];
};
};
export function normalizeAnalyticsResponse(raw: AnalyticsResponse): NormalizedAnalyticsResponse {
return {
...raw,
skills: {
summary: {
total_skill_loads: raw.skills?.summary?.total_skill_loads ?? 0,
total_skill_edits: raw.skills?.summary?.total_skill_edits ?? 0,
total_skill_actions: raw.skills?.summary?.total_skill_actions ?? 0,
distinct_skills_used: raw.skills?.summary?.distinct_skills_used ?? 0,
},
top_skills: Array.isArray(raw.skills?.top_skills) ? raw.skills.top_skills : [],
},
};
}
export interface CronJob {

View file

@ -7,7 +7,12 @@ import {
TrendingUp,
} from "lucide-react";
import { api } from "@/lib/api";
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api";
import type {
AnalyticsDailyEntry,
AnalyticsModelEntry,
AnalyticsSkillEntry,
NormalizedAnalyticsResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -277,7 +282,7 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
export default function AnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [data, setData] = useState<NormalizedAnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();