@cryptiklemur/lattice 1.4.0 → 1.5.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.
@@ -0,0 +1,93 @@
1
+ import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
2
+
3
+ var MODEL_COLORS: Record<string, string> = {
4
+ opus: "#a855f7",
5
+ sonnet: "oklch(55% 0.25 280)",
6
+ haiku: "#22c55e",
7
+ other: "#f59e0b",
8
+ };
9
+
10
+ function getModelColor(model: string): string {
11
+ var key = model.toLowerCase();
12
+ if (key.includes("opus")) return MODEL_COLORS.opus;
13
+ if (key.includes("sonnet")) return MODEL_COLORS.sonnet;
14
+ if (key.includes("haiku")) return MODEL_COLORS.haiku;
15
+ return MODEL_COLORS.other;
16
+ }
17
+
18
+ interface ModelUsage {
19
+ model: string;
20
+ cost: number;
21
+ percentage: number;
22
+ }
23
+
24
+ interface CostDonutChartProps {
25
+ modelUsage: ModelUsage[];
26
+ totalCost: number;
27
+ }
28
+
29
+ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; payload: ModelUsage }> }) {
30
+ if (!active || !payload || payload.length === 0) return null;
31
+ var entry = payload[0];
32
+ return (
33
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
34
+ <p className="text-[10px] font-mono text-base-content/50 mb-1">{entry.name}</p>
35
+ <p className="text-[11px] font-mono text-base-content">${entry.value.toFixed(4)}</p>
36
+ <p className="text-[10px] font-mono text-base-content/50">{entry.payload.percentage.toFixed(1)}%</p>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ function CenterLabel({ totalCost }: { totalCost: number }) {
42
+ return (
43
+ <text x="50%" y="50%" textAnchor="middle" dominantBaseline="middle">
44
+ <tspan x="50%" dy="-0.4em" style={{ fontSize: 11, fontFamily: "var(--font-mono)", fill: "oklch(0.9 0.02 280 / 0.4)" }}>
45
+ TOTAL
46
+ </tspan>
47
+ <tspan x="50%" dy="1.4em" style={{ fontSize: 14, fontFamily: "var(--font-mono)", fill: "oklch(0.9 0.02 280 / 0.9)", fontWeight: 700 }}>
48
+ ${totalCost.toFixed(2)}
49
+ </tspan>
50
+ </text>
51
+ );
52
+ }
53
+
54
+ export function CostDonutChart({ modelUsage, totalCost }: CostDonutChartProps) {
55
+ return (
56
+ <div>
57
+ <ResponsiveContainer width="100%" height={200}>
58
+ <PieChart>
59
+ <Pie
60
+ data={modelUsage}
61
+ dataKey="cost"
62
+ nameKey="model"
63
+ innerRadius={50}
64
+ outerRadius={80}
65
+ paddingAngle={2}
66
+ startAngle={90}
67
+ endAngle={-270}
68
+ >
69
+ {modelUsage.map(function (entry, index) {
70
+ return <Cell key={entry.model + index} fill={getModelColor(entry.model)} />;
71
+ })}
72
+ </Pie>
73
+ <Tooltip content={<CustomTooltip />} />
74
+ <CenterLabel totalCost={totalCost} />
75
+ </PieChart>
76
+ </ResponsiveContainer>
77
+ <div className="flex flex-wrap justify-center gap-3 mt-2">
78
+ {modelUsage.map(function (entry) {
79
+ return (
80
+ <div key={entry.model} className="flex items-center gap-1.5 text-[10px] font-mono text-base-content/50">
81
+ <span
82
+ className="inline-block w-2 h-2 rounded-full flex-shrink-0"
83
+ style={{ background: getModelColor(entry.model) }}
84
+ />
85
+ <span className="capitalize">{entry.model}</span>
86
+ <span className="text-base-content/30">{entry.percentage.toFixed(1)}%</span>
87
+ </div>
88
+ );
89
+ })}
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,62 @@
1
+ import {
2
+ AreaChart,
3
+ Area,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from "recharts";
10
+
11
+ var TICK_STYLE = {
12
+ fontSize: 10,
13
+ fontFamily: "var(--font-mono)",
14
+ fill: "oklch(0.9 0.02 280 / 0.3)",
15
+ };
16
+
17
+ var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
18
+
19
+ interface CumulativeDatum {
20
+ date: string;
21
+ total: number;
22
+ }
23
+
24
+ interface CumulativeCostChartProps {
25
+ data: CumulativeDatum[];
26
+ }
27
+
28
+ function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ value: number }>; label?: string }) {
29
+ if (!active || !payload || payload.length === 0) return null;
30
+ return (
31
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
32
+ <p className="text-[10px] font-mono text-base-content/50 mb-1">{label}</p>
33
+ <p className="text-[11px] font-mono text-base-content">${payload[0].value.toFixed(4)}</p>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export function CumulativeCostChart({ data }: CumulativeCostChartProps) {
39
+ return (
40
+ <ResponsiveContainer width="100%" height={200}>
41
+ <AreaChart data={data} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
42
+ <defs>
43
+ <linearGradient id="cumulativeGrad" x1="0" y1="0" x2="0" y2="1">
44
+ <stop offset="5%" stopColor="oklch(55% 0.25 280)" stopOpacity={0.3} />
45
+ <stop offset="95%" stopColor="oklch(55% 0.25 280)" stopOpacity={0} />
46
+ </linearGradient>
47
+ </defs>
48
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} vertical={false} />
49
+ <XAxis dataKey="date" tick={TICK_STYLE} axisLine={false} tickLine={false} />
50
+ <YAxis tick={TICK_STYLE} axisLine={false} tickLine={false} tickFormatter={function (v) { return "$" + v.toFixed(2); }} />
51
+ <Tooltip content={<CustomTooltip />} />
52
+ <Area
53
+ type="monotone"
54
+ dataKey="total"
55
+ stroke="oklch(55% 0.25 280)"
56
+ fill="url(#cumulativeGrad)"
57
+ strokeWidth={2}
58
+ />
59
+ </AreaChart>
60
+ </ResponsiveContainer>
61
+ );
62
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ ScatterChart,
3
+ Scatter,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ ZAxis,
10
+ } from "recharts";
11
+
12
+ var TICK_STYLE = {
13
+ fontSize: 10,
14
+ fontFamily: "var(--font-mono)",
15
+ fill: "oklch(0.9 0.02 280 / 0.3)",
16
+ };
17
+
18
+ var GRID_COLOR = "oklch(0.9 0.02 280 / 0.06)";
19
+
20
+ var PROJECT_PALETTE = [
21
+ "oklch(55% 0.25 280)",
22
+ "#a855f7",
23
+ "#22c55e",
24
+ "#f59e0b",
25
+ "oklch(65% 0.2 240)",
26
+ "oklch(65% 0.25 25)",
27
+ "oklch(65% 0.25 150)",
28
+ "oklch(70% 0.2 60)",
29
+ ];
30
+
31
+ interface SessionBubbleDatum {
32
+ id: string;
33
+ title: string;
34
+ cost: number;
35
+ tokens: number;
36
+ timestamp: number;
37
+ project: string;
38
+ }
39
+
40
+ interface SessionBubbleChartProps {
41
+ data: SessionBubbleDatum[];
42
+ }
43
+
44
+ function formatDate(ts: number): string {
45
+ var d = new Date(ts);
46
+ return (d.getMonth() + 1) + "/" + d.getDate();
47
+ }
48
+
49
+ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: SessionBubbleDatum }> }) {
50
+ if (!active || !payload || payload.length === 0) return null;
51
+ var d = payload[0].payload;
52
+ return (
53
+ <div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg max-w-[180px]">
54
+ <p className="text-[10px] font-mono text-base-content/50 mb-1 truncate">{d.title || d.id}</p>
55
+ <div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
56
+ <p><span className="text-base-content/40">cost </span>${d.cost.toFixed(4)}</p>
57
+ <p><span className="text-base-content/40">tokens </span>{d.tokens.toLocaleString()}</p>
58
+ <p><span className="text-base-content/40">project </span>{d.project}</p>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ export function SessionBubbleChart({ data }: SessionBubbleChartProps) {
65
+ var projects = Array.from(new Set(data.map(function (d) { return d.project; })));
66
+
67
+ function getColor(project: string): string {
68
+ var idx = projects.indexOf(project);
69
+ return PROJECT_PALETTE[idx % PROJECT_PALETTE.length];
70
+ }
71
+
72
+ var byProject = projects.map(function (project) {
73
+ return {
74
+ project,
75
+ color: getColor(project),
76
+ points: data
77
+ .filter(function (d) { return d.project === project; })
78
+ .map(function (d) { return { ...d, x: d.timestamp, y: d.tokens, z: Math.max(d.cost * 1000, 20) }; }),
79
+ };
80
+ });
81
+
82
+ var minTs = Math.min(...data.map(function (d) { return d.timestamp; }));
83
+ var maxTs = Math.max(...data.map(function (d) { return d.timestamp; }));
84
+
85
+ return (
86
+ <ResponsiveContainer width="100%" height={200}>
87
+ <ScatterChart margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
88
+ <CartesianGrid strokeDasharray="3 3" stroke={GRID_COLOR} />
89
+ <XAxis
90
+ dataKey="x"
91
+ type="number"
92
+ domain={[minTs, maxTs]}
93
+ tick={TICK_STYLE}
94
+ axisLine={false}
95
+ tickLine={false}
96
+ tickFormatter={function (v) { return formatDate(v); }}
97
+ />
98
+ <YAxis
99
+ dataKey="y"
100
+ type="number"
101
+ tick={TICK_STYLE}
102
+ axisLine={false}
103
+ tickLine={false}
104
+ tickFormatter={function (v) { return v >= 1000 ? (v / 1000).toFixed(0) + "k" : String(v); }}
105
+ />
106
+ <ZAxis dataKey="z" range={[20, 300]} />
107
+ <Tooltip content={<CustomTooltip />} cursor={{ strokeDasharray: "3 3", stroke: GRID_COLOR }} />
108
+ {byProject.map(function (group) {
109
+ return (
110
+ <Scatter
111
+ key={group.project}
112
+ name={group.project}
113
+ data={group.points}
114
+ fill={group.color}
115
+ fillOpacity={0.7}
116
+ />
117
+ );
118
+ })}
119
+ </ScatterChart>
120
+ </ResponsiveContainer>
121
+ );
122
+ }
@@ -4,6 +4,7 @@ import { useProjects } from "../../hooks/useProjects";
4
4
  import { useSidebar } from "../../hooks/useSidebar";
5
5
  import { useWebSocket } from "../../hooks/useWebSocket";
6
6
  import { LatticeLogomark } from "../ui/LatticeLogomark";
7
+ import { QuickStats } from "../analytics/QuickStats";
7
8
  import {
8
9
  Network, FolderOpen, Activity, MessageSquare, Menu,
9
10
  ChevronRight, Lock, Bug,
@@ -118,6 +119,10 @@ export function DashboardView() {
118
119
  </div>
119
120
  </div>
120
121
 
122
+ <div className="mt-4 mb-8">
123
+ <QuickStats />
124
+ </div>
125
+
121
126
  {sessions.length > 0 && (
122
127
  <div className="mb-8">
123
128
  <h2 className="text-[11px] font-semibold tracking-wider uppercase text-base-content/40 mb-3">Recent Sessions</h2>
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
- import { Plus, ChevronDown, Search, LayoutDashboard, FolderOpen, TerminalSquare, StickyNote, Calendar } from "lucide-react";
2
+ import { Plus, ChevronDown, Search, LayoutDashboard, FolderOpen, TerminalSquare, StickyNote, Calendar, BarChart3 } from "lucide-react";
3
3
  import { LatticeLogomark } from "../ui/LatticeLogomark";
4
4
  import type { SessionSummary, ServerMessage, SettingsDataMessage } from "@lattice/shared";
5
5
  import { useProjects } from "../../hooks/useProjects";
@@ -10,7 +10,7 @@ import { useSession } from "../../hooks/useSession";
10
10
  import { clearSession } from "../../stores/session";
11
11
  import { useOnline } from "../../hooks/useOnline";
12
12
  import { openTab } from "../../stores/workspace";
13
- import { getSidebarStore } from "../../stores/sidebar";
13
+ import { getSidebarStore, goToAnalytics } from "../../stores/sidebar";
14
14
  import { ProjectRail } from "./ProjectRail";
15
15
  import { SessionList } from "./SessionList";
16
16
  import { UserIsland } from "./UserIsland";
@@ -175,6 +175,14 @@ export function Sidebar({ onSessionSelect }: { onSessionSelect?: () => void }) {
175
175
  </button>
176
176
  );
177
177
  })}
178
+ <button
179
+ type="button"
180
+ onClick={goToAnalytics}
181
+ className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-[11px] text-base-content/40 hover:text-base-content/70 hover:bg-base-300/30 transition-colors"
182
+ >
183
+ <BarChart3 size={12} />
184
+ <span className="font-mono tracking-wide">Analytics</span>
185
+ </button>
178
186
  </div>
179
187
 
180
188
  <SectionLabel
@@ -0,0 +1,75 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useStore } from "@tanstack/react-store";
3
+ import { useWebSocket } from "./useWebSocket";
4
+ import type { ServerMessage } from "@lattice/shared";
5
+ import type { AnalyticsPeriod, AnalyticsScope } from "@lattice/shared";
6
+ import {
7
+ getAnalyticsStore,
8
+ setAnalyticsData,
9
+ setAnalyticsLoading,
10
+ setAnalyticsError,
11
+ setAnalyticsPeriod,
12
+ setAnalyticsScope,
13
+ } from "../stores/analytics";
14
+ import type { AnalyticsState } from "../stores/analytics";
15
+
16
+ export function useAnalytics(): AnalyticsState & {
17
+ setPeriod: (period: AnalyticsPeriod) => void;
18
+ setScope: (scope: AnalyticsScope, projectSlug?: string) => void;
19
+ refresh: () => void;
20
+ } {
21
+ var store = getAnalyticsStore();
22
+ var state = useStore(store, function (s) { return s; });
23
+ var { send, subscribe, unsubscribe } = useWebSocket();
24
+ var sendRef = useRef(send);
25
+ sendRef.current = send;
26
+
27
+ function requestAnalytics(forceRefresh?: boolean) {
28
+ var s = getAnalyticsStore().state;
29
+ setAnalyticsLoading(true);
30
+ sendRef.current({
31
+ type: "analytics:request",
32
+ requestId: crypto.randomUUID(),
33
+ scope: s.scope,
34
+ projectSlug: s.projectSlug || undefined,
35
+ period: s.period,
36
+ forceRefresh: forceRefresh,
37
+ } as any);
38
+ }
39
+
40
+ useEffect(function () {
41
+ function handleData(msg: ServerMessage) {
42
+ var m = msg as { type: string; data: any };
43
+ setAnalyticsData(m.data);
44
+ }
45
+
46
+ function handleError(msg: ServerMessage) {
47
+ var m = msg as { type: string; message: string };
48
+ setAnalyticsError(m.message);
49
+ }
50
+
51
+ subscribe("analytics:data", handleData);
52
+ subscribe("analytics:error", handleError);
53
+
54
+ return function () {
55
+ unsubscribe("analytics:data", handleData);
56
+ unsubscribe("analytics:error", handleError);
57
+ };
58
+ }, [subscribe, unsubscribe]);
59
+
60
+ useEffect(function () {
61
+ requestAnalytics();
62
+ }, [state.period, state.scope, state.projectSlug]);
63
+
64
+ return {
65
+ data: state.data,
66
+ loading: state.loading,
67
+ error: state.error,
68
+ period: state.period,
69
+ scope: state.scope,
70
+ projectSlug: state.projectSlug,
71
+ setPeriod: setAnalyticsPeriod,
72
+ setScope: setAnalyticsScope,
73
+ refresh: function () { requestAnalytics(true); },
74
+ };
75
+ }
@@ -8,6 +8,7 @@ import { SettingsView } from "./components/settings/SettingsView";
8
8
  import { ProjectSettingsView } from "./components/project-settings/ProjectSettingsView";
9
9
  import { DashboardView } from "./components/dashboard/DashboardView";
10
10
  import { ProjectDashboardView } from "./components/dashboard/ProjectDashboardView";
11
+ import { AnalyticsView } from "./components/analytics/AnalyticsView";
11
12
  import { NodeSettingsModal } from "./components/sidebar/NodeSettingsModal";
12
13
  import { AddProjectModal } from "./components/sidebar/AddProjectModal";
13
14
  import { useSidebar } from "./hooks/useSidebar";
@@ -421,6 +422,9 @@ function IndexPage() {
421
422
  if (sidebar.activeView.type === "project-dashboard") {
422
423
  return <ProjectDashboardView />;
423
424
  }
425
+ if (sidebar.activeView.type === "analytics") {
426
+ return <AnalyticsView />;
427
+ }
424
428
  return <WorkspaceView />;
425
429
  }
426
430
 
@@ -0,0 +1,54 @@
1
+ import { Store } from "@tanstack/react-store";
2
+ import type { AnalyticsPayload, AnalyticsPeriod, AnalyticsScope } from "@lattice/shared";
3
+
4
+ export interface AnalyticsState {
5
+ data: AnalyticsPayload | null;
6
+ loading: boolean;
7
+ error: string | null;
8
+ period: AnalyticsPeriod;
9
+ scope: AnalyticsScope;
10
+ projectSlug: string | null;
11
+ }
12
+
13
+ var analyticsStore = new Store<AnalyticsState>({
14
+ data: null,
15
+ loading: false,
16
+ error: null,
17
+ period: "7d",
18
+ scope: "global",
19
+ projectSlug: null,
20
+ });
21
+
22
+ export function getAnalyticsStore(): Store<AnalyticsState> {
23
+ return analyticsStore;
24
+ }
25
+
26
+ export function setAnalyticsData(data: AnalyticsPayload): void {
27
+ analyticsStore.setState(function (state) {
28
+ return { ...state, data: data, loading: false, error: null };
29
+ });
30
+ }
31
+
32
+ export function setAnalyticsLoading(loading: boolean): void {
33
+ analyticsStore.setState(function (state) {
34
+ return { ...state, loading: loading };
35
+ });
36
+ }
37
+
38
+ export function setAnalyticsError(error: string): void {
39
+ analyticsStore.setState(function (state) {
40
+ return { ...state, error: error, loading: false };
41
+ });
42
+ }
43
+
44
+ export function setAnalyticsPeriod(period: AnalyticsPeriod): void {
45
+ analyticsStore.setState(function (state) {
46
+ return { ...state, period: period };
47
+ });
48
+ }
49
+
50
+ export function setAnalyticsScope(scope: AnalyticsScope, projectSlug?: string): void {
51
+ analyticsStore.setState(function (state) {
52
+ return { ...state, scope: scope, projectSlug: projectSlug || null };
53
+ });
54
+ }
@@ -13,6 +13,7 @@ export type SidebarMode = "project" | "settings";
13
13
  export type ActiveView =
14
14
  | { type: "dashboard" }
15
15
  | { type: "project-dashboard" }
16
+ | { type: "analytics" }
16
17
  | { type: "chat" }
17
18
  | { type: "settings"; section: SettingsSection }
18
19
  | { type: "project-settings"; section: ProjectSettingsSection };
@@ -246,6 +247,13 @@ export function goToDashboard(): void {
246
247
  pushUrl(null, null);
247
248
  }
248
249
 
250
+ export function goToAnalytics(): void {
251
+ sidebarStore.setState(function (state) {
252
+ return { ...state, activeView: { type: "analytics" }, sidebarMode: "project" };
253
+ });
254
+ pushUrl(sidebarStore.state.activeProjectSlug, null);
255
+ }
256
+
249
257
  export function handlePopState(): void {
250
258
  var url = parseInitialUrl();
251
259
  if (url.settingsSection) {
@@ -10,6 +10,7 @@ export default defineConfig({
10
10
  VitePWA({
11
11
  registerType: "prompt",
12
12
  workbox: {
13
+ maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
13
14
  globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
14
15
  navigateFallback: "/index.html",
15
16
  navigateFallbackDenylist: [/^\/ws/, /^\/api/],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cryptiklemur/lattice",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
5
5
  "license": "MIT",
6
6
  "author": "Aaron Scherer <me@aaronscherer.me>",