@oh-my-pi/omp-stats 16.0.4 → 16.0.5
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/CHANGELOG.md +15 -0
- package/build.ts +11 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +11 -0
- package/dist/client/index.js +108 -108
- package/dist/client/styles.css +1070 -631
- package/dist/types/client/api.d.ts +19 -10
- package/dist/types/client/app/AppLayout.d.ts +16 -0
- package/dist/types/client/app/NavRail.d.ts +7 -0
- package/dist/types/client/app/RangeControl.d.ts +7 -0
- package/dist/types/client/app/SyncButton.d.ts +14 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/TopBar.d.ts +15 -0
- package/dist/types/client/app/routes.d.ts +12 -0
- package/dist/types/client/components/chart-shared.d.ts +26 -40
- package/dist/types/client/components/models-table-shared.d.ts +20 -40
- package/dist/types/client/data/charts.d.ts +1 -0
- package/dist/types/client/data/formatters.d.ts +7 -0
- package/dist/types/client/data/useHashRoute.d.ts +8 -0
- package/dist/types/client/data/useResource.d.ts +13 -0
- package/dist/types/client/data/view-models.d.ts +37 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
- package/dist/types/client/routes/CostsRoute.d.ts +7 -0
- package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
- package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
- package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
- package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
- package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
- package/dist/types/client/routes/index.d.ts +7 -0
- package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
- package/dist/types/client/ui/DataTable.d.ts +17 -0
- package/dist/types/client/ui/EmptyState.d.ts +7 -0
- package/dist/types/client/ui/ErrorState.d.ts +6 -0
- package/dist/types/client/ui/JsonBlock.d.ts +7 -0
- package/dist/types/client/ui/MetricCluster.d.ts +5 -0
- package/dist/types/client/ui/Panel.d.ts +7 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
- package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
- package/dist/types/client/ui/Skeleton.d.ts +8 -0
- package/dist/types/client/ui/StatusPill.d.ts +7 -0
- package/dist/types/client/ui/index.d.ts +11 -0
- package/dist/types/client/useSystemTheme.d.ts +9 -0
- package/package.json +4 -4
- package/src/aggregator.ts +4 -3
- package/src/client/App.tsx +89 -207
- package/src/client/api.ts +55 -37
- package/src/client/app/AppLayout.tsx +93 -0
- package/src/client/app/NavRail.tsx +44 -0
- package/src/client/app/RangeControl.tsx +39 -0
- package/src/client/app/SyncButton.tsx +75 -0
- package/src/client/app/ThemeToggle.tsx +37 -0
- package/src/client/app/TopBar.tsx +73 -0
- package/src/client/app/routes.ts +50 -0
- package/src/client/components/chart-shared.tsx +28 -91
- package/src/client/components/models-table-shared.tsx +9 -29
- package/src/client/components/range-meta.ts +3 -2
- package/src/client/data/charts.ts +14 -0
- package/src/client/data/formatters.ts +38 -0
- package/src/client/data/useHashRoute.ts +85 -0
- package/src/client/data/useResource.ts +154 -0
- package/src/client/data/view-models.ts +178 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/BehaviorRoute.tsx +623 -0
- package/src/client/routes/CostsRoute.tsx +234 -0
- package/src/client/routes/ErrorsRoute.tsx +118 -0
- package/src/client/routes/ModelsRoute.tsx +430 -0
- package/src/client/routes/OverviewRoute.tsx +332 -0
- package/src/client/routes/ProjectsRoute.tsx +163 -0
- package/src/client/routes/RequestsRoute.tsx +123 -0
- package/src/client/routes/index.ts +7 -0
- package/src/client/styles.css +1242 -225
- package/src/client/ui/AsyncBoundary.tsx +54 -0
- package/src/client/ui/DataTable.tsx +122 -0
- package/src/client/ui/EmptyState.tsx +16 -0
- package/src/client/ui/ErrorState.tsx +25 -0
- package/src/client/ui/JsonBlock.tsx +75 -0
- package/src/client/ui/MetricCluster.tsx +67 -0
- package/src/client/ui/Panel.tsx +24 -0
- package/src/client/ui/RequestDrawer.tsx +208 -0
- package/src/client/ui/SegmentedControl.tsx +36 -0
- package/src/client/ui/Skeleton.tsx +17 -0
- package/src/client/ui/StatusPill.tsx +15 -0
- package/src/client/ui/index.ts +11 -0
- package/src/client/useSystemTheme.ts +73 -17
- package/dist/types/client/components/BehaviorChart.d.ts +0 -6
- package/dist/types/client/components/BehaviorModelsTable.d.ts +0 -7
- package/dist/types/client/components/BehaviorSummary.d.ts +0 -7
- package/dist/types/client/components/ChartsContainer.d.ts +0 -7
- package/dist/types/client/components/CostChart.d.ts +0 -6
- package/dist/types/client/components/CostSummary.d.ts +0 -6
- package/dist/types/client/components/Header.d.ts +0 -12
- package/dist/types/client/components/ModelsTable.d.ts +0 -8
- package/dist/types/client/components/RequestDetail.d.ts +0 -6
- package/dist/types/client/components/RequestList.d.ts +0 -8
- package/dist/types/client/components/StatsGrid.d.ts +0 -6
- package/src/client/components/BehaviorChart.tsx +0 -189
- package/src/client/components/BehaviorModelsTable.tsx +0 -342
- package/src/client/components/BehaviorSummary.tsx +0 -95
- package/src/client/components/ChartsContainer.tsx +0 -221
- package/src/client/components/CostChart.tsx +0 -171
- package/src/client/components/CostSummary.tsx +0 -53
- package/src/client/components/Header.tsx +0 -72
- package/src/client/components/ModelsTable.tsx +0 -265
- package/src/client/components/RequestDetail.tsx +0 -172
- package/src/client/components/RequestList.tsx +0 -73
- package/src/client/components/StatsGrid.tsx +0 -135
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { RefreshCw } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { sync } from "../api";
|
|
4
|
+
|
|
5
|
+
export interface SyncButtonProps {
|
|
6
|
+
onSyncStart?: () => void;
|
|
7
|
+
onSyncComplete?: (result: {
|
|
8
|
+
success: boolean;
|
|
9
|
+
data?: { processed: number; files: number; totalMessages: number };
|
|
10
|
+
error?: string;
|
|
11
|
+
}) => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SyncButton({ onSyncStart, onSyncComplete, className = "" }: SyncButtonProps) {
|
|
16
|
+
const [syncing, setSyncing] = useState(false);
|
|
17
|
+
const [status, setStatus] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
|
18
|
+
|
|
19
|
+
const handleSync = async () => {
|
|
20
|
+
if (syncing) return;
|
|
21
|
+
|
|
22
|
+
setSyncing(true);
|
|
23
|
+
setStatus(null);
|
|
24
|
+
if (onSyncStart) {
|
|
25
|
+
onSyncStart();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const data = await sync();
|
|
30
|
+
const result = {
|
|
31
|
+
processed: typeof data?.processed === "number" ? data.processed : 0,
|
|
32
|
+
files: typeof data?.files === "number" ? data.files : 0,
|
|
33
|
+
totalMessages: typeof data?.totalMessages === "number" ? data.totalMessages : 0,
|
|
34
|
+
};
|
|
35
|
+
setStatus({
|
|
36
|
+
type: "success",
|
|
37
|
+
message: `Synced: ${result.processed} new request${result.processed === 1 ? "" : "s"} found.`,
|
|
38
|
+
});
|
|
39
|
+
if (onSyncComplete) {
|
|
40
|
+
onSyncComplete({ success: true, data: result });
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
44
|
+
setStatus({
|
|
45
|
+
type: "error",
|
|
46
|
+
message: `Sync failed: ${errorMessage}`,
|
|
47
|
+
});
|
|
48
|
+
if (onSyncComplete) {
|
|
49
|
+
onSyncComplete({ success: false, error: errorMessage });
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
setSyncing(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={`stats-sync-container ${className}`}>
|
|
58
|
+
{status && (
|
|
59
|
+
<span className="stats-sync-status-msg" data-type={status.type}>
|
|
60
|
+
{status.message}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={handleSync}
|
|
66
|
+
disabled={syncing}
|
|
67
|
+
className="stats-button stats-button-primary stats-sync-btn"
|
|
68
|
+
aria-busy={syncing}
|
|
69
|
+
>
|
|
70
|
+
<RefreshCw size={14} className={`stats-sync-icon ${syncing ? "stats-spin" : ""}`} />
|
|
71
|
+
{syncing ? "Syncing..." : "Sync DB"}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react";
|
|
2
|
+
import { type ThemePreference, useThemePreference } from "../useSystemTheme";
|
|
3
|
+
|
|
4
|
+
const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
|
|
5
|
+
system: "light",
|
|
6
|
+
light: "dark",
|
|
7
|
+
dark: "system",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const PREFERENCE_ICON: Record<ThemePreference, LucideIcon> = {
|
|
11
|
+
system: Monitor,
|
|
12
|
+
light: Sun,
|
|
13
|
+
dark: Moon,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const PREFERENCE_LABEL: Record<ThemePreference, string> = {
|
|
17
|
+
system: "System theme",
|
|
18
|
+
light: "Light theme",
|
|
19
|
+
dark: "Dark theme",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function ThemeToggle() {
|
|
23
|
+
const { preference, setPreference } = useThemePreference();
|
|
24
|
+
const Icon = PREFERENCE_ICON[preference];
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
className="stats-theme-toggle"
|
|
30
|
+
onClick={() => setPreference(NEXT_PREFERENCE[preference])}
|
|
31
|
+
aria-label={`${PREFERENCE_LABEL[preference]} (click to switch)`}
|
|
32
|
+
title={`${PREFERENCE_LABEL[preference]} — click to switch`}
|
|
33
|
+
>
|
|
34
|
+
<Icon size={16} />
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Menu } from "lucide-react";
|
|
2
|
+
import type { TimeRange } from "../types";
|
|
3
|
+
import { RangeControl } from "./RangeControl";
|
|
4
|
+
import type { DashboardSection } from "./routes";
|
|
5
|
+
import { routes } from "./routes";
|
|
6
|
+
import { SyncButton } from "./SyncButton";
|
|
7
|
+
import { ThemeToggle } from "./ThemeToggle";
|
|
8
|
+
|
|
9
|
+
export interface TopBarProps {
|
|
10
|
+
activeSection: DashboardSection;
|
|
11
|
+
range: TimeRange;
|
|
12
|
+
onRangeChange: (range: TimeRange) => void;
|
|
13
|
+
updatedAt: number | null;
|
|
14
|
+
onSyncStart?: () => void;
|
|
15
|
+
onSyncComplete?: (result: { success: boolean }) => void;
|
|
16
|
+
onMenuToggle?: () => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TopBar({
|
|
21
|
+
activeSection,
|
|
22
|
+
range,
|
|
23
|
+
onRangeChange,
|
|
24
|
+
updatedAt,
|
|
25
|
+
onSyncStart,
|
|
26
|
+
onSyncComplete,
|
|
27
|
+
onMenuToggle,
|
|
28
|
+
className = "",
|
|
29
|
+
}: TopBarProps) {
|
|
30
|
+
const currentRoute = routes.find(r => r.id === activeSection);
|
|
31
|
+
const title = currentRoute?.label || "Observability";
|
|
32
|
+
|
|
33
|
+
const formatLastUpdated = (time: number | null) => {
|
|
34
|
+
if (!time) return "Not updated";
|
|
35
|
+
const date = new Date(time);
|
|
36
|
+
return `Updated ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<header className={`stats-top-bar ${className}`}>
|
|
41
|
+
<div className="stats-top-bar-left">
|
|
42
|
+
{onMenuToggle && (
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={onMenuToggle}
|
|
46
|
+
className="stats-mobile-menu-btn"
|
|
47
|
+
aria-label="Open navigation menu"
|
|
48
|
+
>
|
|
49
|
+
<Menu size={20} />
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
<h1 className="stats-page-title">{title}</h1>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="stats-top-bar-right">
|
|
56
|
+
<div className="stats-top-bar-meta">
|
|
57
|
+
<span
|
|
58
|
+
className="stats-last-updated"
|
|
59
|
+
title={updatedAt ? new Date(updatedAt).toLocaleString() : undefined}
|
|
60
|
+
>
|
|
61
|
+
{formatLastUpdated(updatedAt)}
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<RangeControl value={range} onChange={onRangeChange} />
|
|
66
|
+
|
|
67
|
+
<ThemeToggle />
|
|
68
|
+
|
|
69
|
+
<SyncButton onSyncStart={onSyncStart} onSyncComplete={onSyncComplete} />
|
|
70
|
+
</div>
|
|
71
|
+
</header>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Activity, AlertCircle, Coins, Cpu, Folder, LayoutDashboard, Smile } from "lucide-react";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
|
|
4
|
+
export type DashboardSection = "overview" | "requests" | "errors" | "models" | "costs" | "behavior" | "projects";
|
|
5
|
+
|
|
6
|
+
export interface DashboardRoute {
|
|
7
|
+
id: DashboardSection;
|
|
8
|
+
label: string;
|
|
9
|
+
shortLabel?: string;
|
|
10
|
+
icon: React.ComponentType<{ size?: number; className?: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const routes: DashboardRoute[] = [
|
|
14
|
+
{
|
|
15
|
+
id: "overview",
|
|
16
|
+
label: "Overview",
|
|
17
|
+
icon: LayoutDashboard,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "requests",
|
|
21
|
+
label: "Requests",
|
|
22
|
+
icon: Activity,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "errors",
|
|
26
|
+
label: "Errors",
|
|
27
|
+
icon: AlertCircle,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "models",
|
|
31
|
+
label: "Models",
|
|
32
|
+
icon: Cpu,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "costs",
|
|
36
|
+
label: "Costs",
|
|
37
|
+
icon: Coins,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "behavior",
|
|
41
|
+
label: "Behavior",
|
|
42
|
+
shortLabel: "Behavior",
|
|
43
|
+
icon: Smile,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "projects",
|
|
47
|
+
label: "Projects",
|
|
48
|
+
icon: Folder,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
@@ -1,40 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared chart primitives for the dashboard timeline charts
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Shared chart primitives for the dashboard timeline charts: the OMP color
|
|
3
|
+
* palette, light/dark chart chrome, legend/tooltip + scale plumbing, dataset
|
|
4
|
+
* styling, and the top-N-by-model / aggregate bucketing used by the cost and
|
|
5
|
+
* behavior series.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { format } from "date-fns";
|
|
9
9
|
|
|
10
|
+
// OMP brand palette (packages/collab-web/src/styles/tokens.css): pink/purple/cyan.
|
|
11
|
+
// Categorical series lead with the brand gradient hues (pink -> purple -> cyan).
|
|
10
12
|
export const MODEL_COLORS = [
|
|
11
|
-
"#
|
|
12
|
-
"#
|
|
13
|
-
"#
|
|
14
|
-
"#
|
|
15
|
-
"#
|
|
16
|
-
"#
|
|
17
|
-
"#
|
|
13
|
+
"#ed4abf", // brand pink (accent)
|
|
14
|
+
"#9b4dff", // brand violet
|
|
15
|
+
"#5ad8e6", // brand cyan
|
|
16
|
+
"#62d394", // green
|
|
17
|
+
"#c77dff", // light purple
|
|
18
|
+
"#ff8fd1", // light pink
|
|
19
|
+
"#f5c14b", // amber
|
|
20
|
+
"#ff6b7d", // rose
|
|
18
21
|
];
|
|
19
22
|
|
|
20
23
|
export const CHART_THEMES = {
|
|
21
24
|
dark: {
|
|
22
|
-
legendLabel: "#
|
|
23
|
-
tooltipBackground: "#
|
|
24
|
-
tooltipTitle: "#
|
|
25
|
-
tooltipBody: "#
|
|
26
|
-
tooltipBorder: "rgba(255, 255, 255, 0.
|
|
25
|
+
legendLabel: "#a89fb3",
|
|
26
|
+
tooltipBackground: "#241a2e",
|
|
27
|
+
tooltipTitle: "#eae5ef",
|
|
28
|
+
tooltipBody: "#a89fb3",
|
|
29
|
+
tooltipBorder: "rgba(255, 255, 255, 0.12)",
|
|
27
30
|
grid: "rgba(255, 255, 255, 0.06)",
|
|
28
|
-
tick: "#
|
|
31
|
+
tick: "#867a93",
|
|
29
32
|
},
|
|
30
33
|
light: {
|
|
31
|
-
legendLabel: "#
|
|
34
|
+
legendLabel: "#5a5462",
|
|
32
35
|
tooltipBackground: "#ffffff",
|
|
33
|
-
tooltipTitle: "#
|
|
34
|
-
tooltipBody: "#
|
|
35
|
-
tooltipBorder: "rgba(
|
|
36
|
-
grid: "rgba(
|
|
37
|
-
tick: "#
|
|
36
|
+
tooltipTitle: "#241a2e",
|
|
37
|
+
tooltipBody: "#5a5462",
|
|
38
|
+
tooltipBorder: "rgba(20, 12, 28, 0.15)",
|
|
39
|
+
grid: "rgba(20, 12, 28, 0.08)",
|
|
40
|
+
tick: "#6a6275",
|
|
38
41
|
},
|
|
39
42
|
} as const;
|
|
40
43
|
|
|
@@ -128,7 +131,8 @@ export function barDatasetStyle(color: string) {
|
|
|
128
131
|
backgroundColor: color,
|
|
129
132
|
borderColor: color,
|
|
130
133
|
borderWidth: 0,
|
|
131
|
-
borderRadius:
|
|
134
|
+
borderRadius: 4,
|
|
135
|
+
maxBarThickness: 56,
|
|
132
136
|
};
|
|
133
137
|
}
|
|
134
138
|
|
|
@@ -251,70 +255,3 @@ export function buildTopNByModelSeries<T extends ModelKeyedPoint, B>(
|
|
|
251
255
|
})),
|
|
252
256
|
};
|
|
253
257
|
}
|
|
254
|
-
|
|
255
|
-
/** All Models / By Model segmented toggle — identical UI in every time chart. */
|
|
256
|
-
function ByModelToggle({ byModel, onChange }: { byModel: boolean; onChange: (v: boolean) => void }) {
|
|
257
|
-
return (
|
|
258
|
-
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
|
|
259
|
-
<button
|
|
260
|
-
type="button"
|
|
261
|
-
onClick={() => onChange(false)}
|
|
262
|
-
className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
|
|
263
|
-
>
|
|
264
|
-
All Models
|
|
265
|
-
</button>
|
|
266
|
-
<button type="button" onClick={() => onChange(true)} className={`tab-btn text-xs ${byModel ? "active" : ""}`}>
|
|
267
|
-
By Model
|
|
268
|
-
</button>
|
|
269
|
-
</div>
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Outer surface card used by both time charts. `controls` slot covers
|
|
275
|
-
* chart-specific tabs (e.g. behavior metric picker); the by-model toggle and
|
|
276
|
-
* empty-state are part of the frame so callers don't redeclare them.
|
|
277
|
-
*/
|
|
278
|
-
export function ChartFrame({
|
|
279
|
-
title,
|
|
280
|
-
subtitle,
|
|
281
|
-
empty,
|
|
282
|
-
emptyMessage,
|
|
283
|
-
controls,
|
|
284
|
-
byModel,
|
|
285
|
-
onByModelChange,
|
|
286
|
-
children,
|
|
287
|
-
}: {
|
|
288
|
-
title: string;
|
|
289
|
-
subtitle: string;
|
|
290
|
-
empty: boolean;
|
|
291
|
-
emptyMessage: string;
|
|
292
|
-
controls?: React.ReactNode;
|
|
293
|
-
byModel: boolean;
|
|
294
|
-
onByModelChange: (v: boolean) => void;
|
|
295
|
-
children: React.ReactNode;
|
|
296
|
-
}) {
|
|
297
|
-
return (
|
|
298
|
-
<div className="surface overflow-hidden">
|
|
299
|
-
<div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
|
|
300
|
-
<div>
|
|
301
|
-
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
302
|
-
<p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p>
|
|
303
|
-
</div>
|
|
304
|
-
<div className="flex items-center gap-2 flex-wrap">
|
|
305
|
-
{controls}
|
|
306
|
-
<ByModelToggle byModel={byModel} onChange={onByModelChange} />
|
|
307
|
-
</div>
|
|
308
|
-
</div>
|
|
309
|
-
<div className="p-5 min-h-[320px]">
|
|
310
|
-
{empty ? (
|
|
311
|
-
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
312
|
-
{emptyMessage}
|
|
313
|
-
</div>
|
|
314
|
-
) : (
|
|
315
|
-
<div className="h-[280px]">{children}</div>
|
|
316
|
-
)}
|
|
317
|
-
</div>
|
|
318
|
-
</div>
|
|
319
|
-
);
|
|
320
|
-
}
|
|
@@ -1,39 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared primitives for the per-model breakdown tables
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* chrome, expand-row plumbing, theme palette, and the mini-sparkline plus
|
|
2
|
+
* Shared primitives for the per-model breakdown tables. Each table owns its
|
|
3
|
+
* column definitions, sort order, and chart type — this module owns the
|
|
4
|
+
* surface chrome, expand-row plumbing, theme palette, the mini-sparkline, and
|
|
6
5
|
* the shared plugin/scale config consumed by multi-line detail charts.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { format } from "date-fns";
|
|
10
9
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
11
10
|
import { Line } from "react-chartjs-2";
|
|
11
|
+
import type { ChartTheme } from "./chart-shared";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
legendLabel: "#cbd5e1",
|
|
18
|
-
tooltipBackground: "#16161e",
|
|
19
|
-
tooltipTitle: "#f8fafc",
|
|
20
|
-
tooltipBody: "#94a3b8",
|
|
21
|
-
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
22
|
-
grid: "rgba(255, 255, 255, 0.06)",
|
|
23
|
-
tick: "#94a3b8",
|
|
24
|
-
},
|
|
25
|
-
light: {
|
|
26
|
-
legendLabel: "#334155",
|
|
27
|
-
tooltipBackground: "#ffffff",
|
|
28
|
-
tooltipTitle: "#0f172a",
|
|
29
|
-
tooltipBody: "#334155",
|
|
30
|
-
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
31
|
-
grid: "rgba(15, 23, 42, 0.08)",
|
|
32
|
-
tick: "#475569",
|
|
33
|
-
},
|
|
34
|
-
} as const;
|
|
35
|
-
|
|
36
|
-
export type TableChartTheme = (typeof TABLE_CHART_THEMES)[keyof typeof TABLE_CHART_THEMES];
|
|
13
|
+
// Detail-table charts share the exact OMP chart chrome as the timeline charts;
|
|
14
|
+
// re-export rather than duplicate so the palette has a single source of truth.
|
|
15
|
+
export { CHART_THEMES as TABLE_CHART_THEMES, MODEL_COLORS } from "./chart-shared";
|
|
16
|
+
export type TableChartTheme = ChartTheme;
|
|
37
17
|
|
|
38
18
|
/** Style defaults for one line in a non-stacked detail chart. */
|
|
39
19
|
export function lineSeriesStyle(color: string) {
|
|
@@ -167,7 +147,7 @@ export function ModelTableShell({
|
|
|
167
147
|
children: React.ReactNode;
|
|
168
148
|
}) {
|
|
169
149
|
return (
|
|
170
|
-
<div className="
|
|
150
|
+
<div className="stats-panel overflow-hidden">
|
|
171
151
|
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
|
|
172
152
|
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
173
153
|
{subtitle ? <p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p> : null}
|
|
@@ -9,6 +9,7 @@ import type { TimeRange } from "../types";
|
|
|
9
9
|
|
|
10
10
|
const HOUR_MS = 60 * 60 * 1000;
|
|
11
11
|
const DAY_MS = 24 * HOUR_MS;
|
|
12
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
12
13
|
|
|
13
14
|
export interface RangeMeta {
|
|
14
15
|
/** Human label used in chart subtitles ("the last 24 hours"). */
|
|
@@ -27,8 +28,8 @@ const RANGE_META: Record<TimeRange, RangeMeta> = {
|
|
|
27
28
|
"1h": {
|
|
28
29
|
windowLabel: "the last hour",
|
|
29
30
|
trendLabel: "1h Trend",
|
|
30
|
-
bucketMs:
|
|
31
|
-
bucketCount:
|
|
31
|
+
bucketMs: FIVE_MIN_MS,
|
|
32
|
+
bucketCount: 12,
|
|
32
33
|
tickFormat: "HH:mm",
|
|
33
34
|
},
|
|
34
35
|
"24h": {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarElement,
|
|
3
|
+
CategoryScale,
|
|
4
|
+
Chart as ChartJS,
|
|
5
|
+
Filler,
|
|
6
|
+
Legend,
|
|
7
|
+
LinearScale,
|
|
8
|
+
LineElement,
|
|
9
|
+
PointElement,
|
|
10
|
+
Title,
|
|
11
|
+
Tooltip,
|
|
12
|
+
} from "chart.js";
|
|
13
|
+
|
|
14
|
+
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { formatDistanceToNow } from "date-fns";
|
|
2
|
+
|
|
3
|
+
export function formatInteger(value: number): string {
|
|
4
|
+
return value.toLocaleString();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatCompact(value: number): string {
|
|
8
|
+
return value.toLocaleString(undefined, { notation: "compact" });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatCost(value: number, digits?: number): string {
|
|
12
|
+
if (value === 0) return "$0";
|
|
13
|
+
const fractionDigits = digits !== undefined ? digits : value > 0 && value < 0.01 ? 4 : 2;
|
|
14
|
+
return `$${value.toLocaleString(undefined, {
|
|
15
|
+
minimumFractionDigits: fractionDigits,
|
|
16
|
+
maximumFractionDigits: fractionDigits,
|
|
17
|
+
})}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatPercent(value: number, digits = 1): string {
|
|
21
|
+
return `${(value * 100).toFixed(digits)}%`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatDurationMs(value: number | null, digits?: number): string {
|
|
25
|
+
if (value === null) return "-";
|
|
26
|
+
const sec = value / 1000;
|
|
27
|
+
const d = digits !== undefined ? digits : sec < 1 ? 2 : 1;
|
|
28
|
+
return `${sec.toFixed(d)}s`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatTokensPerSecond(value: number | null): string {
|
|
32
|
+
if (value === null) return "-";
|
|
33
|
+
return value.toFixed(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatRelativeTime(timestamp: number): string {
|
|
37
|
+
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
|
38
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import type { DashboardSection } from "../app/routes";
|
|
3
|
+
import type { TimeRange } from "../types";
|
|
4
|
+
|
|
5
|
+
const VALID_SECTIONS: DashboardSection[] = [
|
|
6
|
+
"overview",
|
|
7
|
+
"requests",
|
|
8
|
+
"errors",
|
|
9
|
+
"models",
|
|
10
|
+
"costs",
|
|
11
|
+
"behavior",
|
|
12
|
+
"projects",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
|
|
16
|
+
|
|
17
|
+
function parseHash(hash: string): { section: DashboardSection; range: TimeRange } {
|
|
18
|
+
const cleanHash = hash.replace(/^#\/?/, "");
|
|
19
|
+
const [pathPart, queryPart] = cleanHash.split("?");
|
|
20
|
+
|
|
21
|
+
const section: DashboardSection = (VALID_SECTIONS as string[]).includes(pathPart)
|
|
22
|
+
? (pathPart as DashboardSection)
|
|
23
|
+
: "overview";
|
|
24
|
+
|
|
25
|
+
let range: TimeRange = "24h";
|
|
26
|
+
if (queryPart) {
|
|
27
|
+
const params = new URLSearchParams(queryPart);
|
|
28
|
+
const rangeParam = params.get("range") as TimeRange;
|
|
29
|
+
if (VALID_RANGES.includes(rangeParam)) {
|
|
30
|
+
range = rangeParam;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { section, range };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useHashRoute() {
|
|
38
|
+
const [route, setRouteState] = useState(() => parseHash(window.location.hash));
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const handleHashChange = () => {
|
|
42
|
+
setRouteState(parseHash(window.location.hash));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
window.addEventListener("hashchange", handleHashChange);
|
|
46
|
+
return () => {
|
|
47
|
+
window.removeEventListener("hashchange", handleHashChange);
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const updateHash = useCallback((section: string, range: TimeRange) => {
|
|
52
|
+
window.location.hash = `/${section}?range=${range}`;
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const setSection = useCallback(
|
|
56
|
+
(newSection: DashboardSection) => {
|
|
57
|
+
updateHash(newSection, route.range);
|
|
58
|
+
},
|
|
59
|
+
[route.range, updateHash],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const setRange = useCallback(
|
|
63
|
+
(newRange: string) => {
|
|
64
|
+
const nextRange = VALID_RANGES.includes(newRange as TimeRange) ? (newRange as TimeRange) : "24h";
|
|
65
|
+
updateHash(route.section, nextRange);
|
|
66
|
+
},
|
|
67
|
+
[route.section, updateHash],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const currentHash = window.location.hash;
|
|
72
|
+
const parsed = parseHash(currentHash);
|
|
73
|
+
const expectedHash = `#/${parsed.section}?range=${parsed.range}`;
|
|
74
|
+
if (currentHash !== expectedHash) {
|
|
75
|
+
window.location.hash = `/${parsed.section}?range=${parsed.range}`;
|
|
76
|
+
}
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
section: route.section,
|
|
81
|
+
setSection,
|
|
82
|
+
range: route.range,
|
|
83
|
+
setRange,
|
|
84
|
+
};
|
|
85
|
+
}
|