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.
- package/lib/analyzer.d.ts +9 -1
- package/lib/analyzer.js +75 -0
- package/lib/client/locales.d.ts +10 -0
- package/lib/client/locales.js +20 -0
- package/lib/client/ui.d.ts +124 -0
- package/lib/client/ui.js +388 -0
- package/lib/client/view.d.ts +3 -20
- package/lib/client/view.js +41 -51
- package/lib/index.d.ts +7 -0
- package/lib/index.js +4 -0
- package/lib/routes.d.ts +3 -0
- package/lib/routes.js +51 -3
- package/lib/stats.d.ts +9 -1
- package/lib/stats.js +33 -1
- package/lib/stats.web.js +616 -81
- package/lib/stats.web.js.map +4 -4
- package/lib/store.d.ts +2 -0
- package/lib/store.js +15 -1
- package/lib/types.d.ts +34 -0
- package/package.json +1 -1
package/lib/analyzer.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import type { ToolCall, ToolCounter, DeadTool, FailingTool, Recommendation } from './types.js';
|
|
1
|
+
import type { ToolCall, ToolCounter, DeadTool, FailingTool, Recommendation, DailyToolTrend, ToolDetail, Alert, ToolStatsOverview } from './types.js';
|
|
2
2
|
export declare function aggregate(calls: ToolCall[]): Map<string, ToolCounter>;
|
|
3
3
|
export declare function findDeadTools(configuredTools: string[], calls: ToolCall[], threshold?: number): DeadTool[];
|
|
4
4
|
export declare function findFailingTools(calls: ToolCall[], threshold?: number): FailingTool[];
|
|
5
5
|
export declare function recommend(configuredTools: string[], calls: ToolCall[], deadThreshold?: number, estimatedTokensPerTool?: number): Recommendation[];
|
|
6
|
+
/** Build daily trend for a specific tool. */
|
|
7
|
+
export declare function dailyTrend(calls: ToolCall[], toolName: string): DailyToolTrend[];
|
|
8
|
+
/** Detailed view for a single tool. */
|
|
9
|
+
export declare function toolDetail(calls: ToolCall[], toolName: string): ToolDetail | null;
|
|
10
|
+
/** Generate alerts for failing tools. */
|
|
11
|
+
export declare function generateAlerts(calls: ToolCall[], threshold?: number): Alert[];
|
|
12
|
+
/** Compute overview stats. */
|
|
13
|
+
export declare function overview(calls: ToolCall[]): ToolStatsOverview;
|
package/lib/analyzer.js
CHANGED
|
@@ -90,3 +90,78 @@ export function recommend(configuredTools, calls, deadThreshold = 10, estimatedT
|
|
|
90
90
|
}
|
|
91
91
|
return recs;
|
|
92
92
|
}
|
|
93
|
+
/** Build daily trend for a specific tool. */
|
|
94
|
+
export function dailyTrend(calls, toolName) {
|
|
95
|
+
const toolCalls = calls.filter((c) => c.tool === toolName);
|
|
96
|
+
const byDay = new Map();
|
|
97
|
+
for (const c of toolCalls) {
|
|
98
|
+
const day = new Date(c.timestamp).toISOString().slice(0, 10);
|
|
99
|
+
const d = byDay.get(day) ?? { invocations: 0, failures: 0, totalLatency: 0 };
|
|
100
|
+
d.invocations++;
|
|
101
|
+
if (!c.success)
|
|
102
|
+
d.failures++;
|
|
103
|
+
d.totalLatency += c.latencyMs;
|
|
104
|
+
byDay.set(day, d);
|
|
105
|
+
}
|
|
106
|
+
return [...byDay.entries()]
|
|
107
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
108
|
+
.map(([date, d]) => ({
|
|
109
|
+
date,
|
|
110
|
+
invocations: d.invocations,
|
|
111
|
+
failures: d.failures,
|
|
112
|
+
avgLatencyMs: Math.round(d.totalLatency / d.invocations),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
/** Detailed view for a single tool. */
|
|
116
|
+
export function toolDetail(calls, toolName) {
|
|
117
|
+
const counters = aggregate(calls);
|
|
118
|
+
const counter = counters.get(toolName);
|
|
119
|
+
if (!counter)
|
|
120
|
+
return null;
|
|
121
|
+
const toolCalls = calls.filter((c) => c.tool === toolName);
|
|
122
|
+
const recentCalls = toolCalls.sort((a, b) => b.timestamp - a.timestamp).slice(0, 50);
|
|
123
|
+
const trend = dailyTrend(calls, toolName);
|
|
124
|
+
// Error breakdown
|
|
125
|
+
const errorCounts = new Map();
|
|
126
|
+
for (const c of toolCalls) {
|
|
127
|
+
if (!c.success && c.errorMessage) {
|
|
128
|
+
const msg = c.errorMessage.slice(0, 100);
|
|
129
|
+
errorCounts.set(msg, (errorCounts.get(msg) ?? 0) + 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const errorBreakdown = [...errorCounts.entries()]
|
|
133
|
+
.map(([message, count]) => ({ message, count }))
|
|
134
|
+
.sort((a, b) => b.count - a.count);
|
|
135
|
+
return { counter, recentCalls, trend, errorBreakdown };
|
|
136
|
+
}
|
|
137
|
+
/** Generate alerts for failing tools. */
|
|
138
|
+
export function generateAlerts(calls, threshold = 0.5) {
|
|
139
|
+
const alerts = [];
|
|
140
|
+
const counters = aggregate(calls);
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
for (const c of counters.values()) {
|
|
143
|
+
if (c.invocations >= 5 && c.failureRate >= threshold) {
|
|
144
|
+
alerts.push({
|
|
145
|
+
level: c.failureRate >= 0.8 ? 'error' : 'warn',
|
|
146
|
+
tool: c.tool,
|
|
147
|
+
message: `Failure rate ${Math.round(c.failureRate * 100)}% (${c.failures}/${c.invocations})`,
|
|
148
|
+
timestamp: now,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return alerts;
|
|
153
|
+
}
|
|
154
|
+
/** Compute overview stats. */
|
|
155
|
+
export function overview(calls) {
|
|
156
|
+
const totalCalls = calls.length;
|
|
157
|
+
const totalFailures = calls.filter((c) => !c.success).length;
|
|
158
|
+
const sessions = new Set(calls.map((c) => c.sessionId));
|
|
159
|
+
const totalLatency = calls.reduce((s, c) => s + c.latencyMs, 0);
|
|
160
|
+
return {
|
|
161
|
+
totalCalls,
|
|
162
|
+
totalFailures,
|
|
163
|
+
overallFailureRate: totalCalls > 0 ? totalFailures / totalCalls : 0,
|
|
164
|
+
activeSessions: sessions.size,
|
|
165
|
+
avgLatencyMs: totalCalls > 0 ? Math.round(totalLatency / totalCalls) : 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/lib/client/locales.d.ts
CHANGED
|
@@ -24,6 +24,16 @@ export declare const zh: {
|
|
|
24
24
|
loading: string;
|
|
25
25
|
failed: string;
|
|
26
26
|
retry: string;
|
|
27
|
+
totalCalls: string;
|
|
28
|
+
totalFailures: string;
|
|
29
|
+
sessions: string;
|
|
30
|
+
avgLatency: string;
|
|
31
|
+
export: string;
|
|
32
|
+
clear: string;
|
|
33
|
+
confirmClear: string;
|
|
34
|
+
errorBreakdown: string;
|
|
35
|
+
recentCalls: string;
|
|
36
|
+
sessionsUnused: string;
|
|
27
37
|
};
|
|
28
38
|
/** English dictionary, checked complete against the zh key set. */
|
|
29
39
|
export declare const en: typeof zh;
|
package/lib/client/locales.js
CHANGED
|
@@ -24,6 +24,16 @@ export const zh = {
|
|
|
24
24
|
'loading': '正在加载…',
|
|
25
25
|
'failed': '加载失败',
|
|
26
26
|
'retry': '重试',
|
|
27
|
+
'totalCalls': '总调用',
|
|
28
|
+
'totalFailures': '总失败',
|
|
29
|
+
'sessions': '会话数',
|
|
30
|
+
'avgLatency': '平均延迟',
|
|
31
|
+
'export': '导出 CSV',
|
|
32
|
+
'clear': '清空',
|
|
33
|
+
'confirmClear': '确定清空所有记录?',
|
|
34
|
+
'errorBreakdown': '错误分类',
|
|
35
|
+
'recentCalls': '最近调用',
|
|
36
|
+
'sessionsUnused': '会话未用',
|
|
27
37
|
};
|
|
28
38
|
/** English dictionary, checked complete against the zh key set. */
|
|
29
39
|
export const en = {
|
|
@@ -40,4 +50,14 @@ export const en = {
|
|
|
40
50
|
'loading': 'Loading…',
|
|
41
51
|
'failed': 'Failed to load',
|
|
42
52
|
'retry': 'Retry',
|
|
53
|
+
'totalCalls': 'Total Calls',
|
|
54
|
+
'totalFailures': 'Total Failures',
|
|
55
|
+
'sessions': 'Sessions',
|
|
56
|
+
'avgLatency': 'Avg Latency',
|
|
57
|
+
'export': 'Export CSV',
|
|
58
|
+
'clear': 'Clear',
|
|
59
|
+
'confirmClear': 'Clear all recorded data?',
|
|
60
|
+
'errorBreakdown': 'Error Breakdown',
|
|
61
|
+
'recentCalls': 'Recent Calls',
|
|
62
|
+
'sessionsUnused': 'sessions unused',
|
|
43
63
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared UI component kit for dsh plugin panels.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained: imports nothing but React. All styling is inline + CSS custom
|
|
5
|
+
* properties so the components follow the host theme. A single <style> tag injects
|
|
6
|
+
* keyframes the first time any component mounts.
|
|
7
|
+
*
|
|
8
|
+
* @module client/ui
|
|
9
|
+
*/
|
|
10
|
+
import type { ButtonHTMLAttributes, CSSProperties, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
|
|
11
|
+
export declare function Modal({ title, onClose, children, footer, width, }: {
|
|
12
|
+
title: string;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
footer?: ReactNode;
|
|
16
|
+
width?: number;
|
|
17
|
+
}): ReactNode;
|
|
18
|
+
export declare function ConfirmDialog({ title, message, confirmLabel, cancelLabel, onConfirm, onClose, danger, }: {
|
|
19
|
+
title: string;
|
|
20
|
+
message: string;
|
|
21
|
+
confirmLabel: string;
|
|
22
|
+
cancelLabel?: string;
|
|
23
|
+
onConfirm: () => void;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
danger?: boolean;
|
|
26
|
+
}): ReactNode;
|
|
27
|
+
export declare function Button({ variant, size, children, style, ...rest }: {
|
|
28
|
+
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
29
|
+
size?: 'sm' | 'md';
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
} & ButtonHTMLAttributes<HTMLButtonElement>): ReactNode;
|
|
33
|
+
export declare function Card({ title, icon, children, actions, style, padding, }: {
|
|
34
|
+
title?: string;
|
|
35
|
+
icon?: string;
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
actions?: ReactNode;
|
|
38
|
+
style?: CSSProperties;
|
|
39
|
+
padding?: number;
|
|
40
|
+
}): ReactNode;
|
|
41
|
+
export declare function StatCard({ value, label, color, unit, }: {
|
|
42
|
+
value: string | number;
|
|
43
|
+
label: string;
|
|
44
|
+
color?: string;
|
|
45
|
+
unit?: string;
|
|
46
|
+
}): ReactNode;
|
|
47
|
+
export declare function Badge({ children, color, }: {
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
color?: 'default' | 'success' | 'error' | 'warning' | 'info';
|
|
50
|
+
}): ReactNode;
|
|
51
|
+
export declare function Input({ style, ...rest }: {
|
|
52
|
+
style?: CSSProperties;
|
|
53
|
+
} & InputHTMLAttributes<HTMLInputElement>): ReactNode;
|
|
54
|
+
export declare function Select({ style, children, ...rest }: {
|
|
55
|
+
style?: CSSProperties;
|
|
56
|
+
children: ReactNode;
|
|
57
|
+
} & SelectHTMLAttributes<HTMLSelectElement>): ReactNode;
|
|
58
|
+
export declare function Textarea({ style, ...rest }: {
|
|
59
|
+
style?: CSSProperties;
|
|
60
|
+
} & React.TextareaHTMLAttributes<HTMLTextAreaElement>): ReactNode;
|
|
61
|
+
interface ToastItem {
|
|
62
|
+
id: number;
|
|
63
|
+
type: 'success' | 'error' | 'info';
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
|
66
|
+
export declare function ToastProvider({ children }: {
|
|
67
|
+
children: ReactNode;
|
|
68
|
+
}): ReactNode;
|
|
69
|
+
export declare function useToast(): (type: ToastItem['type'], message: string) => void;
|
|
70
|
+
export declare function Spinner({ size }: {
|
|
71
|
+
size?: number;
|
|
72
|
+
}): ReactNode;
|
|
73
|
+
export declare function EmptyState({ icon, message, }: {
|
|
74
|
+
icon?: string;
|
|
75
|
+
message: string;
|
|
76
|
+
}): ReactNode;
|
|
77
|
+
export declare function SectionTitle({ icon, children, actions, }: {
|
|
78
|
+
icon?: string;
|
|
79
|
+
children: ReactNode;
|
|
80
|
+
actions?: ReactNode;
|
|
81
|
+
}): ReactNode;
|
|
82
|
+
export declare const tableStyles: {
|
|
83
|
+
table: {
|
|
84
|
+
borderCollapse: "collapse";
|
|
85
|
+
width: "100%";
|
|
86
|
+
};
|
|
87
|
+
th: {
|
|
88
|
+
textAlign: "left";
|
|
89
|
+
padding: string;
|
|
90
|
+
fontWeight: 600;
|
|
91
|
+
fontSize: 12;
|
|
92
|
+
color: string;
|
|
93
|
+
borderBottom: string;
|
|
94
|
+
};
|
|
95
|
+
td: {
|
|
96
|
+
padding: string;
|
|
97
|
+
fontSize: 13;
|
|
98
|
+
borderBottom: string;
|
|
99
|
+
};
|
|
100
|
+
clickRow: {
|
|
101
|
+
cursor: "pointer";
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
export declare function CodeBlock({ children, maxHeight, style, }: {
|
|
105
|
+
children: string;
|
|
106
|
+
maxHeight?: number;
|
|
107
|
+
style?: CSSProperties;
|
|
108
|
+
}): ReactNode;
|
|
109
|
+
export declare function ProgressBar({ pct, color, height, }: {
|
|
110
|
+
pct: number;
|
|
111
|
+
color?: string;
|
|
112
|
+
height?: number;
|
|
113
|
+
}): ReactNode;
|
|
114
|
+
export declare function Field({ label, children, hint, }: {
|
|
115
|
+
label: string;
|
|
116
|
+
children: ReactNode;
|
|
117
|
+
hint?: string;
|
|
118
|
+
}): ReactNode;
|
|
119
|
+
export declare function usePanel<P>(path: string): {
|
|
120
|
+
payload: P | null;
|
|
121
|
+
error: string | null;
|
|
122
|
+
reload: () => void;
|
|
123
|
+
};
|
|
124
|
+
export {};
|
package/lib/client/ui.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Shared UI component kit for dsh plugin panels.
|
|
4
|
+
*
|
|
5
|
+
* Self-contained: imports nothing but React. All styling is inline + CSS custom
|
|
6
|
+
* properties so the components follow the host theme. A single <style> tag injects
|
|
7
|
+
* keyframes the first time any component mounts.
|
|
8
|
+
*
|
|
9
|
+
* @module client/ui
|
|
10
|
+
*/
|
|
11
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
12
|
+
// ─── Theme tokens ──────────────────────────────────────────────
|
|
13
|
+
const T = {
|
|
14
|
+
accent: 'var(--accent, #4B8BBE)',
|
|
15
|
+
accentHover: 'var(--accent-hover, #3a6f9e)',
|
|
16
|
+
error: 'var(--error, #e53935)',
|
|
17
|
+
success: 'var(--success, #2e7d32)',
|
|
18
|
+
warning: 'var(--warning, #ed6c02)',
|
|
19
|
+
bg: 'var(--bg-secondary, rgba(128,128,128,0.04))',
|
|
20
|
+
bgHover: 'var(--bg-tertiary, rgba(128,128,128,0.08))',
|
|
21
|
+
border: 'var(--border, rgba(128,128,128,0.2))',
|
|
22
|
+
text: 'var(--text-primary, inherit)',
|
|
23
|
+
muted: 'var(--text-secondary, rgba(128,128,128,0.65))',
|
|
24
|
+
surface: 'var(--bg-primary, #fff)',
|
|
25
|
+
};
|
|
26
|
+
// ─── Style injection (once) ────────────────────────────────────
|
|
27
|
+
let injected = false;
|
|
28
|
+
function ensureStyles() {
|
|
29
|
+
if (injected || typeof document === 'undefined')
|
|
30
|
+
return;
|
|
31
|
+
injected = true;
|
|
32
|
+
const el = document.createElement('style');
|
|
33
|
+
el.textContent = `
|
|
34
|
+
@keyframes dsh-fade-in{from{opacity:0}to{opacity:1}}
|
|
35
|
+
@keyframes dsh-slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
|
36
|
+
@keyframes dsh-spin{to{transform:rotate(360deg)}}
|
|
37
|
+
.dsh-modal-overlay{animation:dsh-fade-in .15s ease}
|
|
38
|
+
.dsh-modal-body{animation:dsh-slide-up .2s ease}
|
|
39
|
+
.dsh-toast{animation:dsh-slide-up .2s ease}
|
|
40
|
+
.dsh-spin{animation:dsh-spin .6s linear infinite}
|
|
41
|
+
`;
|
|
42
|
+
document.head.appendChild(el);
|
|
43
|
+
}
|
|
44
|
+
// ─── Modal ─────────────────────────────────────────────────────
|
|
45
|
+
export function Modal({ title, onClose, children, footer, width = 520, }) {
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
ensureStyles();
|
|
48
|
+
const handler = (e) => {
|
|
49
|
+
if (e.key === 'Escape')
|
|
50
|
+
onClose();
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener('keydown', handler);
|
|
53
|
+
return () => document.removeEventListener('keydown', handler);
|
|
54
|
+
}, [onClose]);
|
|
55
|
+
return (_jsx("div", { className: "dsh-modal-overlay", style: {
|
|
56
|
+
position: 'fixed',
|
|
57
|
+
inset: 0,
|
|
58
|
+
zIndex: 1000,
|
|
59
|
+
display: 'flex',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
justifyContent: 'center',
|
|
62
|
+
background: 'rgba(0,0,0,0.45)',
|
|
63
|
+
backdropFilter: 'blur(3px)',
|
|
64
|
+
WebkitBackdropFilter: 'blur(3px)',
|
|
65
|
+
}, onClick: onClose, children: _jsxs("div", { className: "dsh-modal-body", style: {
|
|
66
|
+
background: T.surface,
|
|
67
|
+
borderRadius: 14,
|
|
68
|
+
boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
|
|
69
|
+
width: `min(92vw, ${width}px)`,
|
|
70
|
+
maxHeight: '85vh',
|
|
71
|
+
display: 'flex',
|
|
72
|
+
flexDirection: 'column',
|
|
73
|
+
overflow: 'hidden',
|
|
74
|
+
}, onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { style: {
|
|
75
|
+
display: 'flex',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
justifyContent: 'space-between',
|
|
78
|
+
padding: '16px 22px',
|
|
79
|
+
borderBottom: `1px solid ${T.border}`,
|
|
80
|
+
}, children: [_jsx("h3", { style: { margin: 0, fontSize: 15, fontWeight: 600 }, children: title }), _jsx("button", { type: "button", onClick: onClose, style: {
|
|
81
|
+
background: 'none',
|
|
82
|
+
border: 'none',
|
|
83
|
+
cursor: 'pointer',
|
|
84
|
+
fontSize: 20,
|
|
85
|
+
color: T.muted,
|
|
86
|
+
padding: '0 4px',
|
|
87
|
+
lineHeight: 1,
|
|
88
|
+
borderRadius: 4,
|
|
89
|
+
}, "aria-label": "Close", children: "\u00D7" })] }), _jsx("div", { style: { padding: 22, overflow: 'auto', flex: 1 }, children: children }), footer !== undefined && (_jsx("div", { style: {
|
|
90
|
+
padding: '14px 22px',
|
|
91
|
+
borderTop: `1px solid ${T.border}`,
|
|
92
|
+
display: 'flex',
|
|
93
|
+
justifyContent: 'flex-end',
|
|
94
|
+
gap: 10,
|
|
95
|
+
background: T.bg,
|
|
96
|
+
}, children: footer }))] }) }));
|
|
97
|
+
}
|
|
98
|
+
// ─── ConfirmDialog ─────────────────────────────────────────────
|
|
99
|
+
export function ConfirmDialog({ title, message, confirmLabel, cancelLabel = 'Cancel', onConfirm, onClose, danger, }) {
|
|
100
|
+
return (_jsx(Modal, { title: title, onClose: onClose, width: 420, footer: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: cancelLabel }), _jsx(Button, { variant: danger === true ? 'danger' : 'primary', onClick: () => {
|
|
101
|
+
onConfirm();
|
|
102
|
+
onClose();
|
|
103
|
+
}, children: confirmLabel })] }), children: _jsx("p", { style: { margin: 0, fontSize: 14, lineHeight: 1.6 }, children: message }) }));
|
|
104
|
+
}
|
|
105
|
+
// ─── Button ────────────────────────────────────────────────────
|
|
106
|
+
export function Button({ variant = 'secondary', size = 'md', children, style, ...rest }) {
|
|
107
|
+
const base = {
|
|
108
|
+
cursor: 'pointer',
|
|
109
|
+
borderRadius: 7,
|
|
110
|
+
fontWeight: 500,
|
|
111
|
+
transition: 'all 0.15s ease',
|
|
112
|
+
border: 'none',
|
|
113
|
+
display: 'inline-flex',
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
gap: 4,
|
|
116
|
+
fontSize: size === 'sm' ? 12 : 13,
|
|
117
|
+
padding: size === 'sm' ? '5px 12px' : '8px 18px',
|
|
118
|
+
fontFamily: 'inherit',
|
|
119
|
+
};
|
|
120
|
+
const variants = {
|
|
121
|
+
primary: { background: T.accent, color: '#fff' },
|
|
122
|
+
secondary: {
|
|
123
|
+
background: T.bg,
|
|
124
|
+
color: T.text,
|
|
125
|
+
border: `1px solid ${T.border}`,
|
|
126
|
+
},
|
|
127
|
+
danger: {
|
|
128
|
+
background: 'transparent',
|
|
129
|
+
color: T.error,
|
|
130
|
+
border: `1px solid ${T.error}`,
|
|
131
|
+
},
|
|
132
|
+
ghost: { background: 'transparent', color: T.muted, border: 'none' },
|
|
133
|
+
};
|
|
134
|
+
return (_jsx("button", { type: "button", style: { ...base, ...variants[variant], ...style }, ...rest, children: children }));
|
|
135
|
+
}
|
|
136
|
+
// ─── Card ──────────────────────────────────────────────────────
|
|
137
|
+
export function Card({ title, icon, children, actions, style, padding = 14, }) {
|
|
138
|
+
return (_jsxs("div", { style: {
|
|
139
|
+
borderRadius: 10,
|
|
140
|
+
border: `1px solid ${T.border}`,
|
|
141
|
+
background: T.bg,
|
|
142
|
+
overflow: 'hidden',
|
|
143
|
+
...style,
|
|
144
|
+
}, children: [(title !== undefined || actions !== undefined) && (_jsxs("div", { style: {
|
|
145
|
+
display: 'flex',
|
|
146
|
+
alignItems: 'center',
|
|
147
|
+
justifyContent: 'space-between',
|
|
148
|
+
padding: '12px 16px',
|
|
149
|
+
borderBottom: title !== undefined ? `1px solid ${T.border}` : 'none',
|
|
150
|
+
}, children: [_jsxs("span", { style: { fontSize: 13, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }, children: [icon !== undefined && _jsx("span", { children: icon }), title] }), actions] })), _jsx("div", { style: { padding }, children: children })] }));
|
|
151
|
+
}
|
|
152
|
+
// ─── StatCard ──────────────────────────────────────────────────
|
|
153
|
+
export function StatCard({ value, label, color, unit, }) {
|
|
154
|
+
return (_jsxs("div", { style: {
|
|
155
|
+
flex: 1,
|
|
156
|
+
minWidth: 90,
|
|
157
|
+
textAlign: 'center',
|
|
158
|
+
padding: '14px 10px',
|
|
159
|
+
borderRadius: 10,
|
|
160
|
+
border: `1px solid ${T.border}`,
|
|
161
|
+
background: T.bg,
|
|
162
|
+
}, children: [_jsxs("div", { style: { fontSize: 24, fontWeight: 700, color: color ?? T.text, lineHeight: 1.2 }, children: [value, unit !== undefined && (_jsx("span", { style: { fontSize: 13, fontWeight: 500, opacity: 0.7, marginLeft: 2 }, children: unit }))] }), _jsx("div", { style: { fontSize: 11, color: T.muted, marginTop: 4 }, children: label })] }));
|
|
163
|
+
}
|
|
164
|
+
// ─── Badge ─────────────────────────────────────────────────────
|
|
165
|
+
export function Badge({ children, color = 'default', }) {
|
|
166
|
+
const colors = {
|
|
167
|
+
default: { bg: 'rgba(128,128,128,0.12)', fg: T.muted },
|
|
168
|
+
success: { bg: 'rgba(46,125,50,0.12)', fg: T.success },
|
|
169
|
+
error: { bg: 'rgba(229,57,53,0.12)', fg: T.error },
|
|
170
|
+
warning: { bg: 'rgba(245,124,0,0.12)', fg: T.warning },
|
|
171
|
+
info: { bg: 'rgba(75,139,190,0.12)', fg: T.accent },
|
|
172
|
+
};
|
|
173
|
+
const c = colors[color];
|
|
174
|
+
return (_jsx("span", { style: {
|
|
175
|
+
display: 'inline-block',
|
|
176
|
+
padding: '2px 9px',
|
|
177
|
+
borderRadius: 10,
|
|
178
|
+
fontSize: 11,
|
|
179
|
+
fontWeight: 600,
|
|
180
|
+
background: c.bg,
|
|
181
|
+
color: c.fg,
|
|
182
|
+
}, children: children }));
|
|
183
|
+
}
|
|
184
|
+
// ─── Input ─────────────────────────────────────────────────────
|
|
185
|
+
export function Input({ style, ...rest }) {
|
|
186
|
+
return (_jsx("input", { ...rest, style: {
|
|
187
|
+
fontSize: 13,
|
|
188
|
+
padding: '8px 12px',
|
|
189
|
+
borderRadius: 7,
|
|
190
|
+
border: `1px solid ${T.border}`,
|
|
191
|
+
background: T.surface,
|
|
192
|
+
color: T.text,
|
|
193
|
+
width: '100%',
|
|
194
|
+
boxSizing: 'border-box',
|
|
195
|
+
fontFamily: 'inherit',
|
|
196
|
+
outline: 'none',
|
|
197
|
+
transition: 'border-color 0.15s',
|
|
198
|
+
...style,
|
|
199
|
+
} }));
|
|
200
|
+
}
|
|
201
|
+
// ─── Select ────────────────────────────────────────────────────
|
|
202
|
+
export function Select({ style, children, ...rest }) {
|
|
203
|
+
return (_jsx("select", { ...rest, style: {
|
|
204
|
+
fontSize: 13,
|
|
205
|
+
padding: '8px 12px',
|
|
206
|
+
borderRadius: 7,
|
|
207
|
+
border: `1px solid ${T.border}`,
|
|
208
|
+
background: T.surface,
|
|
209
|
+
color: T.text,
|
|
210
|
+
cursor: 'pointer',
|
|
211
|
+
fontFamily: 'inherit',
|
|
212
|
+
outline: 'none',
|
|
213
|
+
...style,
|
|
214
|
+
}, children: children }));
|
|
215
|
+
}
|
|
216
|
+
// ─── Textarea ──────────────────────────────────────────────────
|
|
217
|
+
export function Textarea({ style, ...rest }) {
|
|
218
|
+
return (_jsx("textarea", { ...rest, style: {
|
|
219
|
+
fontSize: 13,
|
|
220
|
+
padding: '8px 12px',
|
|
221
|
+
borderRadius: 7,
|
|
222
|
+
border: `1px solid ${T.border}`,
|
|
223
|
+
background: T.surface,
|
|
224
|
+
color: T.text,
|
|
225
|
+
width: '100%',
|
|
226
|
+
boxSizing: 'border-box',
|
|
227
|
+
fontFamily: 'inherit',
|
|
228
|
+
outline: 'none',
|
|
229
|
+
resize: 'vertical',
|
|
230
|
+
lineHeight: 1.5,
|
|
231
|
+
...style,
|
|
232
|
+
} }));
|
|
233
|
+
}
|
|
234
|
+
const ToastContext = createContext(() => { });
|
|
235
|
+
export function ToastProvider({ children }) {
|
|
236
|
+
const [toasts, setToasts] = useState([]);
|
|
237
|
+
const show = useCallback((type, message) => {
|
|
238
|
+
const id = Date.now() + Math.random();
|
|
239
|
+
setToasts((t) => [...t, { id, type, message }]);
|
|
240
|
+
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3500);
|
|
241
|
+
}, []);
|
|
242
|
+
return (_jsxs(ToastContext.Provider, { value: show, children: [children, toasts.length > 0 && (_jsx("div", { style: {
|
|
243
|
+
position: 'fixed',
|
|
244
|
+
bottom: 24,
|
|
245
|
+
right: 24,
|
|
246
|
+
zIndex: 2000,
|
|
247
|
+
display: 'flex',
|
|
248
|
+
flexDirection: 'column',
|
|
249
|
+
gap: 8,
|
|
250
|
+
}, children: toasts.map((t) => (_jsx("div", { className: "dsh-toast", style: {
|
|
251
|
+
padding: '12px 18px',
|
|
252
|
+
borderRadius: 8,
|
|
253
|
+
fontSize: 13,
|
|
254
|
+
fontWeight: 500,
|
|
255
|
+
boxShadow: '0 6px 20px rgba(0,0,0,0.2)',
|
|
256
|
+
color: '#fff',
|
|
257
|
+
background: t.type === 'success'
|
|
258
|
+
? T.success
|
|
259
|
+
: t.type === 'error'
|
|
260
|
+
? T.error
|
|
261
|
+
: T.accent,
|
|
262
|
+
maxWidth: 360,
|
|
263
|
+
}, children: t.message }, t.id))) }))] }));
|
|
264
|
+
}
|
|
265
|
+
export function useToast() {
|
|
266
|
+
return useContext(ToastContext);
|
|
267
|
+
}
|
|
268
|
+
// ─── Spinner ───────────────────────────────────────────────────
|
|
269
|
+
export function Spinner({ size = 16 }) {
|
|
270
|
+
ensureStyles();
|
|
271
|
+
return (_jsx("span", { className: "dsh-spin", style: {
|
|
272
|
+
display: 'inline-block',
|
|
273
|
+
width: size,
|
|
274
|
+
height: size,
|
|
275
|
+
border: `2px solid ${T.border}`,
|
|
276
|
+
borderTopColor: T.accent,
|
|
277
|
+
borderRadius: '50%',
|
|
278
|
+
} }));
|
|
279
|
+
}
|
|
280
|
+
// ─── EmptyState ────────────────────────────────────────────────
|
|
281
|
+
export function EmptyState({ icon, message, }) {
|
|
282
|
+
return (_jsxs("div", { style: {
|
|
283
|
+
textAlign: 'center',
|
|
284
|
+
padding: '36px 16px',
|
|
285
|
+
color: T.muted,
|
|
286
|
+
}, children: [icon !== undefined && (_jsx("div", { style: { fontSize: 36, marginBottom: 10, opacity: 0.4 }, children: icon })), _jsx("div", { style: { fontSize: 13 }, children: message })] }));
|
|
287
|
+
}
|
|
288
|
+
// ─── SectionTitle ──────────────────────────────────────────────
|
|
289
|
+
export function SectionTitle({ icon, children, actions, }) {
|
|
290
|
+
return (_jsxs("div", { style: {
|
|
291
|
+
display: 'flex',
|
|
292
|
+
alignItems: 'center',
|
|
293
|
+
justifyContent: 'space-between',
|
|
294
|
+
marginTop: 6,
|
|
295
|
+
marginBottom: 8,
|
|
296
|
+
}, children: [_jsxs("span", { style: {
|
|
297
|
+
fontSize: 14,
|
|
298
|
+
fontWeight: 600,
|
|
299
|
+
display: 'flex',
|
|
300
|
+
alignItems: 'center',
|
|
301
|
+
gap: 6,
|
|
302
|
+
}, children: [icon !== undefined && _jsx("span", { children: icon }), children] }), actions] }));
|
|
303
|
+
}
|
|
304
|
+
// ─── Table helpers ─────────────────────────────────────────────
|
|
305
|
+
export const tableStyles = {
|
|
306
|
+
table: {
|
|
307
|
+
borderCollapse: 'collapse',
|
|
308
|
+
width: '100%',
|
|
309
|
+
},
|
|
310
|
+
th: {
|
|
311
|
+
textAlign: 'left',
|
|
312
|
+
padding: '8px 12px 8px 0',
|
|
313
|
+
fontWeight: 600,
|
|
314
|
+
fontSize: 12,
|
|
315
|
+
color: T.muted,
|
|
316
|
+
borderBottom: `1px solid ${T.border}`,
|
|
317
|
+
},
|
|
318
|
+
td: {
|
|
319
|
+
padding: '8px 12px 8px 0',
|
|
320
|
+
fontSize: 13,
|
|
321
|
+
borderBottom: '1px solid rgba(128,128,128,0.08)',
|
|
322
|
+
},
|
|
323
|
+
clickRow: { cursor: 'pointer' },
|
|
324
|
+
};
|
|
325
|
+
// ─── CodeBlock ─────────────────────────────────────────────────
|
|
326
|
+
export function CodeBlock({ children, maxHeight = 200, style, }) {
|
|
327
|
+
return (_jsx("pre", { style: {
|
|
328
|
+
whiteSpace: 'pre-wrap',
|
|
329
|
+
wordBreak: 'break-word',
|
|
330
|
+
fontSize: 12,
|
|
331
|
+
maxHeight,
|
|
332
|
+
overflow: 'auto',
|
|
333
|
+
padding: 10,
|
|
334
|
+
borderRadius: 7,
|
|
335
|
+
background: 'rgba(128,128,128,0.06)',
|
|
336
|
+
border: `1px solid ${T.border}`,
|
|
337
|
+
margin: 0,
|
|
338
|
+
...style,
|
|
339
|
+
}, children: children }));
|
|
340
|
+
}
|
|
341
|
+
// ─── ProgressBar ───────────────────────────────────────────────
|
|
342
|
+
export function ProgressBar({ pct, color, height = 18, }) {
|
|
343
|
+
return (_jsx("div", { style: {
|
|
344
|
+
width: '100%',
|
|
345
|
+
height,
|
|
346
|
+
background: 'rgba(128,128,128,0.12)',
|
|
347
|
+
borderRadius: 5,
|
|
348
|
+
overflow: 'hidden',
|
|
349
|
+
position: 'relative',
|
|
350
|
+
}, children: _jsx("div", { style: {
|
|
351
|
+
width: `${Math.min(pct, 100)}%`,
|
|
352
|
+
height: '100%',
|
|
353
|
+
background: color ?? T.accent,
|
|
354
|
+
borderRadius: 5,
|
|
355
|
+
transition: 'width 0.3s ease',
|
|
356
|
+
} }) }));
|
|
357
|
+
}
|
|
358
|
+
// ─── Field label ───────────────────────────────────────────────
|
|
359
|
+
export function Field({ label, children, hint, }) {
|
|
360
|
+
return (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [_jsx("label", { style: { fontSize: 12, fontWeight: 600, color: T.text }, children: label }), children, hint !== undefined && (_jsx("span", { style: { fontSize: 11, color: T.muted }, children: hint }))] }));
|
|
361
|
+
}
|
|
362
|
+
// ─── usePanel hook (shared fetch logic) ────────────────────────
|
|
363
|
+
export function usePanel(path) {
|
|
364
|
+
const [payload, setPayload] = useState(null);
|
|
365
|
+
const [error, setError] = useState(null);
|
|
366
|
+
const [tick, setTick] = useState(0);
|
|
367
|
+
const reload = useCallback(() => setTick((v) => v + 1), []);
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
const c = new AbortController();
|
|
370
|
+
setError(null);
|
|
371
|
+
fetch(path, { signal: c.signal })
|
|
372
|
+
.then(async (r) => {
|
|
373
|
+
if (!r.ok)
|
|
374
|
+
throw new Error(String(r.status));
|
|
375
|
+
return r.json();
|
|
376
|
+
})
|
|
377
|
+
.then((p) => {
|
|
378
|
+
if (!c.signal.aborted)
|
|
379
|
+
setPayload(p);
|
|
380
|
+
})
|
|
381
|
+
.catch((e) => {
|
|
382
|
+
if (!c.signal.aborted)
|
|
383
|
+
setPayload(null), setError(e instanceof Error ? e.message : String(e));
|
|
384
|
+
});
|
|
385
|
+
return () => c.abort();
|
|
386
|
+
}, [tick, path]);
|
|
387
|
+
return useMemo(() => ({ payload, error, reload }), [payload, error, reload]);
|
|
388
|
+
}
|