dsh-tool-stats 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,10 @@
1
1
  /**
2
- * Pure rendering half of the toolStats page.
3
- *
4
- * Separate from `index.tsx` so a static render can assert in Node what the page draws —
5
- * the shipped bundle is a loader factory only a browser can run. Every user-visible
6
- * string comes from the `t` seat the renderer binds from this plugin's namespace, so the
7
- * page follows the UI language; no copy is hardcoded here.
8
- *
2
+ * Pure rendering half of the toolStats page. Uses shared UI kit.
9
3
  * @module client/view
10
4
  */
11
- import type { PanelPayload } from '../types.js';
12
- /** The translate seat the renderer binds from this plugin's locale namespace. */
5
+ import type { ReactNode } from 'react';
13
6
  export type Translate = (key: string, params?: Record<string, unknown>) => string;
14
7
  export interface PanelProps {
15
- /** Bound translate function for this plugin's namespace. */
16
8
  t: Translate;
17
9
  }
18
- interface PanelState {
19
- payload: PanelPayload | null;
20
- error: string | null;
21
- }
22
- /** Fetch the host panel payload; `reload` re-runs the request. */
23
- export declare function usePanel(): PanelState & {
24
- reload: () => void;
25
- };
26
- export declare function ToolStatsPanel({ t }: PanelProps): import("react").JSX.Element;
27
- export {};
10
+ export declare function ToolStatsPanel({ t }: PanelProps): ReactNode;
@@ -1,58 +1,48 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
- * Pure rendering half of the toolStats page.
4
- *
5
- * Separate from `index.tsx` so a static render can assert in Node what the page draws —
6
- * the shipped bundle is a loader factory only a browser can run. Every user-visible
7
- * string comes from the `t` seat the renderer binds from this plugin's namespace, so the
8
- * page follows the UI language; no copy is hardcoded here.
9
- *
3
+ * Pure rendering half of the toolStats page. Uses shared UI kit.
10
4
  * @module client/view
11
5
  */
12
- import { useCallback, useEffect, useState } from 'react';
13
- /** Panel route registered by the host half on the web connection. */
14
- const PANEL_PATH = "/api/stats.panel";
15
- const wrap = { display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760, fontFamily: 'inherit' };
16
- const head = { display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' };
17
- const muted = { fontSize: 12, opacity: 0.75 };
18
- const table = { borderCollapse: 'collapse', width: '100%' };
19
- const th = { textAlign: 'left', padding: '4px 10px 4px 0', fontWeight: 600, fontSize: 12, opacity: 0.8, borderBottom: '0.5px solid rgba(128,128,128,0.4)' };
20
- const td = { padding: '6px 10px 6px 0', fontSize: 13, borderBottom: '0.5px solid rgba(128,128,128,0.18)' };
21
- const list = { margin: 0, paddingLeft: 18, fontSize: 13 };
22
- /** Fetch the host panel payload; `reload` re-runs the request. */
23
- export function usePanel() {
24
- const [state, setState] = useState({ payload: null, error: null });
25
- const [tick, setTick] = useState(0);
26
- const reload = useCallback(() => setTick((value) => value + 1), []);
27
- useEffect(() => {
28
- const controller = new AbortController();
29
- setState((previous) => ({ ...previous, error: null }));
30
- fetch(PANEL_PATH, { signal: controller.signal })
31
- .then(async (response) => {
32
- if (!response.ok)
33
- throw new Error(String(response.status));
34
- return response.json();
35
- })
36
- .then((payload) => {
37
- if (!controller.signal.aborted)
38
- setState({ payload, error: null });
39
- })
40
- .catch((cause) => {
41
- if (controller.signal.aborted)
42
- return;
43
- setState({ payload: null, error: cause instanceof Error ? cause.message : String(cause) });
44
- });
45
- return () => controller.abort();
46
- }, [tick]);
47
- return { ...state, reload };
6
+ import { useCallback, useState } from 'react';
7
+ import { Badge, Button, Card, ConfirmDialog, EmptyState, Modal, SectionTitle, Spinner, StatCard, ToastProvider, tableStyles, usePanel, useToast, } from './ui.js';
8
+ const PANEL_PATH = '/api/stats.panel';
9
+ const DETAIL_PATH = '/api/stats.detail';
10
+ const EXPORT_PATH = '/api/stats.export';
11
+ const CLEAR_PATH = '/api/stats.clear';
12
+ function ToolModal({ tool, t, onClose }) {
13
+ const [detail, setDetail] = useState(null);
14
+ const [loading, setLoading] = useState(true);
15
+ useCallback(() => {
16
+ setLoading(true);
17
+ fetch(`${DETAIL_PATH}?tool=${encodeURIComponent(tool)}`)
18
+ .then(async (r) => { if (r.ok)
19
+ setDetail(await r.json()); })
20
+ .finally(() => setLoading(false));
21
+ }, [tool])();
22
+ return (_jsx(Modal, { title: `${t('tool')}: ${tool}`, onClose: onClose, width: 600, children: loading ? _jsx("div", { style: { display: 'flex', justifyContent: 'center', padding: 20 }, children: _jsx(Spinner, { size: 24 }) })
23
+ : detail === null ? _jsx(EmptyState, { message: t('notFound') })
24
+ : (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 12 }, children: [detail.errorBreakdown.length > 0 && (_jsx(Card, { title: t('errorBreakdown'), children: detail.errorBreakdown.map((e, i) => (_jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }, children: [_jsxs(Badge, { color: "error", children: ["\u00D7", e.count] }), _jsx("span", { style: { fontSize: 12 }, children: e.message })] }, i))) })), _jsx(Card, { title: t('recentCalls'), children: _jsx("table", { style: tableStyles.table, children: _jsx("tbody", { children: detail.recentCalls.slice(0, 15).map((c, i) => (_jsxs("tr", { children: [_jsx("td", { style: tableStyles.td, children: c.success ? _jsx(Badge, { color: "success", children: "\u2713" }) : _jsx(Badge, { color: "error", children: "\u2717" }) }), _jsxs("td", { style: tableStyles.td, children: [c.latencyMs, "ms"] }), _jsx("td", { style: tableStyles.td, children: new Date(c.timestamp).toLocaleTimeString() }), _jsx("td", { style: { ...tableStyles.td, fontSize: 10, opacity: 0.6 }, children: c.errorMessage?.slice(0, 60) })] }, i))) }) }) })] })) }));
48
25
  }
49
- export function ToolStatsPanel({ t }) {
50
- const { payload, error, reload } = usePanel();
51
- const header = (_jsxs("header", { style: head, children: [_jsx("strong", { style: { fontSize: 13 }, children: t('title') }), _jsx("span", { style: { flex: 1 } }), _jsx("button", { type: "button", onClick: reload, style: { fontSize: 12 }, children: t('refresh') })] }));
52
- if (error !== null) {
53
- return (_jsxs("div", { style: wrap, children: [header, _jsxs("p", { role: "alert", style: { margin: 0, fontSize: 13 }, children: [t('failed'), ": ", error] }), _jsx("button", { type: "button", onClick: reload, style: { alignSelf: 'flex-start', fontSize: 12 }, children: t('retry') })] }));
54
- }
26
+ function ToolStatsPanelInner({ t }) {
27
+ const { payload, error, reload } = usePanel(PANEL_PATH);
28
+ const toast = useToast();
29
+ const [toolModal, setToolModal] = useState(null);
30
+ const [confirmClear, setConfirmClear] = useState(false);
31
+ const handleClear = useCallback(async () => {
32
+ const r = await fetch(CLEAR_PATH, { method: 'POST' });
33
+ if (r.ok) {
34
+ toast('success', t('cleared'));
35
+ reload();
36
+ }
37
+ }, [t, toast, reload]);
38
+ const header = (_jsxs("header", { style: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }, children: [_jsxs("strong", { style: { fontSize: 15 }, children: ["\uD83D\uDD27 ", t('title')] }), _jsx("span", { style: { flex: 1 } }), _jsx("a", { href: EXPORT_PATH, download: true, children: _jsx(Button, { variant: "secondary", size: "sm", children: t('export') }) }), _jsx(Button, { variant: "danger", size: "sm", onClick: () => setConfirmClear(true), children: t('clear') }), _jsx(Button, { variant: "secondary", size: "sm", onClick: reload, children: t('refresh') })] }));
39
+ if (error !== null)
40
+ return _jsxs("div", { style: { maxWidth: 820 }, children: [header, _jsx(Card, { children: _jsxs("p", { role: "alert", style: { margin: 0, fontSize: 13, color: 'var(--error, #e53935)' }, children: [t('failed'), ": ", error] }) })] });
55
41
  if (payload === null)
56
- return _jsx("p", { style: muted, "aria-live": "polite", children: t('loading') });
57
- return (_jsxs("div", { style: wrap, children: [header, payload.tools.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 13, opacity: 0.8 }, children: t('empty') })) : (_jsxs("table", { style: table, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: th, children: t('tool') }), _jsx("th", { style: th, children: t('calls') }), _jsx("th", { style: th, children: t('failRate') }), _jsx("th", { style: th, children: "p50" }), _jsx("th", { style: th, children: "p95" })] }) }), _jsx("tbody", { children: payload.tools.map((row) => (_jsxs("tr", { children: [_jsx("td", { style: td, children: _jsx("code", { style: { fontSize: 11 }, children: row.tool }) }), _jsx("td", { style: td, children: row.invocations }), _jsxs("td", { style: td, children: [Math.round(row.failureRate * 100), "%"] }), _jsxs("td", { style: td, children: [row.p50Latency, "ms"] }), _jsxs("td", { style: td, children: [row.p95Latency, "ms"] })] }, row.tool))) })] })), payload.deadTools.length > 0 && (_jsxs(_Fragment, { children: [_jsx("strong", { style: { fontSize: 13 }, children: t('deadTools') }), _jsx("ul", { style: list, children: payload.deadTools.map((row) => _jsx("li", { children: _jsx("code", { style: { fontSize: 11 }, children: row.tool }) }, row.tool)) })] })), payload.failingTools.length > 0 && (_jsxs(_Fragment, { children: [_jsx("strong", { style: { fontSize: 13 }, children: t('failingTools') }), _jsx("ul", { style: list, children: payload.failingTools.map((row) => (_jsxs("li", { children: [_jsx("code", { style: { fontSize: 11 }, children: row.tool }), " \u00B7 ", Math.round(row.failureRate * 100), "%"] }, row.tool))) })] })), payload.recommendations.length > 0 && (_jsxs(_Fragment, { children: [_jsx("strong", { style: { fontSize: 13 }, children: t('recommendations') }), _jsx("ul", { style: list, children: payload.recommendations.map((row, index) => _jsx("li", { children: row.message }, index)) })] }))] }));
42
+ return _jsxs("div", { style: { maxWidth: 820 }, children: [header, _jsx("div", { style: { display: 'flex', justifyContent: 'center', padding: 40 }, children: _jsx(Spinner, { size: 28 }) })] });
43
+ const o = payload.overview;
44
+ return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 820 }, children: [header, payload.alerts.length > 0 && (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 6 }, children: payload.alerts.map((a, i) => (_jsx(Card, { style: { borderColor: a.level === 'error' ? 'var(--error, #e53935)' : 'var(--warning, #ed6c02)' }, children: _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx(Badge, { color: a.level === 'error' ? 'error' : 'warning', children: a.level === 'error' ? '🔴' : '🟡' }), _jsx("strong", { children: a.tool }), _jsx("span", { style: { fontSize: 12 }, children: a.message })] }) }, i))) })), _jsxs("div", { style: { display: 'flex', gap: 10, flexWrap: 'wrap' }, children: [_jsx(StatCard, { value: o.totalCalls, label: t('totalCalls') }), _jsx(StatCard, { value: o.totalFailures, label: t('totalFailures'), color: o.totalFailures > 0 ? 'var(--error, #e53935)' : undefined }), _jsx(StatCard, { value: `${Math.round(o.overallFailureRate * 100)}%`, label: t('failRate') }), _jsx(StatCard, { value: o.activeSessions, label: t('sessions') }), _jsx(StatCard, { value: o.avgLatencyMs, unit: "ms", label: t('avgLatency') })] }), payload.tools.length === 0 ? _jsx(EmptyState, { icon: "\uD83D\uDCED", message: t('empty') }) : (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { icon: "\uD83D\uDCCA", children: t('tools') }), _jsx(Card, { padding: 0, children: _jsxs("table", { style: tableStyles.table, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: tableStyles.th, children: t('tool') }), _jsx("th", { style: tableStyles.th, children: t('calls') }), _jsx("th", { style: tableStyles.th, children: t('failRate') }), _jsx("th", { style: tableStyles.th, children: "p50" }), _jsx("th", { style: tableStyles.th, children: "p95" })] }) }), _jsx("tbody", { children: payload.tools.map((row) => (_jsxs("tr", { style: tableStyles.clickRow, onClick: () => setToolModal(row.tool), children: [_jsx("td", { style: tableStyles.td, children: _jsx("code", { style: { fontSize: 11 }, children: row.tool }) }), _jsx("td", { style: tableStyles.td, children: row.invocations }), _jsxs("td", { style: { ...tableStyles.td, color: row.failureRate > 0.3 ? 'var(--error, #e53935)' : undefined }, children: [Math.round(row.failureRate * 100), "%"] }), _jsxs("td", { style: tableStyles.td, children: [row.p50Latency, "ms"] }), _jsxs("td", { style: tableStyles.td, children: [row.p95Latency, "ms"] })] }, row.tool))) })] }) })] })), payload.deadTools.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { icon: "\uD83D\uDC80", children: t('deadTools') }), _jsx(Card, { children: payload.deadTools.map((row) => (_jsxs("div", { style: { marginBottom: 4 }, children: [_jsx("code", { style: { fontSize: 11 }, children: row.tool }), " \u2014 ", row.sessionsSinceLastUse, " ", t('sessionsUnused')] }, row.tool))) })] })), payload.failingTools.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { icon: "\u26A0\uFE0F", children: t('failingTools') }), _jsx(Card, { children: payload.failingTools.map((row) => (_jsxs("div", { style: { marginBottom: 4 }, children: [_jsx("code", { style: { fontSize: 11 }, children: row.tool }), " \u00B7 ", Math.round(row.failureRate * 100), "%", row.lastError ? ` — ${row.lastError.slice(0, 60)}` : ''] }, row.tool))) })] })), payload.recommendations.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionTitle, { icon: "\uD83D\uDCA1", children: t('recommendations') }), _jsx(Card, { children: payload.recommendations.map((row, i) => _jsx("div", { style: { marginBottom: 4 }, children: row.message }, i)) })] })), toolModal !== null && _jsx(ToolModal, { tool: toolModal, t: t, onClose: () => setToolModal(null) }), confirmClear && _jsx(ConfirmDialog, { title: t('clear'), message: t('confirmClear'), confirmLabel: t('clear'), danger: true, onConfirm: handleClear, onClose: () => setConfirmClear(false) })] }));
45
+ }
46
+ export function ToolStatsPanel({ t }) {
47
+ return _jsx(ToastProvider, { children: _jsx(ToolStatsPanelInner, { t: t }) });
58
48
  }
package/lib/index.d.ts CHANGED
@@ -11,5 +11,12 @@ export interface StatsService {
11
11
  errorMessage?: string;
12
12
  }): void;
13
13
  summary(configuredTools?: string[]): ReturnType<ToolStats['summary']>;
14
+ query(range?: {
15
+ from?: number | undefined;
16
+ to?: number | undefined;
17
+ }, configuredTools?: string[]): ReturnType<ToolStats['summary']>;
18
+ detail(toolName: string): ReturnType<ToolStats['detail']>;
19
+ exportCSV(): string;
20
+ clearData(): void;
14
21
  }
15
22
  export declare function apply(ctx: Context): void;
package/lib/index.js CHANGED
@@ -8,6 +8,10 @@ export function apply(ctx) {
8
8
  const service = {
9
9
  record: (call) => stats.record(call),
10
10
  summary: (configuredTools) => stats.summary(configuredTools ?? []),
11
+ query: (range, configuredTools) => stats.query(range, configuredTools ?? []),
12
+ detail: (toolName) => stats.detail(toolName),
13
+ exportCSV: () => stats.exportCSV(),
14
+ clearData: () => stats.clearData(),
11
15
  };
12
16
  ctx.provide('toolStats', service);
13
17
  registerStatsRoutes(ctx, service);
package/lib/routes.d.ts CHANGED
@@ -2,4 +2,7 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { StatsService } from './index.js';
3
3
  import { STATS_PANEL_PATH } from './stats.js';
4
4
  export { STATS_PANEL_PATH };
5
+ export declare const STATS_DETAIL_PATH = "/api/stats.detail";
6
+ export declare const STATS_EXPORT_PATH = "/api/stats.export";
7
+ export declare const STATS_CLEAR_PATH = "/api/stats.clear";
5
8
  export declare function registerStatsRoutes(ctx: Context, stats: StatsService): void;
package/lib/routes.js CHANGED
@@ -1,15 +1,63 @@
1
1
  import { STATS_PANEL_PATH } from './stats.js';
2
2
  export { STATS_PANEL_PATH };
3
+ export const STATS_DETAIL_PATH = '/api/stats.detail';
4
+ export const STATS_EXPORT_PATH = '/api/stats.export';
5
+ export const STATS_CLEAR_PATH = '/api/stats.clear';
3
6
  export function registerStatsRoutes(ctx, stats) {
4
7
  ctx.inject(['connection'], (connectionCtx) => {
5
8
  const connection = connectionCtx.connection;
9
+ // Panel overview
6
10
  connection.fetch.register({
7
11
  path: STATS_PANEL_PATH,
8
12
  methods: ['GET'],
9
13
  requestBody: 'buffered',
10
- fetch: () => Promise.resolve(Response.json(stats.summary(), {
11
- headers: { 'cache-control': 'no-store' },
12
- })),
14
+ fetch: (req) => {
15
+ const url = new URL(req.url, 'http://localhost');
16
+ const from = url.searchParams.get('from');
17
+ const to = url.searchParams.get('to');
18
+ const range = (from || to) ? { from: from ? Number(from) : undefined, to: to ? Number(to) : undefined } : undefined;
19
+ return Promise.resolve(Response.json(range ? stats.query(range) : stats.summary(), {
20
+ headers: { 'cache-control': 'no-store' },
21
+ }));
22
+ },
23
+ });
24
+ // Tool detail
25
+ connection.fetch.register({
26
+ path: STATS_DETAIL_PATH,
27
+ methods: ['GET'],
28
+ requestBody: 'buffered',
29
+ fetch: (req) => {
30
+ const url = new URL(req.url, 'http://localhost');
31
+ const tool = url.searchParams.get('tool');
32
+ if (!tool)
33
+ return Promise.resolve(Response.json({ error: 'tool required' }, { status: 400 }));
34
+ const detail = stats.detail(tool);
35
+ if (!detail)
36
+ return Promise.resolve(Response.json({ error: 'not found' }, { status: 404 }));
37
+ return Promise.resolve(Response.json(detail, { headers: { 'cache-control': 'no-store' } }));
38
+ },
39
+ });
40
+ // Export CSV
41
+ connection.fetch.register({
42
+ path: STATS_EXPORT_PATH,
43
+ methods: ['GET'],
44
+ requestBody: 'buffered',
45
+ fetch: () => {
46
+ const csv = stats.exportCSV();
47
+ return Promise.resolve(new Response(csv, {
48
+ headers: { 'content-type': 'text/csv', 'content-disposition': 'attachment; filename=tool-stats.csv' },
49
+ }));
50
+ },
51
+ });
52
+ // Clear data
53
+ connection.fetch.register({
54
+ path: STATS_CLEAR_PATH,
55
+ methods: ['POST'],
56
+ requestBody: 'buffered',
57
+ fetch: () => {
58
+ stats.clearData();
59
+ return Promise.resolve(Response.json({ ok: true }));
60
+ },
13
61
  });
14
62
  });
15
63
  }
package/lib/stats.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ToolCall, PanelPayload } from './types.js';
1
+ import type { ToolCall, PanelPayload, ToolDetail, TimeRange } from './types.js';
2
2
  export declare const STATS_PANEL_PATH = "/api/stats.panel";
3
3
  export declare class ToolStats {
4
4
  private projectDir;
@@ -6,4 +6,12 @@ export declare class ToolStats {
6
6
  constructor(projectDir: string);
7
7
  record(call: ToolCall): void;
8
8
  summary(configuredTools?: string[]): PanelPayload;
9
+ /** Query calls with optional time range. */
10
+ query(range?: TimeRange, configuredTools?: string[]): PanelPayload;
11
+ /** Get detailed stats for one tool. */
12
+ detail(toolName: string): ToolDetail | null;
13
+ /** Export all calls as CSV. */
14
+ exportCSV(): string;
15
+ /** Clear all recorded data. */
16
+ clearData(): void;
9
17
  }
package/lib/stats.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { StatsStore } from './store.js';
2
- import { aggregate, findDeadTools, findFailingTools, recommend } from './analyzer.js';
2
+ import { aggregate, findDeadTools, findFailingTools, recommend, toolDetail, generateAlerts, overview } from './analyzer.js';
3
3
  export const STATS_PANEL_PATH = '/api/stats.panel';
4
4
  export class ToolStats {
5
5
  projectDir;
@@ -20,6 +20,38 @@ export class ToolStats {
20
20
  deadTools: findDeadTools(configuredTools, calls),
21
21
  failingTools: findFailingTools(calls),
22
22
  recommendations: recommend(configuredTools, calls),
23
+ overview: overview(calls),
24
+ alerts: generateAlerts(calls),
23
25
  };
24
26
  }
27
+ /** Query calls with optional time range. */
28
+ query(range, configuredTools = []) {
29
+ const calls = this.store.query(range?.from, range?.to);
30
+ const counters = aggregate(calls);
31
+ const tools = [...counters.values()].sort((a, b) => b.invocations - a.invocations);
32
+ return {
33
+ tools,
34
+ deadTools: findDeadTools(configuredTools, calls),
35
+ failingTools: findFailingTools(calls),
36
+ recommendations: recommend(configuredTools, calls),
37
+ overview: overview(calls),
38
+ alerts: generateAlerts(calls),
39
+ };
40
+ }
41
+ /** Get detailed stats for one tool. */
42
+ detail(toolName) {
43
+ const calls = this.store.readAll();
44
+ return toolDetail(calls, toolName);
45
+ }
46
+ /** Export all calls as CSV. */
47
+ exportCSV() {
48
+ const calls = this.store.readAll();
49
+ const header = 'tool,timestamp,success,latencyMs,sessionId,errorMessage';
50
+ const rows = calls.map((c) => [c.tool, c.timestamp, c.success, c.latencyMs, c.sessionId, c.errorMessage ?? ''].join(','));
51
+ return [header, ...rows].join('\n');
52
+ }
53
+ /** Clear all recorded data. */
54
+ clearData() {
55
+ this.store.clear();
56
+ }
25
57
  }