@enigmax/dashboard 0.1.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/LICENSE +202 -0
- package/README.md +22 -0
- package/assets/index.html +645 -0
- package/assets/lib/chart.min.js +7 -0
- package/package.json +32 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Enigma - Context Savings</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0b0e14; --surface: #151a23; --surface2: #1b2230; --border: #232a36;
|
|
10
|
+
--text: #e6e6e6; --muted: #8a93a3; --accent: #e0a458; --accent2: #d7875f; --good: #6fcf97;
|
|
11
|
+
}
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
body {
|
|
14
|
+
margin: 0; background: var(--bg); color: var(--text);
|
|
15
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
16
|
+
-webkit-font-smoothing: antialiased; padding: 24px; max-width: 1000px; margin: 0 auto;
|
|
17
|
+
}
|
|
18
|
+
a { color: var(--accent2); }
|
|
19
|
+
header { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
|
|
20
|
+
.wordmark { font-size: 20px; font-weight: 700; letter-spacing: 0.5px; }
|
|
21
|
+
.wordmark span { color: var(--accent); }
|
|
22
|
+
.sub { color: var(--muted); font-size: 13px; }
|
|
23
|
+
.live { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
|
24
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); }
|
|
25
|
+
.dot.stale { background: var(--muted); box-shadow: none; }
|
|
26
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
27
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
|
|
28
|
+
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; }
|
|
29
|
+
.card .value { font-size: 28px; font-weight: 700; margin-top: 6px; }
|
|
30
|
+
.card .value.accent { color: var(--accent); }
|
|
31
|
+
.card .value.good { color: var(--good); }
|
|
32
|
+
.card .foot { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
|
33
|
+
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; margin-bottom: 20px; }
|
|
34
|
+
.panel-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
|
35
|
+
.panel h2 { font-size: 14px; font-weight: 600; margin: 0; color: var(--text); }
|
|
36
|
+
.panel h2 small { color: var(--muted); font-weight: 400; margin-left: 8px; }
|
|
37
|
+
.ctrls { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
38
|
+
.ranges { display: flex; gap: 4px; }
|
|
39
|
+
.ranges button {
|
|
40
|
+
background: var(--surface2); color: var(--muted); border: 1px solid var(--border);
|
|
41
|
+
border-radius: 7px; padding: 4px 10px; font-size: 12px; cursor: pointer; font-family: inherit;
|
|
42
|
+
}
|
|
43
|
+
.ranges button:hover { color: var(--text); }
|
|
44
|
+
.ranges button.active { background: var(--accent); color: #1a1206; border-color: var(--accent); font-weight: 600; }
|
|
45
|
+
.chartwrap { position: relative; width: 100%; height: 280px; }
|
|
46
|
+
/* Suppress the charting library's attribution logo. */
|
|
47
|
+
#tv-attr-logo { display: none !important; }
|
|
48
|
+
.nav { margin-left: auto; display: flex; align-items: center; gap: 18px; }
|
|
49
|
+
.links { display: flex; gap: 14px; align-items: center; }
|
|
50
|
+
.links a { color: var(--muted); display: inline-flex; transition: color 0.15s; }
|
|
51
|
+
.links a:hover { color: var(--accent); }
|
|
52
|
+
.links svg { width: 19px; height: 19px; display: block; }
|
|
53
|
+
.tip {
|
|
54
|
+
position: absolute; pointer-events: none; z-index: 5; background: var(--surface2);
|
|
55
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 12px;
|
|
56
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.4); white-space: nowrap;
|
|
57
|
+
}
|
|
58
|
+
.tip .tip-d { color: var(--muted); }
|
|
59
|
+
.tip .tip-v { color: var(--accent); font-weight: 600; margin-top: 2px; }
|
|
60
|
+
.bar-row { display: flex; align-items: center; gap: 12px; margin: 10px 0; font-size: 13px; }
|
|
61
|
+
.bar-row .name { width: 70px; color: var(--muted); }
|
|
62
|
+
.bar-track { flex: 1; height: 14px; background: var(--surface2); border-radius: 7px; overflow: hidden; }
|
|
63
|
+
.bar-fill { height: 100%; border-radius: 7px; }
|
|
64
|
+
.bar-row .num { width: 90px; text-align: right; font-variant-numeric: tabular-nums; }
|
|
65
|
+
.bar-row .money { width: 78px; text-align: right; color: var(--good); font-variant-numeric: tabular-nums; }
|
|
66
|
+
.empty { color: var(--muted); font-size: 14px; text-align: center; padding: 40px 0; }
|
|
67
|
+
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
68
|
+
.tbl th { text-align: left; color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
|
|
69
|
+
.tbl td { padding: 7px 10px; border-bottom: 1px solid var(--surface2); font-variant-numeric: tabular-nums; }
|
|
70
|
+
.tbl tr:last-child td { border-bottom: none; }
|
|
71
|
+
.tbl .r { text-align: right; }
|
|
72
|
+
.tbl .good { color: var(--good); }
|
|
73
|
+
.tbl-wrap { max-height: 320px; overflow-y: auto; }
|
|
74
|
+
.stat-line { display: flex; gap: 24px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
75
|
+
.stat-line div { font-size: 13px; color: var(--muted); }
|
|
76
|
+
.stat-line b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
77
|
+
footer { color: var(--muted); font-size: 12px; line-height: 1.6; margin-top: 8px; }
|
|
78
|
+
code { background: var(--surface2); padding: 1px 6px; border-radius: 5px; color: var(--accent2); }
|
|
79
|
+
.set-cat { margin-bottom: 18px; }
|
|
80
|
+
.set-cat:last-child { margin-bottom: 0; }
|
|
81
|
+
.set-cat-h { color: var(--accent); font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 4px; }
|
|
82
|
+
.set-cat-b { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
|
|
83
|
+
.set-row { display: flex; align-items: center; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--surface2); }
|
|
84
|
+
.set-row:last-child { border-bottom: none; }
|
|
85
|
+
.set-meta { flex: 1; min-width: 0; }
|
|
86
|
+
.set-name { font-size: 13px; color: var(--text); }
|
|
87
|
+
.set-hint { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
88
|
+
.set-ctl { flex-shrink: 0; }
|
|
89
|
+
.toggle {
|
|
90
|
+
background: var(--surface2); color: var(--muted); border: 1px solid var(--border);
|
|
91
|
+
border-radius: 7px; padding: 5px 16px; font-size: 12px; cursor: pointer; font-family: inherit; min-width: 56px;
|
|
92
|
+
}
|
|
93
|
+
.toggle:hover { color: var(--text); }
|
|
94
|
+
.toggle.on { background: var(--good); color: #06210f; border-color: var(--good); font-weight: 600; }
|
|
95
|
+
select.choice {
|
|
96
|
+
background: var(--surface2); color: var(--text); border: 1px solid var(--border);
|
|
97
|
+
border-radius: 7px; padding: 5px 10px; font-size: 12px; font-family: inherit; cursor: pointer;
|
|
98
|
+
}
|
|
99
|
+
.set-note { color: var(--accent); font-size: 12px; min-height: 16px; margin-top: 10px; }
|
|
100
|
+
@media (max-width: 720px) { .grid { grid-template-columns: repeat(2, 1fr); } }
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<header>
|
|
105
|
+
<div>
|
|
106
|
+
<div class="wordmark">Enigma<span>.</span></div>
|
|
107
|
+
<div class="sub">Context Compression Savings</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="nav">
|
|
110
|
+
<span class="live"><span id="dot" class="dot stale"></span><span id="updated">Connecting...</span></span>
|
|
111
|
+
<nav class="links">
|
|
112
|
+
<a href="https://github.com/FJRG2007/enigma" target="_blank" rel="noopener noreferrer" title="GitHub repository" aria-label="GitHub repository">
|
|
113
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .322.216.694.825.576C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
|
114
|
+
</a>
|
|
115
|
+
<a href="https://tpe.li/dsc" target="_blank" rel="noopener noreferrer" title="Discord" aria-label="Discord">
|
|
116
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9461 2.4189-2.1568 2.4189Z"/></svg>
|
|
117
|
+
</a>
|
|
118
|
+
<a href="https://ko-fi.com/fjrg2007" target="_blank" rel="noopener noreferrer" title="Support on Ko-fi" aria-label="Support on Ko-fi">
|
|
119
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg>
|
|
120
|
+
</a>
|
|
121
|
+
</nav>
|
|
122
|
+
</div>
|
|
123
|
+
</header>
|
|
124
|
+
|
|
125
|
+
<div class="grid">
|
|
126
|
+
<div class="card"><div class="label">Money Saved</div><div id="money" class="value good">-</div><div id="moneyFoot" class="foot">Estimated, by source</div></div>
|
|
127
|
+
<div class="card"><div class="label">Time Saved</div><div id="time" class="value accent">-</div><div id="timeFoot" class="foot">Estimated prefill time</div></div>
|
|
128
|
+
<div class="card"><div class="label">Tokens Saved</div><div id="saved" class="value">-</div><div id="savedFoot" class="foot">Across all compressions</div></div>
|
|
129
|
+
<div class="card"><div class="label">Savings Rate</div><div id="rate" class="value accent">-</div><div class="foot">Of input tokens removed</div></div>
|
|
130
|
+
<div class="card"><div class="label">Compressions</div><div id="calls" class="value">-</div><div class="foot">Reversible via CCR</div></div>
|
|
131
|
+
<div class="card"><div class="label">Avg / Compression</div><div id="avg" class="value">-</div><div class="foot">Tokens saved per call</div></div>
|
|
132
|
+
<div class="card"><div class="label">Best Compression</div><div id="best" class="value">-</div><div class="foot">Most saved in one call</div></div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div class="panel">
|
|
136
|
+
<div class="panel-head">
|
|
137
|
+
<h2>Savings Per Day <small id="rangeLabel"></small></h2>
|
|
138
|
+
<div class="ctrls">
|
|
139
|
+
<div class="ranges" id="metricToggle">
|
|
140
|
+
<button type="button" data-m="tokens" class="active">Tokens</button>
|
|
141
|
+
<button type="button" data-m="usd">$</button>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="ranges" id="modeToggle">
|
|
144
|
+
<button type="button" data-o="daily" class="active">Daily</button>
|
|
145
|
+
<button type="button" data-o="cumulative">Cumulative</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="ranges" id="ranges">
|
|
148
|
+
<button type="button" data-d="7">7D</button>
|
|
149
|
+
<button type="button" data-d="30" class="active">30D</button>
|
|
150
|
+
<button type="button" data-d="90">90D</button>
|
|
151
|
+
<button type="button" data-d="0">All</button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div id="chartWrap" class="chartwrap"></div>
|
|
156
|
+
<div id="chartEmpty" class="empty" style="display:none">No compression activity recorded yet. Run <code>enigma compress <file></code> or enable the compression MCP (<code>enigma config compress on</code>).</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div class="panel" id="usagePanel" style="display:none">
|
|
160
|
+
<h2 style="margin-bottom:4px">Real Tool Usage <small id="usageSub">Claude Code transcripts</small></h2>
|
|
161
|
+
<div class="sub" style="margin-bottom:14px">Measured token consumption and real prompt-cache savings, read-only from your own session logs. Deliberately not attributed to skills or output-style: a transcript has no counterfactual baseline, so any such number would be invented.</div>
|
|
162
|
+
<div class="grid" style="margin-bottom:16px">
|
|
163
|
+
<div class="card"><div class="label">Cache Saved</div><div id="uCacheMoney" class="value good">-</div><div class="foot">Est. from prompt caching</div></div>
|
|
164
|
+
<div class="card"><div class="label">Cache Reads</div><div id="uCacheRead" class="value accent">-</div><div class="foot">Tokens served from cache</div></div>
|
|
165
|
+
<div class="card"><div class="label">Input Tokens</div><div id="uInput" class="value">-</div><div class="foot">Consumed across sessions</div></div>
|
|
166
|
+
<div class="card"><div class="label">Output Tokens</div><div id="uOutput" class="value">-</div><div class="foot">Generated by the agent</div></div>
|
|
167
|
+
<div class="card"><div class="label">Sessions</div><div id="uSessions" class="value">-</div><div id="uSessionsFoot" class="foot">Transcripts scanned</div></div>
|
|
168
|
+
</div>
|
|
169
|
+
<h2 style="margin:0 0 10px">By Model</h2>
|
|
170
|
+
<div id="uByModel"></div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div class="panel" id="usageHint" style="display:none">
|
|
174
|
+
<h2 style="margin-bottom:8px">Real Tool Usage <small>opt-in</small></h2>
|
|
175
|
+
<div class="sub">Show real token consumption and prompt-cache savings from your Claude Code sessions with <code>enigma config usage-stats on</code>. enigma reads only your local session transcripts; nothing is sent anywhere.</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div class="panel">
|
|
179
|
+
<h2 style="margin-bottom:14px">Savings By Source <small>which app/CLI compressed</small></h2>
|
|
180
|
+
<div id="sources"></div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="panel">
|
|
184
|
+
<h2 style="margin-bottom:14px">Savings By Content Type <small>what enigma compressed</small></h2>
|
|
185
|
+
<div id="types"></div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="panel">
|
|
189
|
+
<h2 style="margin-bottom:14px">Before vs. After <small id="ratioLabel">cumulative tokens</small></h2>
|
|
190
|
+
<div class="bar-row"><span class="name">Before</span><div class="bar-track"><div id="barBefore" class="bar-fill" style="background:var(--accent2)"></div></div><span id="numBefore" class="num">-</span></div>
|
|
191
|
+
<div class="bar-row"><span class="name">After</span><div class="bar-track"><div id="barAfter" class="bar-fill" style="background:var(--good)"></div></div><span id="numAfter" class="num">-</span></div>
|
|
192
|
+
<div class="stat-line" style="margin:14px 0 0"><div>Removed <b id="removed">-</b></div><div>Reduction <b id="reduction">-</b></div><div>Compression ratio <b id="ratio">-</b></div></div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="panel">
|
|
196
|
+
<div class="panel-head">
|
|
197
|
+
<h2>Savings History <small id="histStats"></small></h2>
|
|
198
|
+
<div class="ctrls">
|
|
199
|
+
<div class="ranges" id="histToggle">
|
|
200
|
+
<button type="button" data-p="day" class="active">Daily</button>
|
|
201
|
+
<button type="button" data-p="week">Weekly</button>
|
|
202
|
+
<button type="button" data-p="month">Monthly</button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="tbl-wrap"><div id="histBody"></div></div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div class="panel">
|
|
210
|
+
<h2 style="margin-bottom:14px">Recent Compressions <small>newest first</small></h2>
|
|
211
|
+
<div class="tbl-wrap"><div id="recent"></div></div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div class="panel">
|
|
215
|
+
<h2 style="margin-bottom:14px">Reversible Cache (CCR) <small>recoverable originals on disk</small></h2>
|
|
216
|
+
<div id="cache"></div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div class="panel">
|
|
220
|
+
<h2 style="margin-bottom:4px">Enigma Settings <small>same options as the terminal UI</small></h2>
|
|
221
|
+
<div class="sub" style="margin-bottom:14px">Everything configurable from <code>enigma config</code> / the TUI, editable here. Changes apply at global scope and take effect immediately; memory toggles need an agent restart. Numeric estimates use <code>enigma config token-price <usd></code> and <code>enigma config token-speed <tok/s></code>.</div>
|
|
222
|
+
<div id="settingsBody"><div class="empty" style="padding:24px 0">Loading settings...</div></div>
|
|
223
|
+
<div id="settingsNote" class="set-note"></div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<footer>
|
|
227
|
+
Polling pauses automatically when this tab is hidden, and the server caches each snapshot - so an open dashboard costs almost nothing.
|
|
228
|
+
Data source: <code>~/.enigma/ccr/stats.json</code> + <code>history.jsonl</code> - reset it with <code>enigma compress --clear</code>. Enigma <span id="ver"></span>
|
|
229
|
+
</footer>
|
|
230
|
+
|
|
231
|
+
<script src="/lib/chart.min.js"></script>
|
|
232
|
+
<script>
|
|
233
|
+
const POLL_MS = 5000;
|
|
234
|
+
const DAY = 86400000;
|
|
235
|
+
const $ = (id) => document.getElementById(id);
|
|
236
|
+
|
|
237
|
+
function fmt(n) {
|
|
238
|
+
n = Number(n) || 0;
|
|
239
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
|
|
240
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
|
241
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
|
242
|
+
return String(Math.round(n));
|
|
243
|
+
}
|
|
244
|
+
function fmtUsd(n) {
|
|
245
|
+
n = Number(n) || 0;
|
|
246
|
+
if (n >= 1e3) return "$" + (n / 1e3).toFixed(2) + "k";
|
|
247
|
+
if (n >= 1) return "$" + n.toFixed(2);
|
|
248
|
+
return "$" + n.toFixed(n >= 0.1 ? 2 : 3);
|
|
249
|
+
}
|
|
250
|
+
function fmtBytes(n) {
|
|
251
|
+
n = Number(n) || 0;
|
|
252
|
+
if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB";
|
|
253
|
+
if (n >= 1024) return (n / 1024).toFixed(1) + " KB";
|
|
254
|
+
return n + " B";
|
|
255
|
+
}
|
|
256
|
+
function fmtDuration(s) {
|
|
257
|
+
s = Number(s) || 0;
|
|
258
|
+
if (s <= 0) return "0s";
|
|
259
|
+
if (s < 1) return "<1s";
|
|
260
|
+
if (s < 60) return s.toFixed(0) + "s";
|
|
261
|
+
if (s < 3600) return (s / 60).toFixed(1) + "m";
|
|
262
|
+
if (s < 86400) return (s / 3600).toFixed(1) + "h";
|
|
263
|
+
return (s / 86400).toFixed(1) + "d";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Estimated price per 1M INPUT tokens, by source. enigma is not a proxy, so it
|
|
267
|
+
// can't know the real model - these are sensible defaults, overridable globally
|
|
268
|
+
// with `enigma config token-price <usd>` (priceOverride from the payload).
|
|
269
|
+
const PRICE_1M = {
|
|
270
|
+
"claude-code": 3, "claude": 3, "claude-desktop": 3,
|
|
271
|
+
"opencode": 3, "codex": 2.5, "codex-cli": 2.5, "cli": 3, "unknown": 3,
|
|
272
|
+
};
|
|
273
|
+
const DEFAULT_PRICE_1M = 3;
|
|
274
|
+
let priceOverride = 0;
|
|
275
|
+
function priceFor(source) { return priceOverride > 0 ? priceOverride : (PRICE_1M[source] ?? DEFAULT_PRICE_1M); }
|
|
276
|
+
function moneyOf(source, savedTokens) { return savedTokens * priceFor(source) / 1e6; }
|
|
277
|
+
|
|
278
|
+
// Real prompt-cache saving: cache-read tokens cost a fraction of full input price,
|
|
279
|
+
// so the saving vs. reprocessing them is ~90% of input price for those tokens.
|
|
280
|
+
const CACHE_SAVING_FRACTION = 0.9;
|
|
281
|
+
function cacheMoney(cacheRead) { return cacheRead * priceFor("claude") * CACHE_SAVING_FRACTION / 1e6; }
|
|
282
|
+
|
|
283
|
+
// Estimated model prefill speed (tokens/sec) used to turn saved INPUT tokens into
|
|
284
|
+
// saved time. enigma is not a proxy and can't know the real model, so this is a
|
|
285
|
+
// conservative default, overridable globally with `enigma config token-speed <tps>`
|
|
286
|
+
// (speedOverride from the payload). Time saved = savedTokens / speed.
|
|
287
|
+
const DEFAULT_TPS = 3000;
|
|
288
|
+
let speedOverride = 0;
|
|
289
|
+
function tps() { return speedOverride > 0 ? speedOverride : DEFAULT_TPS; }
|
|
290
|
+
|
|
291
|
+
// Friendly names for known MCP clients / content types; unknown -> title-cased raw.
|
|
292
|
+
const SOURCE_LABELS = {
|
|
293
|
+
"claude-code": "Claude Code", "claude": "Claude Code", "claude-desktop": "Claude Desktop",
|
|
294
|
+
"opencode": "OpenCode", "codex": "Codex", "codex-cli": "Codex",
|
|
295
|
+
"cli": "CLI (enigma compress)", "unknown": "Unattributed",
|
|
296
|
+
};
|
|
297
|
+
function prettySource(k) { return SOURCE_LABELS[k] || String(k).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); }
|
|
298
|
+
const TYPE_LABELS = { json: "JSON", log: "Logs", text: "Text", code: "Code", diff: "Diffs", markdown: "Markdown", unknown: "Other" };
|
|
299
|
+
function prettyType(k) { return TYPE_LABELS[k] || String(k).replace(/\b\w/g, (c) => c.toUpperCase()); }
|
|
300
|
+
|
|
301
|
+
// One bar per key (source or type), sorted by tokens saved, with an optional $ column.
|
|
302
|
+
function renderBreakdown(el, map, label, money) {
|
|
303
|
+
const entries = Object.entries(map || {}).sort((a, b) => (b[1].tokensSaved || 0) - (a[1].tokensSaved || 0));
|
|
304
|
+
if (!entries.length) {
|
|
305
|
+
el.innerHTML = '<div class="empty" style="padding:20px 0">No data yet - this fills in as compressions are recorded.</div>';
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const max = Math.max(1, ...entries.map(([, v]) => v.tokensSaved || 0));
|
|
309
|
+
el.innerHTML = entries.map(([k, v]) => {
|
|
310
|
+
const saved = v.tokensSaved || 0, pct = Math.round(saved / max * 100);
|
|
311
|
+
const m = money ? '<span class="money">' + fmtUsd(moneyOf(k, saved)) + '</span>' : '';
|
|
312
|
+
return '<div class="bar-row"><span class="name" style="width:140px">' + label(k)
|
|
313
|
+
+ '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent)"></div></div>'
|
|
314
|
+
+ '<span class="num">' + fmt(saved) + '</span>' + m
|
|
315
|
+
+ '<span class="foot" style="width:64px;text-align:right;margin:0">' + fmt(v.calls || 0) + ' calls</span></div>';
|
|
316
|
+
}).join("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// A millisecond timestamp -> the UTC calendar day as the YYYY-MM-DD string the
|
|
320
|
+
// chart's time scale expects (matches the server's UTC-day history buckets).
|
|
321
|
+
function dayKey(ms) { return new Date(ms).toISOString().slice(0, 10); }
|
|
322
|
+
|
|
323
|
+
const savedOf = (p) => Math.max(0, (p.b || 0) - (p.a || 0));
|
|
324
|
+
|
|
325
|
+
// Recent compressions table (newest first), one row per history point.
|
|
326
|
+
function renderRecent(history) {
|
|
327
|
+
const rows = (history || []).slice(-20).reverse();
|
|
328
|
+
const el = $("recent");
|
|
329
|
+
if (!rows.length) { el.innerHTML = '<div class="empty" style="padding:24px 0">No compressions recorded yet.</div>'; return; }
|
|
330
|
+
el.innerHTML = '<table class="tbl"><thead><tr><th>Time</th><th>Type</th><th>Source</th>'
|
|
331
|
+
+ '<th class="r">Before</th><th class="r">After</th><th class="r">Saved</th></tr></thead><tbody>'
|
|
332
|
+
+ rows.map((p) => {
|
|
333
|
+
const saved = savedOf(p), pct = p.b ? Math.round(saved / p.b * 100) : 0;
|
|
334
|
+
return '<tr><td>' + new Date(p.t).toLocaleString() + '</td><td>' + prettyType(p.c || "unknown")
|
|
335
|
+
+ '</td><td>' + prettySource(p.s || "unknown") + '</td><td class="r">' + fmt(p.b || 0)
|
|
336
|
+
+ '</td><td class="r">' + fmt(p.a || 0) + '</td><td class="r good">' + fmt(saved) + " (" + pct + "%)</td></tr>";
|
|
337
|
+
}).join("") + "</tbody></table>";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ISO-ish period key for grouping (day = YYYY-MM-DD, week = YYYY-Www, month = YYYY-MM).
|
|
341
|
+
function periodKey(t, period) {
|
|
342
|
+
const d = new Date(t);
|
|
343
|
+
if (period === "month") return d.toISOString().slice(0, 7);
|
|
344
|
+
if (period === "week") {
|
|
345
|
+
const onejan = Date.UTC(d.getUTCFullYear(), 0, 1);
|
|
346
|
+
const week = Math.ceil(((d.getTime() - onejan) / DAY + 1) / 7);
|
|
347
|
+
return d.getUTCFullYear() + "-W" + String(week).padStart(2, "0");
|
|
348
|
+
}
|
|
349
|
+
return dayKey(t);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let histPeriod = "day";
|
|
353
|
+
function renderHistory(history) {
|
|
354
|
+
const hist = history || [];
|
|
355
|
+
const days = new Set(hist.map((p) => dayKey(p.t))).size;
|
|
356
|
+
const total = hist.reduce((s, p) => s + savedOf(p), 0);
|
|
357
|
+
$("histStats").textContent = days ? days + " active day" + (days === 1 ? "" : "s") + " · avg " + fmt(Math.round(total / days)) + "/day" : "";
|
|
358
|
+
const byP = new Map();
|
|
359
|
+
for (const p of hist) {
|
|
360
|
+
const k = periodKey(p.t, histPeriod);
|
|
361
|
+
const cur = byP.get(k) || { tokens: 0, usd: 0, calls: 0 };
|
|
362
|
+
cur.tokens += savedOf(p);
|
|
363
|
+
cur.usd += moneyOf(p.s || "unknown", savedOf(p));
|
|
364
|
+
cur.calls++;
|
|
365
|
+
byP.set(k, cur);
|
|
366
|
+
}
|
|
367
|
+
const rows = [...byP.entries()].sort((a, b) => a[0] < b[0] ? 1 : -1);
|
|
368
|
+
const el = $("histBody");
|
|
369
|
+
if (!rows.length) { el.innerHTML = '<div class="empty" style="padding:24px 0">No history yet.</div>'; return; }
|
|
370
|
+
el.innerHTML = '<table class="tbl"><thead><tr><th>Period</th><th class="r">Compressions</th>'
|
|
371
|
+
+ '<th class="r">Tokens Saved</th><th class="r">$ Saved</th></tr></thead><tbody>'
|
|
372
|
+
+ rows.map(([k, v]) => '<tr><td>' + k + '</td><td class="r">' + v.calls + '</td><td class="r good">'
|
|
373
|
+
+ fmt(v.tokens) + '</td><td class="r">' + fmtUsd(v.usd) + "</td></tr>").join("") + "</tbody></table>";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Real tool-usage panel (Claude Code transcripts). null = the opt-in flag is off.
|
|
377
|
+
function renderUsage(u) {
|
|
378
|
+
const panel = $("usagePanel"), hint = $("usageHint");
|
|
379
|
+
if (!u) { panel.style.display = "none"; hint.style.display = "block"; return; }
|
|
380
|
+
hint.style.display = "none";
|
|
381
|
+
panel.style.display = "block";
|
|
382
|
+
$("uCacheMoney").textContent = fmtUsd(cacheMoney(u.cacheRead || 0));
|
|
383
|
+
$("uCacheRead").textContent = fmt(u.cacheRead || 0);
|
|
384
|
+
$("uInput").textContent = fmt(u.input || 0);
|
|
385
|
+
$("uOutput").textContent = fmt(u.output || 0);
|
|
386
|
+
$("uSessions").textContent = fmt(u.sessions || 0);
|
|
387
|
+
$("uSessionsFoot").textContent = u.pending ? "scanning sessions..." : fmt(u.scannedFiles || 0) + " files scanned";
|
|
388
|
+
const entries = Object.entries(u.byModel || {}).sort((a, b) => ((b[1].input + b[1].output) - (a[1].input + a[1].output)));
|
|
389
|
+
const el = $("uByModel");
|
|
390
|
+
if (!entries.length) {
|
|
391
|
+
el.innerHTML = '<div class="empty" style="padding:20px 0">' + (u.pending ? "Scanning session transcripts..." : "No usage recorded yet.") + "</div>";
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
el.innerHTML = '<table class="tbl"><thead><tr><th>Model</th><th class="r">Input</th><th class="r">Output</th>'
|
|
395
|
+
+ '<th class="r">Cache reads</th><th class="r">Messages</th></tr></thead><tbody>'
|
|
396
|
+
+ entries.map(([m, v]) => '<tr><td>' + m + '</td><td class="r">' + fmt(v.input) + '</td><td class="r">'
|
|
397
|
+
+ fmt(v.output) + '</td><td class="r good">' + fmt(v.cacheRead) + '</td><td class="r">' + fmt(v.messages) + "</td></tr>").join("")
|
|
398
|
+
+ "</tbody></table>";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function renderCache(c) {
|
|
402
|
+
const el = $("cache");
|
|
403
|
+
if (!c) { el.innerHTML = '<div class="empty" style="padding:20px 0">No cache data.</div>'; return; }
|
|
404
|
+
const pct = c.cap ? Math.round((c.count || 0) / c.cap * 100) : 0;
|
|
405
|
+
el.innerHTML = '<div class="bar-row"><span class="name" style="width:170px">Cached originals</span>'
|
|
406
|
+
+ '<div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--accent)"></div></div>'
|
|
407
|
+
+ '<span class="num">' + fmt(c.count || 0) + " / " + fmt(c.cap || 0) + '</span></div>'
|
|
408
|
+
+ '<div class="stat-line" style="margin-top:6px"><div>On disk <b>' + fmtBytes(c.bytes || 0) + '</b></div>'
|
|
409
|
+
+ '<div>Retention <b>newest ' + fmt(c.cap || 0) + '</b></div>'
|
|
410
|
+
+ '<div>Restore <b>enigma compress --retrieve <hash></b></div></div>';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Aggregate raw history points into one {time, tokens, usd} per day (ascending,
|
|
414
|
+
// unique times). $ per point uses that point's recorded source price.
|
|
415
|
+
function dailySeries(history) {
|
|
416
|
+
const byDay = new Map();
|
|
417
|
+
for (const p of history || []) {
|
|
418
|
+
const saved = Math.max(0, (p.b || 0) - (p.a || 0));
|
|
419
|
+
const key = dayKey(p.t);
|
|
420
|
+
const cur = byDay.get(key) || { tokens: 0, usd: 0 };
|
|
421
|
+
cur.tokens += saved;
|
|
422
|
+
cur.usd += moneyOf(p.s || "unknown", saved);
|
|
423
|
+
byDay.set(key, cur);
|
|
424
|
+
}
|
|
425
|
+
return [...byDay.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1)
|
|
426
|
+
.map(([time, v]) => ({ time, tokens: v.tokens, usd: v.usd }));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let chart = null, series = null, dailyData = [], lastHistory = [], currentRange = 30, metric = "tokens", mode = "daily";
|
|
430
|
+
const metricFmt = (v) => metric === "usd" ? fmtUsd(v) : fmt(v);
|
|
431
|
+
|
|
432
|
+
// Map the per-day data to LC {time, value} for the active metric and mode.
|
|
433
|
+
function seriesValues() {
|
|
434
|
+
let cum = 0;
|
|
435
|
+
return dailyData.map((d) => {
|
|
436
|
+
const base = metric === "usd" ? d.usd : d.tokens;
|
|
437
|
+
if (mode === "cumulative") { cum += base; return { time: d.time, value: cum }; }
|
|
438
|
+
return { time: d.time, value: base };
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function initChart() {
|
|
443
|
+
if (!window.LightweightCharts) return false;
|
|
444
|
+
const LC = window.LightweightCharts;
|
|
445
|
+
const el = $("chartWrap");
|
|
446
|
+
chart = LC.createChart(el, {
|
|
447
|
+
autoSize: true,
|
|
448
|
+
layout: { background: { type: "solid", color: "transparent" }, textColor: "#8a93a3", fontSize: 12 },
|
|
449
|
+
grid: { vertLines: { color: "rgba(35,42,54,0.5)" }, horzLines: { color: "rgba(35,42,54,0.5)" } },
|
|
450
|
+
rightPriceScale: { borderColor: "#232a36" },
|
|
451
|
+
timeScale: { borderColor: "#232a36", fixLeftEdge: true, fixRightEdge: true },
|
|
452
|
+
crosshair: {
|
|
453
|
+
mode: LC.CrosshairMode.Magnet,
|
|
454
|
+
vertLine: { color: "#3a4150", labelBackgroundColor: "#e0a458" },
|
|
455
|
+
horzLine: { color: "#3a4150", labelBackgroundColor: "#e0a458" },
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
series = chart.addSeries(LC.AreaSeries, {
|
|
459
|
+
lineColor: "#e0a458", topColor: "rgba(224,164,88,0.35)", bottomColor: "rgba(224,164,88,0.02)",
|
|
460
|
+
lineWidth: 2, priceLineVisible: false, lastValueVisible: false,
|
|
461
|
+
priceFormat: { type: "custom", minMove: 1, formatter: metricFmt },
|
|
462
|
+
});
|
|
463
|
+
setupTooltip(el);
|
|
464
|
+
// Button groups: select one, restyle siblings, run the picker.
|
|
465
|
+
const group = (id, onPick) => $(id).addEventListener("click", (e) => {
|
|
466
|
+
const b = e.target.closest("button");
|
|
467
|
+
if (!b) return;
|
|
468
|
+
for (const x of $(id).children) x.classList.toggle("active", x === b);
|
|
469
|
+
onPick(b);
|
|
470
|
+
});
|
|
471
|
+
group("ranges", (b) => { currentRange = Number(b.dataset.d); applyRange(); });
|
|
472
|
+
group("metricToggle", (b) => { metric = b.dataset.m; redraw(); });
|
|
473
|
+
group("modeToggle", (b) => { mode = b.dataset.o; redraw(); });
|
|
474
|
+
group("histToggle", (b) => { histPeriod = b.dataset.p; renderHistory(lastHistory); });
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Floating crosshair tooltip: hovered date + that point's value (tokens or $).
|
|
479
|
+
function setupTooltip(container) {
|
|
480
|
+
const tip = document.createElement("div");
|
|
481
|
+
tip.className = "tip";
|
|
482
|
+
tip.style.display = "none";
|
|
483
|
+
container.appendChild(tip);
|
|
484
|
+
chart.subscribeCrosshairMove((param) => {
|
|
485
|
+
if (!param.time || !param.point || param.point.x < 0 || param.point.y < 0) { tip.style.display = "none"; return; }
|
|
486
|
+
const d = param.seriesData.get(series);
|
|
487
|
+
if (!d) { tip.style.display = "none"; return; }
|
|
488
|
+
const suffix = (metric === "usd" ? " saved" : " tokens saved") + (mode === "cumulative" ? " (total)" : "");
|
|
489
|
+
tip.innerHTML = '<div class="tip-d">' + param.time + '</div><div class="tip-v">' + metricFmt(d.value) + suffix + "</div>";
|
|
490
|
+
tip.style.display = "block";
|
|
491
|
+
const left = Math.max(0, Math.min(param.point.x + 14, container.clientWidth - 160));
|
|
492
|
+
tip.style.left = left + "px";
|
|
493
|
+
tip.style.top = Math.max(0, param.point.y - 10) + "px";
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Push the active metric/mode data into the series and reformat the axis.
|
|
498
|
+
function redraw() {
|
|
499
|
+
if (!series) return;
|
|
500
|
+
series.applyOptions({ priceFormat: { type: "custom", minMove: metric === "usd" ? 0.0001 : 1, formatter: metricFmt } });
|
|
501
|
+
const has = dailyData.length > 0;
|
|
502
|
+
$("chartEmpty").style.display = has ? "none" : "block";
|
|
503
|
+
$("chartWrap").style.display = has ? "block" : "none";
|
|
504
|
+
if (has) { series.setData(seriesValues()); applyRange(); }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Constrain the visible window to the selected range (All = fit everything).
|
|
508
|
+
function applyRange() {
|
|
509
|
+
if (!chart || !dailyData.length) return;
|
|
510
|
+
const ts = chart.timeScale();
|
|
511
|
+
if (currentRange === 0) { ts.fitContent(); $("rangeLabel").textContent = "all time"; return; }
|
|
512
|
+
const to = dailyData[dailyData.length - 1].time;
|
|
513
|
+
const from = dayKey(Date.now() - currentRange * DAY);
|
|
514
|
+
try { ts.setVisibleRange({ from, to }); }
|
|
515
|
+
catch { ts.fitContent(); }
|
|
516
|
+
$("rangeLabel").textContent = "last " + currentRange + " days";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Total estimated $ saved: sum each source's saved tokens at its price (legacy
|
|
520
|
+
// data with no attribution falls back to the default price).
|
|
521
|
+
function totalMoney(s) {
|
|
522
|
+
const bs = s.bySource || {};
|
|
523
|
+
const keys = Object.keys(bs);
|
|
524
|
+
if (keys.length) return keys.reduce((sum, k) => sum + moneyOf(k, bs[k].tokensSaved || 0), 0);
|
|
525
|
+
return moneyOf("unknown", s.tokensSaved || 0);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function render(data) {
|
|
529
|
+
priceOverride = Number(data.priceOverride) || 0;
|
|
530
|
+
speedOverride = Number(data.speedOverride) || 0;
|
|
531
|
+
const s = data.stats || {};
|
|
532
|
+
const before = s.tokensBefore || 0, after = s.tokensAfter || 0, saved = s.tokensSaved || 0, calls = s.calls || 0;
|
|
533
|
+
const rate = before ? Math.round((saved / before) * 100) : 0;
|
|
534
|
+
$("money").textContent = fmtUsd(totalMoney(s));
|
|
535
|
+
$("moneyFoot").textContent = priceOverride > 0 ? "~$" + priceOverride + "/1M tokens" : "Estimated, by source";
|
|
536
|
+
$("time").textContent = fmtDuration(saved / tps());
|
|
537
|
+
$("timeFoot").textContent = "Est. ~" + fmt(tps()) + " tok/s prefill";
|
|
538
|
+
$("saved").textContent = fmt(saved);
|
|
539
|
+
$("savedFoot").textContent = saved ? "~" + saved.toLocaleString() + " tokens" : "Across all compressions";
|
|
540
|
+
$("rate").textContent = rate + "%";
|
|
541
|
+
$("calls").textContent = fmt(calls);
|
|
542
|
+
$("avg").textContent = calls ? fmt(Math.round(saved / calls)) : "0";
|
|
543
|
+
$("best").textContent = fmt(s.best || 0);
|
|
544
|
+
$("numBefore").textContent = fmt(before);
|
|
545
|
+
$("numAfter").textContent = fmt(after);
|
|
546
|
+
const max = Math.max(1, before);
|
|
547
|
+
$("barBefore").style.width = (before / max * 100) + "%";
|
|
548
|
+
$("barAfter").style.width = (after / max * 100) + "%";
|
|
549
|
+
$("removed").textContent = fmt(saved) + " (" + rate + "%)";
|
|
550
|
+
$("reduction").textContent = before ? (100 - rate) + "% of input sent" : "-";
|
|
551
|
+
$("ratio").textContent = after ? "~" + (before / after).toFixed(1) + ":1" : "-";
|
|
552
|
+
renderBreakdown($("sources"), s.bySource, prettySource, true);
|
|
553
|
+
renderBreakdown($("types"), s.byType, prettyType, false);
|
|
554
|
+
|
|
555
|
+
renderUsage(data.usage);
|
|
556
|
+
|
|
557
|
+
lastHistory = data.history || [];
|
|
558
|
+
renderRecent(lastHistory);
|
|
559
|
+
renderHistory(lastHistory);
|
|
560
|
+
renderCache(data.cache);
|
|
561
|
+
|
|
562
|
+
dailyData = dailySeries(data.history);
|
|
563
|
+
redraw();
|
|
564
|
+
|
|
565
|
+
$("ver").textContent = data.version ? "v" + data.version : "";
|
|
566
|
+
$("dot").classList.remove("stale");
|
|
567
|
+
$("updated").textContent = "Updated " + new Date(data.generatedAt || Date.now()).toLocaleTimeString();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function poll() {
|
|
571
|
+
// Headroom pattern: never hit the server while the tab is hidden.
|
|
572
|
+
if (document.hidden) return;
|
|
573
|
+
try {
|
|
574
|
+
const res = await fetch("/api/stats", { cache: "no-store" });
|
|
575
|
+
render(await res.json());
|
|
576
|
+
} catch {
|
|
577
|
+
$("dot").classList.add("stale");
|
|
578
|
+
$("updated").textContent = "Disconnected";
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// --- settings panel (mirrors the TUI registry over /api/settings) ---
|
|
583
|
+
function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
|
|
584
|
+
|
|
585
|
+
function settingRow(s) {
|
|
586
|
+
const ctl = s.choices
|
|
587
|
+
? '<select class="choice" data-skey="' + esc(s.key) + '">'
|
|
588
|
+
+ s.choices.map((c) => '<option value="' + esc(c) + '"' + (c === s.choice ? " selected" : "") + ">" + esc(c) + "</option>").join("")
|
|
589
|
+
+ "</select>"
|
|
590
|
+
: '<button type="button" class="toggle' + (s.value ? " on" : "") + '" data-skey="' + esc(s.key)
|
|
591
|
+
+ '" data-kind="bool" data-on="' + (s.value ? 1 : 0) + '">' + (s.value ? "On" : "Off") + "</button>";
|
|
592
|
+
return '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(s.label) + "</div>"
|
|
593
|
+
+ '<div class="set-hint">' + esc(s.hint) + (s.globalOnly ? " · global" : "") + "</div></div>"
|
|
594
|
+
+ '<div class="set-ctl">' + ctl + "</div></div>";
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function renderSettings(cats) {
|
|
598
|
+
const el = $("settingsBody");
|
|
599
|
+
if (!cats.length) { el.innerHTML = '<div class="empty" style="padding:24px 0">No settings available.</div>'; return; }
|
|
600
|
+
el.innerHTML = cats.map((c) => '<div class="set-cat"><div class="set-cat-h">' + esc(c.title) + "</div>"
|
|
601
|
+
+ (c.blurb ? '<div class="set-cat-b">' + esc(c.blurb) + "</div>" : "")
|
|
602
|
+
+ c.settings.map(settingRow).join("") + "</div>").join("");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function postSetting(key, value, ctl) {
|
|
606
|
+
$("settingsNote").textContent = "Saving " + key + "...";
|
|
607
|
+
try {
|
|
608
|
+
const res = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) });
|
|
609
|
+
const out = await res.json();
|
|
610
|
+
if (!out.ok) { $("settingsNote").textContent = "Could not change " + key + ": " + (out.error || "error"); return; }
|
|
611
|
+
const s = out.setting;
|
|
612
|
+
if (s && ctl.dataset.kind === "bool") { ctl.classList.toggle("on", s.value); ctl.textContent = s.value ? "On" : "Off"; ctl.dataset.on = s.value ? 1 : 0; }
|
|
613
|
+
else if (s) { ctl.value = s.choice; }
|
|
614
|
+
$("settingsNote").textContent = out.restartNote || ("Saved " + key + ".");
|
|
615
|
+
} catch { $("settingsNote").textContent = "Could not reach the server to change " + key + "."; }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function wireSettings() {
|
|
619
|
+
const el = $("settingsBody");
|
|
620
|
+
el.addEventListener("click", (e) => {
|
|
621
|
+
const b = e.target.closest("button.toggle");
|
|
622
|
+
if (b) postSetting(b.dataset.skey, b.dataset.on !== "1", b);
|
|
623
|
+
});
|
|
624
|
+
el.addEventListener("change", (e) => {
|
|
625
|
+
const sel = e.target.closest("select.choice");
|
|
626
|
+
if (sel) postSetting(sel.dataset.skey, sel.value, sel);
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function loadSettings() {
|
|
631
|
+
try {
|
|
632
|
+
const res = await fetch("/api/settings", { cache: "no-store" });
|
|
633
|
+
renderSettings((await res.json()).categories || []);
|
|
634
|
+
} catch { $("settingsBody").innerHTML = '<div class="empty" style="padding:24px 0">Settings unavailable.</div>'; }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
initChart();
|
|
638
|
+
poll();
|
|
639
|
+
setInterval(poll, POLL_MS);
|
|
640
|
+
document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); });
|
|
641
|
+
wireSettings();
|
|
642
|
+
loadSettings();
|
|
643
|
+
</script>
|
|
644
|
+
</body>
|
|
645
|
+
</html>
|