icycache 0.3.2__py3-none-any.whl

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,2385 @@
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.0">
6
+ <title>IcyCache Console</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23050a16'/%3E%3Ccircle cx='32' cy='32' r='23' fill='none' stroke='%2339ffa8' stroke-opacity='.5' stroke-width='2' stroke-dasharray='4 6'/%3E%3Cpath d='M32 5 L56 18.5 L56 45.5 L32 59 L8 45.5 L8 18.5 Z' fill='none' stroke='%2322e6ff' stroke-width='3.5'/%3E%3Cpath d='M32 12 L48.5 21.5 L48.5 42.5 L32 52 L15.5 42.5 L15.5 21.5 Z' fill='none' stroke='%23c56bff' stroke-width='1' stroke-opacity='.6'/%3E%3Cpath d='M35.5 12 L20 35.5 L29.5 35.5 L27 52 L44 27 L33.5 27 Z' fill='%23ffe055'/%3E%3C/svg%3E">
8
+ <style>
9
+ :root{
10
+ --bg:#04030d;
11
+ --bg2:#0a1020;
12
+ --panel:rgba(14,22,42,.72);
13
+ --panel-soft:rgba(18,28,50,.5);
14
+ --brd:rgba(90,190,255,.22);
15
+ --brd-hot:rgba(255,90,60,.45);
16
+ /* --- palette d'accents : chaque role a SA couleur, pas trois nuances de
17
+ cyan. Le vert = economie/cache, le rouge = cout paye, le bleu =
18
+ bypass, le jaune = tokens economises. --- */
19
+ --ice:#00e5ff; /* cyan glacé */
20
+ --green:#3dffa0; /* vert */
21
+ --red:#ff4d6d; /* rouge */
22
+ --gold:#ffd24a; /* jaune */
23
+ --blue:#4d9dff; /* bleu */
24
+ --violet:#a98bff; /* violet */
25
+ --pink:#ff5fd2; /* magenta */
26
+ --amber:#ffab3d; /* ambre */
27
+ /* --- roles semantiques --- */
28
+ --lime:var(--green);
29
+ --cyan:var(--ice);
30
+ --fire:var(--red);
31
+ --ember:var(--gold);
32
+ --ash:#5a6a85;
33
+ --ink:#f2fbff;
34
+ --muted:#9fbcd0;
35
+ --dim:#5d7a92;
36
+ --mono:'Cascadia Mono','Consolas','SF Mono',ui-monospace,monospace;
37
+ --sans:'Segoe UI Variable Display','Segoe UI',system-ui,-apple-system,sans-serif;
38
+ }
39
+ *{box-sizing:border-box;margin:0;padding:0}
40
+ html,body{height:100%}
41
+ body{
42
+ font-family:var(--sans);color:var(--ink);background:var(--bg);overflow:hidden;
43
+ -webkit-font-smoothing:antialiased;
44
+ }
45
+ body::before{
46
+ content:'';position:fixed;inset:0;z-index:0;pointer-events:none;
47
+ background:
48
+ radial-gradient(1200px 800px at 20% 0%,rgba(0,255,255,.28),transparent 55%),
49
+ radial-gradient(700px 500px at 85% 10%,rgba(255,100,0,.18),transparent 50%),
50
+ radial-gradient(900px 700px at 50% 100%,rgba(0,200,255,.12),transparent 60%),
51
+ radial-gradient(600px 400px at 75% 80%,rgba(255,140,0,.10),transparent 50%),
52
+ linear-gradient(180deg,#010810,#020610 50%,#040a14);
53
+ }
54
+ #bgfx{position:fixed;inset:0;z-index:0;opacity:.5;pointer-events:none;mix-blend-mode:screen}
55
+
56
+ /* ============ shared glow kit ============ */
57
+ .glow-cyan{box-shadow:0 0 0 1px rgba(0,255,255,.3),0 0 26px rgba(0,255,255,.22),0 0 70px rgba(0,255,255,.1),inset 0 1px 0 rgba(238,250,255,.09)}
58
+ .glow-lime{box-shadow:0 0 0 1px rgba(0,255,255,.3),0 0 26px rgba(0,255,255,.22),0 0 70px rgba(0,255,255,.1),inset 0 1px 0 rgba(238,250,255,.09)}
59
+ @keyframes breathe{0%,100%{opacity:.72}50%{opacity:1}}
60
+ @keyframes ping{0%{box-shadow:0 0 0 0 rgba(0,255,255,.5)}70%{box-shadow:0 0 0 12px rgba(0,255,255,0)}100%{box-shadow:0 0 0 0 rgba(0,255,255,0)}}
61
+ @keyframes sweep{0%{transform:translateX(-130%)}100%{transform:translateX(340%)}}
62
+ @keyframes floaty{0%,100%{transform:translateY(0)}50%{transform:translateY(-5px)}}
63
+ @keyframes shimmer{0%{background-position:-220% 0}100%{background-position:220% 0}}
64
+ @keyframes aurora{
65
+ 0%{background-position:0% 50%}
66
+ 50%{background-position:100% 50%}
67
+ 100%{background-position:0% 50%}
68
+ }
69
+ @keyframes neonEdge{
70
+ 0%,100%{border-color:rgba(0,255,255,.22)}
71
+ 50%{border-color:rgba(0,255,255,.5)}
72
+ }
73
+
74
+ /* ============ app frame ============ */
75
+ .app{
76
+ position:relative;z-index:1;display:grid;
77
+ grid-template-columns:296px 1fr;grid-template-rows:60px 1fr;height:100vh;
78
+ }
79
+
80
+ /* ============ sidebar ============ */
81
+ .side{
82
+ grid-row:1/3;display:flex;flex-direction:column;gap:14px;padding:18px 14px 14px;
83
+ background:linear-gradient(180deg,rgba(10,16,32,.92),rgba(5,8,18,.96));
84
+ border-right:1px solid var(--brd);backdrop-filter:blur(14px);
85
+ box-shadow:26px 0 60px -30px rgba(0,255,255,.3);overflow:hidden;
86
+ }
87
+ .brand{display:flex;align-items:center;gap:14px;padding:6px 6px 4px}
88
+ .logo-wrap{position:relative;width:66px;height:66px;flex-shrink:0;animation:floaty 5s ease-in-out infinite}
89
+ .logo-wrap svg{width:100%;height:100%;overflow:visible;filter:drop-shadow(0 0 7px rgba(0,255,255,.75)) drop-shadow(0 0 18px rgba(0,255,255,.45)) drop-shadow(0 0 34px rgba(80,180,255,.3))}
90
+ .logo-wrap::before{
91
+ content:'';position:absolute;inset:-14px;border-radius:50%;
92
+ background:conic-gradient(from 0deg,rgba(0,255,255,.34),rgba(0,255,255,.34),rgba(80,180,255,.3),rgba(255,100,0,.28),rgba(255,180,0,.3),rgba(0,255,255,.34));
93
+ filter:blur(11px);opacity:.85;animation:aurora 7s ease-in-out infinite;pointer-events:none;
94
+ }
95
+ .logo-wrap::after{
96
+ content:'';position:absolute;inset:-8px;border-radius:50%;
97
+ background:radial-gradient(circle,rgba(0,255,255,.34),transparent 68%);
98
+ animation:breathe 3.4s ease-in-out infinite;pointer-events:none;
99
+ }
100
+ .brand-txt{display:flex;flex-direction:column;line-height:1.1;min-width:0}
101
+ .brand-txt b{
102
+ font-size:24px;font-weight:800;letter-spacing:.2px;
103
+ background:linear-gradient(92deg,var(--ice),var(--cyan) 38%,var(--violet) 68%,var(--fire));
104
+ -webkit-background-clip:text;background-clip:text;color:transparent;
105
+ filter:drop-shadow(0 0 14px rgba(0,255,255,.5));
106
+ }
107
+ .brand-txt span{font-size:9.5px;letter-spacing:2.4px;color:var(--dim);text-transform:uppercase;margin-top:4px}
108
+
109
+ .sep{display:flex;align-items:center;gap:10px;padding:0 4px}
110
+ .sep i{flex:1;height:1px;background:linear-gradient(90deg,var(--brd-hot),transparent)}
111
+ .sep span{font-size:9px;letter-spacing:2px;color:var(--dim);text-transform:uppercase;font-weight:800}
112
+
113
+ /* nav */
114
+ .nav{display:flex;flex-direction:column;gap:5px}
115
+ .nav-btn{
116
+ position:relative;display:flex;align-items:center;gap:11px;width:100%;text-align:left;
117
+ padding:11px 13px;border-radius:12px;font-size:13.5px;font-weight:700;color:var(--muted);
118
+ background:transparent;border:1px solid transparent;transition:.2s cubic-bezier(.2,.8,.2,1);
119
+ font-family:inherit;cursor:pointer;overflow:hidden;
120
+ }
121
+ .nav-btn::before{
122
+ content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);
123
+ width:3px;height:0;border-radius:3px;background:var(--cyan);
124
+ box-shadow:0 0 12px var(--cyan);transition:height .25s cubic-bezier(.2,.8,.2,1);
125
+ }
126
+ .nav-btn:hover{color:var(--ink);background:rgba(0,255,255,.06);border-color:var(--brd)}
127
+ .nav-btn .ico{font-size:15px;width:19px;text-align:center;filter:grayscale(.4) brightness(1.4)}
128
+ .nav-btn:hover .ico{filter:none}
129
+ .nav-btn .lbl{flex:1;letter-spacing:.3px}
130
+ .nav-btn .cnt{
131
+ font-size:10.5px;font-weight:800;padding:2px 8px;border-radius:9px;
132
+ background:rgba(0,255,255,.13);color:var(--cyan);font-variant-numeric:tabular-nums;
133
+ box-shadow:inset 0 0 0 1px rgba(0,255,255,.2);
134
+ }
135
+ .nav-btn.on{
136
+ color:#02121c;background:linear-gradient(94deg,var(--cyan),#7ee6ff);
137
+ border-color:transparent;box-shadow:0 0 22px rgba(0,255,255,.48),0 6px 22px -10px rgba(0,255,255,.7);
138
+ }
139
+ .nav-btn.on::before{height:60%;background:#02121c;box-shadow:none}
140
+ .nav-btn.on .ico{filter:none}
141
+ .nav-btn.on .cnt{background:rgba(0,0,0,.28);color:#02121c;box-shadow:none}
142
+
143
+ /* telemetry rail */
144
+ .rail{display:flex;flex-direction:column;gap:7px}
145
+ .rail-row{
146
+ display:grid;grid-template-columns:1fr auto;grid-template-rows:auto auto;
147
+ align-items:center;gap:2px 8px;padding:8px 11px;border-radius:11px;
148
+ background:var(--panel-soft);border:1px solid rgba(238,250,255,.045);
149
+ transition:.2s;cursor:default;
150
+ }
151
+ .rail-row:hover{border-color:var(--brd);background:rgba(0,255,255,.05);transform:translateX(2px)}
152
+ .rail-row .rl{font-size:10px;letter-spacing:1.15px;text-transform:uppercase;color:var(--dim);font-weight:700}
153
+ .rail-row .rv{font-size:14.5px;font-weight:800;font-variant-numeric:tabular-nums;text-align:right;line-height:1}
154
+ .rail-row .rs{grid-column:1/3;font-size:10.5px;color:var(--dim);display:flex;align-items:center;gap:6px}
155
+ .rail-row .spark{grid-column:1/3;height:20px;width:100%;display:block}
156
+ .status-dot{width:7px;height:7px;border-radius:50%;background:var(--dim);box-shadow:0 0 8px currentColor;flex-shrink:0}
157
+ .status-dot.on{background:var(--lime);color:var(--lime);animation:ping 1.9s infinite}
158
+ .status-dot.off{background:var(--pink);color:var(--pink)}
159
+
160
+ .spacer{flex:1;min-height:6px}
161
+ .side-actions{display:flex;flex-direction:column;gap:7px}
162
+ .act-btn{
163
+ display:flex;align-items:center;gap:9px;width:100%;padding:10px 13px;border-radius:11px;
164
+ font-size:12.5px;font-weight:700;letter-spacing:.35px;font-family:inherit;cursor:pointer;
165
+ background:rgba(238,250,255,.045);color:var(--ink);border:1px solid var(--brd);transition:.18s;
166
+ }
167
+ .act-btn:hover{transform:translateY(-1px);border-color:var(--brd-hot);background:rgba(0,255,255,.09);box-shadow:0 0 16px rgba(0,255,255,.26)}
168
+ .act-btn.danger{background:rgba(255,100,0,.11);border-color:rgba(255,100,0,.36);color:#ff8c40}
169
+ .act-btn.danger:hover{box-shadow:0 0 18px rgba(255,100,0,.42);border-color:var(--pink);background:rgba(255,100,0,.18)}
170
+ .act-btn.help{justify-content:center;background:rgba(80,180,255,.1);border-color:rgba(80,180,255,.32);color:#a0c8ff}
171
+ .act-btn.help:hover{box-shadow:0 0 18px rgba(80,180,255,.4);border-color:var(--violet);background:rgba(80,180,255,.18)}
172
+ .side-foot{font-size:9.5px;color:var(--dim);text-align:center;letter-spacing:1.9px;text-transform:uppercase;padding-top:2px}
173
+
174
+ /* ============ topbar ============ */
175
+ .top{
176
+ display:flex;align-items:center;gap:16px;padding:0 22px;
177
+ border-bottom:1px solid var(--brd);background:rgba(8,14,28,.72);backdrop-filter:blur(14px);
178
+ box-shadow:0 18px 40px -32px rgba(0,255,255,.6);
179
+ }
180
+ .top h1{font-size:17px;font-weight:800;letter-spacing:2.2px;text-transform:uppercase;display:flex;align-items:center;gap:9px;white-space:nowrap}
181
+ .top h1 em{
182
+ font-style:normal;color:var(--cyan);
183
+ text-shadow:0 0 14px rgba(0,255,255,.95),0 0 34px rgba(0,255,255,.55);
184
+ animation:breathe 3s ease-in-out infinite;
185
+ }
186
+ .pills{display:flex;gap:8px;flex-wrap:nowrap;overflow:hidden}
187
+ .pill{
188
+ display:inline-flex;align-items:center;gap:7px;padding:6px 13px;border-radius:19px;
189
+ font-size:11px;font-weight:800;letter-spacing:.9px;white-space:nowrap;
190
+ border:1px solid var(--brd);background:rgba(238,250,255,.04);color:var(--muted);
191
+ font-variant-numeric:tabular-nums;
192
+ }
193
+ .pill i{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 9px currentColor;font-style:normal}
194
+ .pill.ok{color:var(--lime);background:rgba(0,255,255,.08);border-color:rgba(0,255,255,.34);box-shadow:0 0 16px rgba(0,255,255,.14)}
195
+ .pill.ok i{animation:ping 2s infinite}
196
+ .pill.warn{color:var(--amber);background:rgba(255,180,0,.08);border-color:rgba(255,180,0,.34)}
197
+ .pill.bad{color:var(--pink);background:rgba(255,100,0,.1);border-color:rgba(255,100,0,.38);box-shadow:0 0 16px rgba(255,100,0,.18)}
198
+ .pill b{color:var(--ink);font-weight:800}
199
+
200
+ .ticker{
201
+ flex:1;min-width:0;height:34px;display:flex;align-items:center;gap:10px;padding:0 12px;
202
+ border-radius:17px;border:1px solid var(--brd);background:rgba(0,255,255,.06);overflow:hidden;
203
+ box-shadow:inset 0 0 22px rgba(0,255,255,.12),0 0 18px -10px rgba(0,255,255,.5);
204
+ }
205
+ .ticker .tk-l{
206
+ font-size:9px;letter-spacing:1.9px;font-weight:800;color:var(--pink);text-transform:uppercase;
207
+ text-shadow:0 0 12px rgba(255,100,0,.95),0 0 26px rgba(255,100,0,.6);flex-shrink:0;animation:breathe 1.7s ease-in-out infinite;
208
+ }
209
+ .save-led{
210
+ display:inline-flex;align-items:center;gap:6px;flex-shrink:0;
211
+ font-size:8.5px;letter-spacing:1.7px;font-weight:800;text-transform:uppercase;
212
+ padding:3px 9px;border-radius:20px;color:var(--dim);
213
+ border:1px solid rgba(238,250,255,.14);background:rgba(238,250,255,.04);
214
+ transition:color .2s,background .2s,border-color .2s,box-shadow .2s;
215
+ }
216
+ .save-led i{width:7px;height:7px;border-radius:50%;background:var(--dim);box-shadow:none;font-style:normal;transition:.2s}
217
+ .save-led.hit{
218
+ color:#02121c;border-color:transparent;background:linear-gradient(94deg,var(--lime),#a9ffd6);
219
+ box-shadow:0 0 24px rgba(0,255,255,.75),0 0 52px rgba(0,255,255,.35);
220
+ }
221
+ .save-led.hit i{background:#02121c;box-shadow:none;animation:none}
222
+ .tk-track{display:flex;align-items:center;gap:9px;overflow:hidden;flex:1;min-width:0}
223
+ .tk-item{
224
+ display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:13px;flex-shrink:0;
225
+ font-size:11px;font-weight:800;letter-spacing:.5px;font-variant-numeric:tabular-nums;
226
+ border:1px solid rgba(0,255,255,.55);color:var(--lime);background:rgba(0,255,255,.12);
227
+ box-shadow:0 0 20px rgba(0,255,255,.42),inset 0 0 12px rgba(0,255,255,.18);white-space:nowrap;
228
+ text-shadow:0 0 10px rgba(0,255,255,.7);
229
+ animation:tkIn .55s cubic-bezier(.2,.9,.25,1);
230
+ }
231
+ .tk-item.miss{border-color:rgba(0,255,255,.5);color:var(--cyan);background:rgba(0,255,255,.1);box-shadow:0 0 20px rgba(0,255,255,.4),inset 0 0 12px rgba(0,255,255,.16);text-shadow:0 0 10px rgba(0,255,255,.7)}
232
+ .tk-item.warn{border-color:rgba(255,180,0,.5);color:var(--amber);background:rgba(255,180,0,.1);box-shadow:0 0 20px rgba(255,180,0,.4),inset 0 0 12px rgba(255,180,0,.16);text-shadow:0 0 10px rgba(255,180,0,.7)}
233
+ @keyframes tkIn{from{opacity:0;transform:translateY(14px) scale(.82)}to{opacity:1;transform:none}}
234
+ .ticker .tk-empty{font-size:11px;color:var(--dim);letter-spacing:.6px}
235
+
236
+ .refr{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--muted);white-space:nowrap}
237
+ .refr select{
238
+ background:rgba(0,255,255,.09);color:var(--cyan);border:1px solid var(--brd);border-radius:8px;
239
+ padding:4px 8px;font-family:inherit;font-size:11.5px;font-weight:700;cursor:pointer;outline:none;
240
+ }
241
+ .refr select option{background:#0c1424;color:var(--ink)}
242
+ .refr button{
243
+ width:28px;height:28px;border-radius:8px;background:rgba(0,255,255,.09);border:1px solid var(--brd);
244
+ color:var(--cyan);font-size:13px;cursor:pointer;transition:.18s;font-family:inherit;
245
+ }
246
+ .refr button:hover{box-shadow:0 0 14px rgba(0,255,255,.45);transform:rotate(180deg);transition:transform .5s cubic-bezier(.2,.8,.2,1)}
247
+ #clock{
248
+ font-family:var(--mono);font-size:13.5px;color:var(--cyan);letter-spacing:1.4px;white-space:nowrap;
249
+ text-shadow:0 0 12px rgba(0,255,255,.65);
250
+ }
251
+
252
+ /* ============ main ============ */
253
+ .main{overflow-y:auto;overflow-x:hidden;padding:20px 22px 34px;scrollbar-width:thin;scrollbar-color:rgba(0,255,255,.35) transparent}
254
+ .main::-webkit-scrollbar{width:9px}
255
+ .main::-webkit-scrollbar-thumb{background:rgba(0,255,255,.28);border-radius:9px}
256
+ .main::-webkit-scrollbar-track{background:transparent}
257
+ .view{display:none;animation:fadein .34s cubic-bezier(.2,.8,.2,1)}
258
+ .view.on{display:block}
259
+ @keyframes fadein{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:none}}
260
+
261
+ .grid{display:grid;gap:15px;margin-bottom:15px}
262
+ /* KPI tiles — the node tile is the signature, it gets the biggest footprint */
263
+ .grid.tiles{grid-template-columns:repeat(6,minmax(0,1fr));grid-auto-rows:minmax(108px,auto);gap:15px;margin-bottom:15px}
264
+ .tiles .t-node{grid-column:span 2;grid-row:span 2;display:flex;flex-direction:column;justify-content:center;padding:18px 20px}
265
+ .tiles .t-node .v{font-size:clamp(40px,4.4vw,62px);letter-spacing:-1.5px}
266
+ .tiles .t-node .sub{margin-top:8px}
267
+ .tiles .t-saved{grid-column:span 2}
268
+ .tiles .t-pct{grid-column:span 2}
269
+ .tiles .t-tks{grid-column:span 4}
270
+ .tiles .t-tks .tks-row{display:flex;align-items:center;gap:24px}
271
+ .tiles .t-tks .tks-meta{flex:1;min-width:0}
272
+ .tiles .t-tks .tks-meta .sub{margin-top:0}
273
+ .tiles .t-tks .tks-meta .meter{margin-top:9px}
274
+ /* --- accent par tuile : chaque KPI porte sa couleur, la page n'est plus
275
+ uniformément cyan. Les variables --accent* sont consommees par
276
+ .glass::before, le titre et la regle lumineuse. --- */
277
+ .tiles .glass{border-color:var(--accent-br,var(--brd))}
278
+ .tiles .glass:hover{border-color:var(--accent-br,var(--brd-hot));box-shadow:0 12px 40px rgba(0,0,0,.6),0 0 40px -10px var(--accent-gl,rgba(0,229,255,.55)),inset 0 1px 0 rgba(238,250,255,.12)}
279
+ .tiles .glass h3{color:var(--accent,var(--muted))}
280
+ .tiles .glass h3 .ln{background:linear-gradient(90deg,var(--accent,rgba(238,250,255,.14)),transparent)}
281
+ .t-node{--accent:var(--ice);--accent-gl:rgba(0,229,255,.17);--accent-br:rgba(0,229,255,.32)}
282
+ .t-saved{--accent:var(--green);--accent-gl:rgba(61,255,160,.16);--accent-br:rgba(61,255,160,.30)}
283
+ .t-pct{--accent:var(--gold);--accent-gl:rgba(255,210,74,.14);--accent-br:rgba(255,210,74,.28)}
284
+ .t-tks{--accent:var(--blue);--accent-gl:rgba(77,157,255,.14);--accent-br:rgba(77,157,255,.28)}
285
+ .tile-dots{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px}
286
+ .tile-dots span{
287
+ display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:9.5px;color:var(--muted);
288
+ padding:3px 8px;border-radius:20px;border:1px solid rgba(238,250,255,.08);background:rgba(238,250,255,.03);
289
+ }
290
+ .tile-dots span i{width:7px;height:7px;border-radius:50%;box-shadow:0 0 9px currentColor;flex-shrink:0}
291
+ .tile-dots span b{color:var(--ink);font-weight:800}
292
+ @media (max-width:1180px){
293
+ .grid.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}
294
+ .tiles .t-node{grid-column:span 2;grid-row:auto}
295
+ .tiles .t-saved,.tiles .t-pct,.tiles .t-tks{grid-column:span 1}
296
+ }
297
+ .g4{grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}
298
+ .g3{grid-template-columns:repeat(auto-fit,minmax(260px,1fr))}
299
+ .split{display:grid;gap:15px;grid-template-columns:minmax(0,1.26fr) minmax(0,.74fr);align-items:start}
300
+ .split-r{display:grid;gap:15px;grid-template-columns:minmax(0,1fr) 330px;align-items:start}
301
+
302
+ /* glass */
303
+ .glass{
304
+ position:relative;border-radius:15px;padding:15px 17px;overflow:hidden;
305
+ background:linear-gradient(158deg,rgba(18,30,54,.82),rgba(7,11,24,.78));
306
+ border:1px solid var(--brd);backdrop-filter:blur(13px);
307
+ box-shadow:0 10px 34px rgba(0,0,0,.55),0 0 22px -12px rgba(0,255,255,.35),inset 0 1px 0 rgba(238,250,255,.08);
308
+ transition:border-color .25s,box-shadow .25s;
309
+ }
310
+ .glass:hover{
311
+ border-color:var(--brd-hot);
312
+ box-shadow:0 12px 40px rgba(0,0,0,.6),0 0 40px -10px rgba(0,255,255,.62),inset 0 1px 0 rgba(238,250,255,.12);
313
+ }
314
+ .glass::before{
315
+ content:'';position:absolute;inset:0;border-radius:15px;pointer-events:none;
316
+ background:
317
+ linear-gradient(118deg,rgba(238,250,255,.1),transparent 32%),
318
+ radial-gradient(420px 180px at 88% -20%,rgba(80,180,255,.16),transparent 70%),
319
+ radial-gradient(130% 105% at 4% -14%,var(--accent-gl,transparent),transparent 64%);
320
+ }
321
+ .glass.flash::after{
322
+ content:'';position:absolute;inset:0;pointer-events:none;border-radius:15px;
323
+ background:linear-gradient(100deg,transparent 18%,rgba(238,250,255,.2) 50%,transparent 82%);
324
+ animation:sweep .85s ease-out;
325
+ }
326
+ .glass h3{
327
+ display:flex;align-items:center;gap:10px;font-size:10.5px;text-transform:uppercase;
328
+ letter-spacing:1.9px;color:var(--muted);margin-bottom:12px;font-weight:800;
329
+ }
330
+ .glass h3 .ln{flex:1;height:1px;background:linear-gradient(90deg,var(--brd-hot),transparent)}
331
+ .glass h3 .badge{
332
+ font-size:9px;letter-spacing:1.3px;padding:3px 9px;border-radius:20px;font-weight:800;
333
+ border:1px solid rgba(0,255,255,.4);color:var(--cyan);background:rgba(0,255,255,.1);
334
+ box-shadow:0 0 14px rgba(0,255,255,.28),inset 0 0 10px rgba(0,255,255,.14);
335
+ text-shadow:0 0 9px rgba(0,255,255,.6);white-space:nowrap;
336
+ }
337
+ .v{font-size:clamp(25px,2.3vw,33px);font-weight:800;font-variant-numeric:tabular-nums;line-height:1.02;letter-spacing:-.5px}
338
+ .sub{color:var(--muted);font-size:11.5px;margin-top:6px;line-height:1.45}
339
+ .c-cyan{color:var(--ice);text-shadow:0 0 10px rgba(0,229,255,.95),0 0 26px rgba(0,229,255,.55),0 0 60px rgba(0,229,255,.3)}
340
+ .c-pink{color:var(--pink);text-shadow:0 0 10px rgba(255,95,210,.95),0 0 26px rgba(255,95,210,.55),0 0 60px rgba(255,95,210,.3)}
341
+ .c-lime{color:var(--green);text-shadow:0 0 10px rgba(61,255,160,.95),0 0 26px rgba(61,255,160,.55),0 0 60px rgba(61,255,160,.3)}
342
+ .c-green{color:var(--green);text-shadow:0 0 10px rgba(61,255,160,.95),0 0 26px rgba(61,255,160,.55),0 0 60px rgba(61,255,160,.3)}
343
+ .c-red{color:var(--red);text-shadow:0 0 10px rgba(255,77,109,.95),0 0 26px rgba(255,77,109,.55),0 0 60px rgba(255,77,109,.3)}
344
+ .c-gold{color:var(--gold);text-shadow:0 0 10px rgba(255,210,74,.95),0 0 26px rgba(255,210,74,.55),0 0 60px rgba(255,210,74,.3)}
345
+ .c-blue{color:var(--blue);text-shadow:0 0 10px rgba(77,157,255,.95),0 0 26px rgba(77,157,255,.55),0 0 60px rgba(77,157,255,.3)}
346
+ .c-amber{color:var(--gold);text-shadow:0 0 10px rgba(255,171,61,.95),0 0 26px rgba(255,171,61,.55),0 0 60px rgba(255,171,61,.3)}
347
+ .c-violet{color:var(--violet);text-shadow:0 0 10px rgba(169,139,255,.95),0 0 26px rgba(169,139,255,.55),0 0 60px rgba(169,139,255,.3)}
348
+ .meter{height:5px;border-radius:3px;background:rgba(238,250,255,.08);margin-top:11px;overflow:hidden;box-shadow:inset 0 0 6px rgba(0,0,0,.6)}
349
+ .meter i{display:block;height:100%;width:0;border-radius:3px;transition:width .9s cubic-bezier(.2,.8,.2,1)}
350
+ .m-cyan i{background:linear-gradient(90deg,#005a72,var(--cyan));box-shadow:0 0 14px var(--cyan),0 0 30px rgba(0,255,255,.6)}
351
+ .m-pink i{background:linear-gradient(90deg,#6e0f22,var(--pink));box-shadow:0 0 14px var(--pink),0 0 30px rgba(255,100,0,.6)}
352
+ .m-lime i{background:linear-gradient(90deg,#006b45,var(--lime));box-shadow:0 0 14px var(--lime),0 0 30px rgba(0,255,255,.6)}
353
+ .m-amber i{background:linear-gradient(90deg,#6b5a00,var(--amber));box-shadow:0 0 14px var(--amber),0 0 30px rgba(255,180,0,.6)}
354
+ .m-violet i{background:linear-gradient(90deg,#3a1a6b,var(--violet));box-shadow:0 0 14px var(--violet),0 0 30px rgba(80,180,255,.6)}
355
+
356
+ .kpi-spark{width:100%;height:34px;display:block;margin-top:9px;opacity:.95}
357
+ .live-save{
358
+ font-family:var(--mono);font-size:10.5px;letter-spacing:.4px;color:var(--lime);margin-top:5px;
359
+ text-shadow:0 0 10px rgba(0,255,255,.6);
360
+ }
361
+ .live-save.idle{color:var(--dim);text-shadow:none}
362
+
363
+ /* ============ globe card ============ */
364
+ .globe-card{display:flex;flex-wrap:wrap;gap:20px;align-items:stretch}
365
+ .globe-card > .card-h{width:100%;margin-bottom:-4px}
366
+ .globe-legend.wide{
367
+ width:100%;gap:14px;padding-top:11px;margin-top:2px;
368
+ border-top:1px solid rgba(238,250,255,.07);font-family:var(--mono);font-size:10.5px;
369
+ }
370
+ .globe-legend.wide span{display:inline-flex;align-items:center;gap:6px;color:var(--muted)}
371
+ .globe-legend.wide i{width:8px;height:8px;border-radius:50%;display:inline-block;box-shadow:0 0 9px currentColor}
372
+ .globe-legend.wide b{color:var(--ink);font-weight:800}
373
+ .globe-stage{
374
+ position:relative;width:500px;flex-shrink:0;display:grid;place-items:center;
375
+ border-radius:14px;overflow:hidden;
376
+ background:
377
+ radial-gradient(420px 420px at 50% 52%,rgba(0,190,255,.13),transparent 70%),
378
+ radial-gradient(300px 300px at 82% 18%,rgba(255,100,0,.08),transparent 68%),
379
+ linear-gradient(180deg,#02060d,#030a12 60%,#020509);
380
+ border:1px solid rgba(0,255,255,.09);
381
+ box-shadow:inset 0 0 80px rgba(0,255,255,.05);
382
+ }
383
+ #globe{width:500px;height:500px;cursor:grab;touch-action:none;position:relative;z-index:1}
384
+ #globe:active{cursor:grabbing}
385
+ .globe-stage::before{
386
+ content:'';position:absolute;width:340px;height:340px;border-radius:50%;
387
+ background:radial-gradient(circle,rgba(0,255,255,.16),transparent 68%);
388
+ animation:breathe 4.4s ease-in-out infinite;pointer-events:none;z-index:0;
389
+ }
390
+ .globe-hud{
391
+ position:absolute;bottom:6px;left:50%;transform:translateX(-50%);
392
+ display:flex;gap:8px;pointer-events:none;
393
+ }
394
+ /* graph controls */
395
+ .gctl{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:2px}
396
+ .hud-btn{
397
+ font-family:var(--mono);font-size:9.5px;letter-spacing:.8px;padding:5px 10px;border-radius:9px;
398
+ border:1px solid var(--brd);background:rgba(8,14,28,.9);color:var(--muted);cursor:pointer;
399
+ transition:.16s cubic-bezier(.2,.8,.2,1);white-space:nowrap;font-family:var(--mono);
400
+ }
401
+ .hud-btn:hover{border-color:var(--brd-hot);color:var(--ink);box-shadow:0 0 14px rgba(0,255,255,.42);transform:translateY(-1px)}
402
+ .hud-btn.on{color:var(--cyan);border-color:rgba(0,255,255,.5);background:rgba(0,255,255,.13);box-shadow:0 0 14px rgba(0,255,255,.3)}
403
+ .hud-chip{
404
+ font-family:var(--mono);font-size:9.5px;letter-spacing:.7px;padding:3px 9px;border-radius:8px;
405
+ border:1px solid var(--brd);background:rgba(8,14,28,.85);color:var(--cyan);
406
+ box-shadow:0 0 14px rgba(0,255,255,.25);white-space:nowrap;
407
+ }
408
+ .globe-side{flex:1;display:flex;flex-direction:column;gap:11px;min-width:0}
409
+ /* node inspector, docked next to the graph so it never covers it */
410
+ .inspector{
411
+ padding:10px 12px;border-radius:13px;
412
+ background:rgba(4,9,18,.55);border:1px solid var(--brd);
413
+ box-shadow:inset 0 0 22px rgba(0,255,255,.09),0 0 22px -14px rgba(0,255,255,.6);
414
+ }
415
+ .inspector .ins-head{display:flex;align-items:center;gap:8px;font-size:11.5px;font-weight:800;color:var(--ink)}
416
+ .inspector .ins-head i{width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 10px currentColor;flex-shrink:0}
417
+ .inspector .ins-head b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
418
+ .inspector .ins-tag{
419
+ font-family:var(--mono);font-size:8.5px;letter-spacing:1.2px;text-transform:uppercase;font-weight:800;
420
+ color:var(--lime);border:1px solid rgba(0,255,255,.35);border-radius:20px;padding:2px 7px;
421
+ text-shadow:0 0 9px rgba(0,255,255,.7);flex-shrink:0;
422
+ }
423
+ .inspector .ins-sub{font-family:var(--mono);font-size:9.5px;color:var(--muted);margin-top:6px;line-height:1.5;word-break:break-word}
424
+ .inspector .ins-grid{display:flex;flex-direction:column;gap:3px;margin-top:8px;font-family:var(--mono);font-size:10px}
425
+ .inspector .ins-grid div{display:flex;justify-content:space-between;gap:12px;color:var(--dim)}
426
+ .inspector .ins-grid b{color:var(--lime);font-weight:800;text-shadow:0 0 9px rgba(0,255,255,.6)}
427
+ .kv{display:grid;grid-template-columns:1fr 1fr;gap:9px}
428
+ .kv .glass{padding:11px 13px;border-radius:12px}
429
+ .kv .k{font-size:9.5px;text-transform:uppercase;letter-spacing:1.3px;color:var(--dim);margin-bottom:4px;font-weight:800}
430
+ .kv .val{font-size:21px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
431
+ .globe-legend{display:flex;flex-wrap:wrap;gap:10px;font-size:11px;color:var(--muted)}
432
+ .globe-legend span{display:inline-flex;align-items:center;gap:6px}
433
+ .globe-legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
434
+ /* --- fiche anatomique de la cellule : l'info la plus dense du panneau --- */
435
+ .cell-stats{
436
+ display:grid;grid-template-columns:1fr 1fr;gap:6px;
437
+ padding:11px 12px;border-radius:13px;
438
+ background:rgba(4,9,18,.55);border:1px solid var(--brd);
439
+ box-shadow:inset 0 0 22px rgba(0,229,255,.09);
440
+ }
441
+ .cell-stats div{display:flex;justify-content:space-between;gap:10px;font-family:var(--mono);font-size:10px;color:var(--dim)}
442
+ .cell-stats b{font-weight:800;color:var(--ink);text-shadow:0 0 9px rgba(0,229,255,.5)}
443
+ .cell-stats b.g{color:var(--green);text-shadow:0 0 9px rgba(61,255,160,.55)}
444
+ .cell-stats b.y{color:var(--gold);text-shadow:0 0 9px rgba(255,210,74,.55)}
445
+ .cell-stats b.r{color:var(--red);text-shadow:0 0 9px rgba(255,77,109,.55)}
446
+ .cell-stats b.b{color:var(--blue);text-shadow:0 0 9px rgba(77,157,255,.55)}
447
+ .cell-stats .vitality{
448
+ grid-column:1/-1;margin-top:3px;padding-top:8px;border-top:1px solid rgba(238,250,255,.07);
449
+ font-family:var(--mono);font-size:9.5px;color:var(--dim);letter-spacing:.4px
450
+ }
451
+ .vitality-track{height:6px;border-radius:4px;background:rgba(238,250,255,.08);margin-top:6px;overflow:hidden;position:relative}
452
+ .vitality-track i{
453
+ position:absolute;inset:0 auto 0 0;border-radius:4px;display:block;
454
+ background:linear-gradient(90deg,var(--green),var(--gold) 60%,var(--pink));
455
+ box-shadow:0 0 14px rgba(61,255,160,.55);transition:width .7s cubic-bezier(.2,.8,.2,1)
456
+ }
457
+
458
+ /* ============ ring gauge ============ */
459
+ .ring-wrap{position:relative;display:flex;flex-direction:column;align-items:center;gap:11px;padding:2px 0}
460
+ .ring-canvas{
461
+ position:relative;width:100%;display:grid;place-items:center;border-radius:14px;overflow:hidden;
462
+ background:
463
+ radial-gradient(420px 260px at 50% 118%,rgba(0,255,255,.12),transparent 66%),
464
+ radial-gradient(340px 220px at 12% 0%,rgba(255,120,40,.10),transparent 62%),
465
+ linear-gradient(180deg,#02060d,#030a12 60%,#020509);
466
+ border:1px solid rgba(0,255,255,.09);
467
+ box-shadow:inset 0 0 60px rgba(0,255,255,.05);
468
+ }
469
+ #ring3d{width:100%;max-width:432px;height:300px;display:block;cursor:grab;touch-action:none;position:relative;z-index:1}
470
+ #ring3d:active{cursor:grabbing}
471
+ /* Le readout est en bas a gauche : pose au centre, il masquait exactement le
472
+ croisement du "8", c'est-a-dire la partie la plus lisible de la courbe. */
473
+ .ring-center{
474
+ position:absolute;left:16px;bottom:12px;display:block;pointer-events:none;text-align:left;z-index:2;
475
+ }
476
+ .ring-center::before{
477
+ content:'';position:absolute;left:-16px;bottom:-12px;width:170px;height:104px;
478
+ background:radial-gradient(circle at 26% 68%,rgba(2,7,16,.94) 0%,rgba(2,7,16,.6) 44%,rgba(2,7,16,0) 74%);
479
+ }
480
+ .ring-center > div{position:relative;z-index:1}
481
+ .ring-center .big{font-size:27px;font-weight:800;letter-spacing:-1px;line-height:1;font-variant-numeric:tabular-nums;text-shadow:0 0 22px rgba(0,255,255,.55)}
482
+ .ring-center .lbl{font-size:9px;letter-spacing:2.2px;color:var(--dim);text-transform:uppercase;margin-top:6px;font-weight:800}
483
+ /* loop HUD — the instrument panel of the agent loop */
484
+ .loop-hud{display:flex;flex-wrap:wrap;gap:6px;justify-content:center}
485
+ /* the five stations of the agent pipeline */
486
+ .loop-stages{display:flex;flex-wrap:wrap;gap:5px;justify-content:center}
487
+ .loop-stages span{
488
+ font-family:var(--mono);font-size:8.5px;letter-spacing:1.1px;font-weight:800;
489
+ padding:3px 8px;border-radius:20px;border:1px solid rgba(238,250,255,.08);
490
+ color:var(--dim);background:rgba(4,9,18,.55);transition:.25s cubic-bezier(.2,.8,.2,1);
491
+ }
492
+ .loop-stages span.on{
493
+ color:#1a0308;background:var(--pink);border-color:transparent;
494
+ box-shadow:0 0 16px rgba(255,100,0,.6),0 0 30px rgba(255,100,0,.3);
495
+ }
496
+ /* counter + legend share the last row */
497
+ .ring-bottom{width:100%;display:flex;gap:15px;align-items:stretch}
498
+ .ring-bottom .leg{flex:1;min-width:0;justify-content:center}
499
+ /* live token counter, right next to the gauge */
500
+ .token-counter{
501
+ flex:0 0 150px;display:flex;flex-direction:column;gap:6px;padding:11px 13px;border-radius:13px;
502
+ background:rgba(4,9,18,.62);border:1px solid var(--brd);
503
+ box-shadow:inset 0 0 22px rgba(0,255,255,.1),0 0 24px -14px rgba(0,255,255,.7);
504
+ }
505
+ .tc-k{font-size:9px;letter-spacing:1.6px;text-transform:uppercase;color:var(--dim);font-weight:800}
506
+ .tc-k i{font-style:normal;color:var(--lime);text-shadow:0 0 9px rgba(0,255,255,.9)}
507
+ .tc-v{font-size:25px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.05}
508
+ .tc-r{font-family:var(--mono);font-size:10.5px;color:var(--lime);text-shadow:0 0 9px rgba(0,255,255,.5)}
509
+ .tc-bar{height:4px;border-radius:3px;background:rgba(238,250,255,.08);overflow:hidden;box-shadow:inset 0 0 5px rgba(0,0,0,.6)}
510
+ .tc-bar i{display:block;height:100%;width:0;border-radius:3px;background:linear-gradient(90deg,#006b45,var(--lime));box-shadow:0 0 12px var(--lime);transition:width .5s cubic-bezier(.2,.8,.2,1)}
511
+ .tc-sub{font-family:var(--mono);font-size:9.5px;color:var(--dim);line-height:1.45}
512
+ .tc-sub.hit{color:var(--lime);text-shadow:0 0 9px rgba(0,255,255,.6)}
513
+ .leg{display:flex;flex-direction:column;gap:9px;font-size:13px}
514
+ .leg-row{display:flex;align-items:center;gap:9px}
515
+ .leg-row .dot{width:9px;height:9px;border-radius:3px;box-shadow:0 0 10px currentColor;flex-shrink:0}
516
+ .leg-row .nm{flex:1;color:var(--muted)}
517
+ .leg-row b{font-variant-numeric:tabular-nums;font-weight:800}
518
+
519
+ /* ============ reference images — exact reproduction ============ */
520
+ .ref-img{
521
+ position:absolute;inset:0;width:100%;height:100%;
522
+ object-fit:contain;object-position:center;
523
+ z-index:5;pointer-events:none;display:block;
524
+ }
525
+ /* ============ bars 3d ============ */
526
+ /* La scene 3D est rendue en alpha : le fond du "stage" fait office de decor. */
527
+ .bars-stage{
528
+ position:relative;border-radius:10px;overflow:hidden;
529
+ /* La scene 3D a un format (~2:1) : au-dela, le graphe flotte dans une
530
+ grande zone vide. On borne donc la largeur et on centre. */
531
+ max-width:940px;margin:0 auto;
532
+ background:
533
+ radial-gradient(760px 340px at 50% 118%,rgba(0,255,255,.13),transparent 66%),
534
+ radial-gradient(620px 300px at 14% -12%,rgba(255,100,0,.11),transparent 62%),
535
+ radial-gradient(520px 260px at 92% 8%,rgba(80,180,255,.10),transparent 60%),
536
+ linear-gradient(180deg,#020509,#040a15 52%,#03070f);
537
+ border:1px solid rgba(0,255,255,.10);
538
+ box-shadow:inset 0 0 70px rgba(0,255,255,.055);
539
+ }
540
+ .chart-empty{
541
+ display:none;position:absolute;inset:0;align-items:center;justify-content:center;
542
+ font-size:11.5px;color:var(--dim);letter-spacing:1.6px;text-transform:uppercase;font-weight:800;
543
+ }
544
+ #bars3d,#bars-act,#bars-mdl{width:100%;height:310px;display:block;cursor:grab;touch-action:none}
545
+ #bars3d{height:382px}
546
+ #bars-act{height:330px}
547
+ #bars-mdl{height:290px}
548
+ #bars3d:active,#bars-act:active,#bars-mdl:active{cursor:grabbing}
549
+ /* barre d'options des graphes 3D */
550
+ .chart-tools{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:9px}
551
+ .chart-tools .sp{flex:1}
552
+ .chart-tools .hint{font-family:var(--mono);font-size:9.5px;color:var(--dim);letter-spacing:.6px}
553
+ .chart-legend{display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--muted);margin-top:6px}
554
+ .chart-legend span{display:inline-flex;align-items:center;gap:7px}
555
+ .chart-legend i{width:11px;height:11px;border-radius:3px;display:inline-block;box-shadow:0 0 10px currentColor}
556
+
557
+ /* ============ tables (refined) ============ */
558
+ .tools{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:11px}
559
+ .search{flex:1;min-width:200px;position:relative}
560
+ .search input{
561
+ width:100%;padding:9px 13px 9px 33px;border-radius:10px;font-size:12.5px;color:var(--ink);
562
+ background:rgba(0,255,255,.05);border:1px solid var(--brd);font-family:inherit;outline:none;transition:.18s;
563
+ }
564
+ .search input::placeholder{color:var(--dim)}
565
+ .search input:focus{border-color:var(--brd-hot);box-shadow:0 0 16px rgba(0,255,255,.22);background:rgba(0,255,255,.08)}
566
+ .search::before{content:'⌕';position:absolute;left:11px;top:6px;font-size:15px;opacity:.65;color:var(--cyan)}
567
+ .btn{
568
+ padding:8px 14px;border-radius:10px;font-size:11.5px;font-weight:800;letter-spacing:.6px;
569
+ background:rgba(238,250,255,.045);color:var(--muted);border:1px solid var(--brd);cursor:pointer;
570
+ font-family:inherit;transition:.18s;white-space:nowrap;
571
+ }
572
+ .btn:hover{color:var(--ink);border-color:var(--brd-hot);background:rgba(0,255,255,.09);box-shadow:0 0 14px rgba(0,255,255,.25)}
573
+ .btn.on{color:#02121c;background:var(--cyan);border-color:transparent;box-shadow:0 0 16px rgba(0,255,255,.45)}
574
+
575
+ .tbl-wrap{
576
+ border-radius:12px;overflow:auto;border:1px solid rgba(238,250,255,.055);
577
+ background:rgba(5,8,18,.55);max-height:340px;
578
+ }
579
+ .tbl-wrap::-webkit-scrollbar{width:8px;height:8px}
580
+ .tbl-wrap::-webkit-scrollbar-thumb{background:rgba(0,255,255,.22);border-radius:8px}
581
+ .tbl-wrap::-webkit-scrollbar-track{background:transparent}
582
+ table{width:100%;border-collapse:separate;border-spacing:0}
583
+ thead th{
584
+ position:sticky;top:0;z-index:3;background:rgba(8,14,28,.97);backdrop-filter:blur(8px);
585
+ color:var(--cyan);padding:9px 13px;text-align:left;font-size:9.5px;text-transform:uppercase;letter-spacing:1.45px;
586
+ border-bottom:1px solid rgba(0,255,255,.24);cursor:pointer;user-select:none;white-space:nowrap;font-weight:800;
587
+ transition:color .16s;
588
+ }
589
+ thead th:hover{color:#a9f0ff;text-shadow:0 0 12px rgba(0,255,255,.6)}
590
+ thead th .arr{font-size:8px;opacity:.85;margin-left:5px}
591
+ tbody td{
592
+ padding:7px 13px;border-bottom:1px solid rgba(238,250,255,.038);font-size:12px;
593
+ font-variant-numeric:tabular-nums;color:#d3e9f7;white-space:nowrap;
594
+ }
595
+ tbody td.num{font-family:var(--mono);text-align:right}
596
+ tbody td.hash{font-family:var(--mono);color:var(--dim);font-size:11px}
597
+ tbody td.prompt{font-family:var(--mono);color:var(--muted);max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
598
+ tbody tr{transition:background .14s}
599
+ tbody tr:hover{background:rgba(0,255,255,.075)}
600
+ tbody tr:hover td:first-child{box-shadow:inset 2px 0 0 var(--cyan)}
601
+ tbody tr:last-child td{border-bottom:none}
602
+ td .name{color:var(--ink);font-weight:600}
603
+ td .count{color:var(--amber);font-weight:800;font-family:var(--mono)}
604
+ .tag{
605
+ display:inline-block;padding:2px 9px;border-radius:9px;font-size:9.5px;font-weight:800;letter-spacing:.7px;
606
+ font-family:var(--mono);
607
+ }
608
+ .t-hit{color:var(--lime);background:rgba(0,255,255,.13);border:1px solid rgba(0,255,255,.38);box-shadow:0 0 12px rgba(0,255,255,.18)}
609
+ .t-miss{color:var(--cyan);background:rgba(0,255,255,.11);border:1px solid rgba(0,255,255,.38);box-shadow:0 0 12px rgba(0,255,255,.18)}
610
+ .t-bypass{color:var(--amber);background:rgba(255,180,0,.12);border:1px solid rgba(255,180,0,.38);box-shadow:0 0 12px rgba(255,180,0,.18)}
611
+ .empty{text-align:center;padding:30px;color:var(--dim);font-size:12.5px}
612
+
613
+ /* ============ target provider / mcp cards ============ */
614
+ .stack{display:flex;flex-direction:column;gap:15px;height:100%}
615
+ .kv2{display:grid;gap:9px}
616
+ .kv2 .row{
617
+ display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 11px;border-radius:10px;
618
+ background:rgba(238,250,255,.035);border:1px solid rgba(238,250,255,.045);font-size:12px;
619
+ }
620
+ .kv2 .row .nm{color:var(--muted);font-family:var(--mono);font-size:11px}
621
+ .kv2 .row .vl{font-weight:800;font-variant-numeric:tabular-nums}
622
+ .tool-row{
623
+ display:flex;align-items:center;gap:9px;padding:7px 11px;border-radius:10px;
624
+ background:rgba(0,255,255,.045);border:1px solid rgba(0,255,255,.13);font-size:11.5px;
625
+ }
626
+ .tool-row .fn{font-family:var(--mono);color:var(--cyan);font-weight:700}
627
+ .tool-row .dsc{color:var(--dim);font-size:10.5px;margin-left:auto;text-align:right}
628
+ .tool-row i{width:6px;height:6px;border-radius:50%;background:var(--lime);box-shadow:0 0 9px var(--lime);flex-shrink:0}
629
+
630
+ .provider-url{
631
+ font-family:var(--mono);font-size:12px;color:var(--lime);word-break:break-all;
632
+ text-shadow:0 0 12px rgba(0,255,255,.45);line-height:1.5;
633
+ }
634
+
635
+ /* ============ tooltip ============ */
636
+ .tip{
637
+ position:fixed;z-index:90;pointer-events:none;opacity:0;transition:opacity .12s;max-width:280px;
638
+ padding:9px 12px;border-radius:10px;background:rgba(8,14,28,.97);border:1px solid var(--brd-hot);
639
+ font-size:11.5px;line-height:1.5;color:var(--ink);
640
+ box-shadow:0 0 26px rgba(0,255,255,.28),0 14px 40px rgba(0,0,0,.6);
641
+ }
642
+ .tip b{color:var(--cyan);display:block;margin-bottom:3px;font-size:10.5px;letter-spacing:1.1px;text-transform:uppercase}
643
+ .tip code{font-family:var(--mono);color:var(--lime);font-size:11px}
644
+
645
+ /* ============ toast ============ */
646
+ .toast{
647
+ position:fixed;bottom:20px;right:20px;z-index:95;padding:12px 19px;border-radius:12px;
648
+ background:rgba(8,14,28,.97);border:1px solid var(--lime);color:var(--lime);font-size:12.5px;font-weight:700;
649
+ box-shadow:0 0 30px rgba(0,255,255,.32);opacity:0;transform:translateY(12px);transition:.26s cubic-bezier(.2,.8,.2,1);
650
+ }
651
+ .toast.show{opacity:1;transform:none}
652
+ .toast.err{border-color:var(--pink);color:var(--pink);box-shadow:0 0 30px rgba(255,100,0,.32)}
653
+
654
+ /* ============ help panel ============ */
655
+ .help-scrim{position:fixed;inset:0;z-index:80;background:rgba(3,3,10,.7);backdrop-filter:blur(3px);opacity:0;pointer-events:none;transition:.28s}
656
+ .help-scrim.on{opacity:1;pointer-events:auto}
657
+ .help-panel{
658
+ position:fixed;top:0;right:0;bottom:0;width:min(440px,92vw);z-index:85;
659
+ background:linear-gradient(180deg,rgba(10,18,34,.98),rgba(5,8,18,.99));
660
+ border-left:1px solid var(--brd-hot);box-shadow:-30px 0 70px -30px rgba(0,255,255,.45);
661
+ transform:translateX(102%);transition:transform .34s cubic-bezier(.2,.8,.2,1);
662
+ display:flex;flex-direction:column;
663
+ }
664
+ .help-panel.on{transform:none}
665
+ .help-head{display:flex;align-items:center;gap:12px;padding:18px 20px;border-bottom:1px solid var(--brd)}
666
+ .help-head h2{font-size:15px;font-weight:800;letter-spacing:2.4px;text-transform:uppercase;color:var(--cyan);text-shadow:0 0 16px rgba(0,255,255,.6)}
667
+ .help-head button{margin-left:auto;background:transparent;border:1px solid var(--brd);color:var(--muted);border-radius:9px;width:30px;height:30px;cursor:pointer;font-size:15px;font-family:inherit}
668
+ .help-head button:hover{border-color:var(--pink);color:var(--pink);box-shadow:0 0 14px rgba(255,100,0,.35)}
669
+ .help-body{overflow-y:auto;padding:18px 20px 30px;display:flex;flex-direction:column;gap:20px}
670
+ .help-body::-webkit-scrollbar{width:8px}
671
+ .help-body::-webkit-scrollbar-thumb{background:rgba(0,255,255,.22);border-radius:8px}
672
+ .help-sec h4{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--pink);margin-bottom:10px;font-weight:800;text-shadow:0 0 12px rgba(255,100,0,.5)}
673
+ .help-sec p{font-size:12.5px;line-height:1.62;color:var(--muted);margin-bottom:9px}
674
+ .help-sec p b{color:var(--ink)}
675
+ .help-sec code{font-family:var(--mono);color:var(--lime);font-size:11.5px;background:rgba(0,255,255,.08);padding:1px 5px;border-radius:4px}
676
+ .help-list{display:flex;flex-direction:column;gap:8px}
677
+ .help-item{display:flex;gap:11px;align-items:flex-start;font-size:12.5px;color:var(--muted);line-height:1.5}
678
+ .help-item .k{
679
+ font-family:var(--mono);font-size:10.5px;color:var(--cyan);background:rgba(0,255,255,.1);
680
+ border:1px solid var(--brd);border-radius:6px;padding:2px 7px;flex-shrink:0;min-width:34px;text-align:center;
681
+ }
682
+ .help-item .t b{color:var(--ink);display:block}
683
+ .help-item .t code{font-size:11px}
684
+ .flow{display:flex;flex-direction:column;gap:5px;font-family:var(--mono);font-size:11px}
685
+ .flow div{padding:7px 11px;border-radius:8px;background:rgba(238,250,255,.035);border:1px solid rgba(238,250,255,.055);color:var(--muted)}
686
+ .flow div span{color:var(--cyan)}
687
+ .flow div em{color:var(--lime);font-style:normal}
688
+
689
+ @media (max-width:1180px){
690
+ .app{grid-template-columns:242px 1fr}
691
+ .globe-stage,#globe{width:300px;height:300px}
692
+ .split,.split-r{grid-template-columns:1fr}
693
+ }
694
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
695
+ </style>
696
+ <script src="three.min.js"></script>
697
+ <script>
698
+ /* WebGL canvases: give legacy 2D code a harmless no-op context so it can't
699
+ crash or overwrite the WebGL render. */
700
+ (function(){
701
+ var ids = ['globe','ring3d','bars3d'];
702
+ var noop = new Proxy({}, {
703
+ get: function(t, k){
704
+ if (k === 'canvas') return null;
705
+ if (k === 'createRadialGradient' || k === 'createLinearGradient')
706
+ return function(){ return { addColorStop: function(){} }; };
707
+ if (k === 'measureText') return function(){ return { width: 0 }; };
708
+ if (k === 'getImageData') return function(){ return { data: [] }; };
709
+ return function(){};
710
+ },
711
+ set: function(){ return true; }
712
+ });
713
+ var orig = HTMLCanvasElement.prototype.getContext;
714
+ HTMLCanvasElement.prototype.getContext = function(type){
715
+ if (type === '2d' && this.id && ids.indexOf(this.id) >= 0) return noop;
716
+ return orig.apply(this, arguments);
717
+ };
718
+ })();
719
+ </script>
720
+ <script src="icywebgl.js"></script>
721
+ <style id=wow>body::after{content:'';position:fixed;inset:-15%;z-index:-1;pointer-events:none;background:radial-gradient(38% 32% at 18% 18%,rgba(0,229,255,.17),transparent 70%),radial-gradient(34% 30% at 82% 26%,rgba(169,139,255,.15),transparent 70%),radial-gradient(40% 34% at 58% 92%,rgba(255,95,210,.10),transparent 70%);filter:blur(34px);animation:wowaur 24s ease-in-out infinite alternate}
722
+ @keyframes wowaur{to{transform:translate3d(3%,-2%,0) scale(1.08)}}
723
+ #wowspot{position:fixed;left:0;top:0;width:620px;height:620px;border-radius:50%;pointer-events:none;z-index:0;background:radial-gradient(closest-side,rgba(0,229,255,.11),rgba(169,139,255,.05) 55%,transparent);mix-blend-mode:screen;will-change:transform}
724
+ .glass{transition:box-shadow .35s,border-color .35s,transform .35s}
725
+ .glass:hover{background-image:radial-gradient(460px circle at var(--mx,50%) var(--my,50%),rgba(0,229,255,.10),transparent 60%)}
726
+ .v,.val,.big,.tc-v,.rv{text-shadow:0 0 26px currentColor}
727
+ .wowpop{animation:wowpop .75s ease-out}
728
+ @keyframes wowpop{0%{transform:scale(1.14);filter:brightness(1.9)}100%{transform:none;filter:none}}
729
+ .top{position:relative}
730
+ .top::after{content:'';position:absolute;left:0;right:0;bottom:-1px;height:1px;background:linear-gradient(90deg,transparent,var(--ice),var(--pink),var(--gold),transparent);background-size:200% 100%;animation:wowline 7s linear infinite;opacity:.8}
731
+ @keyframes wowline{to{background-position:-200% 0}}
732
+ .main{padding:22px 28px 40px}
733
+ .side{overflow-y:auto;scrollbar-width:none}
734
+ .side-actions{display:grid;grid-template-columns:1fr 1fr;gap:6px}
735
+ .side .pills{display:flex;flex-wrap:wrap;gap:5px;margin:4px 0}
736
+ .side .refr{display:flex;align-items:center;gap:6px;margin:4px 0}
737
+ .side .kv{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:6px 0}
738
+ .side .kv .glass{padding:7px 10px}
739
+ .side .kv .val{font-size:15px}
740
+ @media(min-width:1200px){.grid.tiles{grid-auto-rows:minmax(92px,auto)}.tiles .t-node{grid-column:span 2;grid-row:span 1;padding:14px 18px}.tiles .t-node .v{font-size:clamp(30px,3.2vw,44px)}.tiles .t-saved,.tiles .t-pct{grid-column:span 2}.tiles .t-tks{display:none}.grid.split{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}}
741
+ .tile-dots{display:none}
742
+ .t-saved #saved-in,.t-saved #saved-out{display:inline-block;font-size:14px;margin-right:14px}
743
+ .globe-card{display:block;position:relative}
744
+ .globe-stage{position:relative;width:100%;height:clamp(400px,54vh,560px);background:none!important;border:0!important;box-shadow:none!important;border-radius:0!important;overflow:visible!important}
745
+ .globe-stage canvas{width:100%;height:100%;display:block;background:transparent!important;filter:drop-shadow(0 0 40px rgba(0,229,255,.42))}
746
+ .globe-stage::before{content:'';position:absolute;inset:6%;border-radius:50%;background:radial-gradient(closest-side,rgba(0,229,255,.17),rgba(169,139,255,.08) 55%,transparent 74%)!important;border:0!important;animation:wowbreath 5s ease-in-out infinite;pointer-events:none}
747
+ @keyframes wowbreath{50%{transform:scale(1.09);opacity:.65}}
748
+ .globe-side{display:none}
749
+ .gtool{position:absolute;left:12px;top:50%;bottom:auto;transform:translateY(-50%);z-index:4;display:flex;flex-direction:column;flex-wrap:nowrap;gap:5px;padding:8px;border-radius:16px;background:rgba(6,12,26,.42);border:1px solid rgba(0,229,255,.2);backdrop-filter:blur(14px);opacity:.5;transition:opacity .25s}
750
+ .gtool .hud-btn{padding:5px 9px;font-size:10px;white-space:nowrap;text-align:left}
751
+ .globe-stage:hover .gtool{opacity:1}
752
+ .globe-hud{position:absolute!important;left:50%!important;bottom:10px!important;transform:translateX(-50%);z-index:3}
753
+ .globe-stage .inspector{position:absolute;top:12px;right:12px;z-index:4;max-width:250px;opacity:0;transform:translateY(-6px);transition:opacity .25s,transform .25s;pointer-events:none}
754
+ .globe-stage:hover .inspector{opacity:1;transform:none}
755
+ .gdet{margin:10px 4px 2px}
756
+ .gdet summary{cursor:pointer;font-size:11px;letter-spacing:1.6px;text-transform:uppercase;color:var(--muted)}
757
+ .loop-hud{display:flex;flex-wrap:wrap;gap:5px;max-height:0;opacity:0;overflow:hidden;transition:max-height .35s,opacity .3s}
758
+ .ring-wrap:hover .loop-hud{max-height:140px;opacity:1}
759
+ .tkpis{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:15px;margin-bottom:15px}
760
+ .tkpis .glass{padding:14px 16px}
761
+ .tkpis .k{font-size:10px;letter-spacing:1.4px;text-transform:uppercase;color:var(--dim);font-weight:800}
762
+ .tkpis .val{font-size:26px;font-weight:800;margin-top:6px;font-variant-numeric:tabular-nums;color:var(--ice)}
763
+ #tk-hits,#tk-lat{color:var(--green)}
764
+ #tk-err{color:var(--red)}
765
+ #tk-mb{color:var(--violet)}
766
+ .tgrid{display:grid;grid-template-columns:1.4fr 1fr;gap:15px;margin-bottom:15px}
767
+ .trow{display:grid;grid-template-columns:120px 1fr 44px 70px;gap:10px;align-items:center;font-size:11.5px;margin:8px 0;font-family:var(--mono);color:var(--muted)}
768
+ .tbar{height:8px;border-radius:5px;background:rgba(238,250,255,.07);overflow:hidden}
769
+ .tbar i{display:block;height:100%;border-radius:5px;background:linear-gradient(90deg,var(--ice),var(--violet));box-shadow:0 0 12px rgba(0,229,255,.5)}
770
+ .trow b{color:var(--ink);text-align:right}
771
+ .ttbl{max-height:46vh;overflow:auto;font-family:var(--mono);font-size:11px}
772
+ .tr{display:grid;grid-template-columns:70px 64px 1fr 76px 72px 78px;gap:8px;padding:6px 8px;border-bottom:1px solid rgba(90,190,255,.08);align-items:center;color:var(--muted)}
773
+ .tr:hover{background:rgba(0,229,255,.05)}
774
+ .tr.th{color:var(--dim);font-size:9.5px;letter-spacing:1.3px;text-transform:uppercase;position:sticky;top:0;background:rgba(8,14,30,.92);z-index:1}
775
+ .tk-tool_call,.tk-tool_hit,.tk-tool_error{padding:2px 8px;border-radius:9px;font-size:9.5px;font-weight:800;letter-spacing:.8px;text-align:center}
776
+ .tk-tool_call{color:var(--ice);background:rgba(0,229,255,.1)}
777
+ .tk-tool_hit{color:var(--green);background:rgba(61,255,160,.12)}
778
+ .tk-tool_error{color:var(--red);background:rgba(255,77,109,.15)}
779
+ .cli-claude{color:var(--gold)}
780
+ .cli-hermes{color:var(--violet)}
781
+ .tchips{margin-left:auto;display:flex;gap:6px}
782
+ .tnote{font-size:10.5px;color:var(--dim);margin-top:12px;line-height:1.5}
783
+ @media(max-width:1199px){.tkpis{grid-template-columns:repeat(2,1fr)}.tgrid{grid-template-columns:1fr}}.glass h3{justify-content:flex-start}.glass h3 .ln{order:3}.glass h3 .badge{order:2}.glass h3 .tchips{order:4}.rail-row{padding:6px 11px!important}.rail-row .spark{height:14px!important}.side-foot{display:none}.brand-txt span{font-size:8px!important;letter-spacing:1px!important;white-space:nowrap}.act-btn{padding:7px 8px!important;font-size:11px!important}.side .refr label{display:none}.side .kv .glass{padding:5px 9px}</style>
784
+ </head>
785
+ <body>
786
+ <canvas id="bgfx"></canvas>
787
+ <div class="tip" id="tip"></div>
788
+ <div class="toast" id="toast"></div>
789
+
790
+ <div class="app">
791
+ <!-- ==================== SIDEBAR ==================== -->
792
+ <aside class="side">
793
+ <div class="brand">
794
+ <div class="logo-wrap">
795
+ <svg viewBox="0 0 64 64" aria-label="IcyCache logo">
796
+ <defs>
797
+ <linearGradient id="lgEdge" x1="0" y1="0" x2="1" y2="1">
798
+ <stop offset="0%" stop-color="#00ffff"/><stop offset="30%" stop-color="#00ffff"/>
799
+ <stop offset="58%" stop-color="#50b4ff"/><stop offset="82%" stop-color="#ff6400"/>
800
+ <stop offset="100%" stop-color="#ffb400"/>
801
+ </linearGradient>
802
+ <linearGradient id="lgBolt" x1=".15" y1="0" x2=".85" y2="1">
803
+ <stop offset="0%" stop-color="#ffffff"/><stop offset="40%" stop-color="#ffb400"/>
804
+ <stop offset="100%" stop-color="#ff6400"/>
805
+ </linearGradient>
806
+ <radialGradient id="lgCore" cx="50%" cy="50%" r="50%">
807
+ <stop offset="0%" stop-color="#00ffff" stop-opacity=".55"/>
808
+ <stop offset="52%" stop-color="#50b4ff" stop-opacity=".2"/>
809
+ <stop offset="100%" stop-color="#00ffff" stop-opacity="0"/>
810
+ </radialGradient>
811
+ <filter id="lgBloom" x="-70%" y="-70%" width="240%" height="240%">
812
+ <feGaussianBlur stdDeviation="1.5" result="b"/>
813
+ <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
814
+ </filter>
815
+ </defs>
816
+
817
+ <circle cx="32" cy="32" r="29" fill="url(#lgCore)"/>
818
+
819
+ <!-- neural orbit ring -->
820
+ <circle cx="32" cy="32" r="27.5" fill="none" stroke="url(#lgEdge)" stroke-width="1.3"
821
+ stroke-opacity=".5" stroke-linecap="round" stroke-dasharray="3.5 6.5">
822
+ <animateTransform attributeName="transform" type="rotate" from="0 32 32" to="360 32 32" dur="16s" repeatCount="indefinite"/>
823
+ </circle>
824
+
825
+ <!-- forge hexagon -->
826
+ <path d="M32 4.5 L55.5 18 L55.5 46 L32 59.5 L8.5 46 L8.5 18 Z" fill="rgba(5,9,20,.55)"
827
+ stroke="url(#lgEdge)" stroke-width="2.6" stroke-linejoin="round" filter="url(#lgBloom)"/>
828
+ <path d="M32 12 L48.5 21.5 L48.5 42.5 L32 52 L15.5 42.5 L15.5 21.5 Z" fill="none"
829
+ stroke="url(#lgEdge)" stroke-width=".9" stroke-opacity=".5" stroke-linejoin="round"/>
830
+
831
+ <!-- neuron arbor behind the bolt -->
832
+ <g stroke="#00ffff" stroke-width="1.3" stroke-linecap="round" stroke-opacity=".65" fill="none">
833
+ <path d="M32 32 L17.5 20.5"/><path d="M32 32 L17.5 43.5"/>
834
+ <path d="M32 32 L46.5 20.5"/><path d="M32 32 L46.5 43.5"/>
835
+ </g>
836
+ <g fill="#00ffff">
837
+ <circle cx="17.5" cy="20.5" r="2.1"><animate attributeName="opacity" values=".35;1;.35" dur="2.8s" repeatCount="indefinite"/></circle>
838
+ <circle cx="46.5" cy="43.5" r="2.1"><animate attributeName="opacity" values="1;.35;1" dur="3.4s" repeatCount="indefinite"/></circle>
839
+ </g>
840
+ <g fill="#50b4ff">
841
+ <circle cx="46.5" cy="20.5" r="1.7"><animate attributeName="opacity" values=".3;1;.3" dur="3.1s" repeatCount="indefinite"/></circle>
842
+ <circle cx="17.5" cy="43.5" r="1.7"><animate attributeName="opacity" values="1;.3;1" dur="2.6s" repeatCount="indefinite"/></circle>
843
+ </g>
844
+
845
+ <!-- forge bolt -->
846
+ <path d="M35.5 12 L20 35.5 L29.5 35.5 L27 52 L44 27 L33.5 27 Z" fill="url(#lgBolt)" filter="url(#lgBloom)"/>
847
+
848
+ <!-- orbiting sparks -->
849
+ <circle cx="32" cy="4.5" r="2.4" fill="#00ffff" filter="url(#lgBloom)">
850
+ <animateTransform attributeName="transform" type="rotate" from="0 32 32" to="360 32 32" dur="7s" repeatCount="indefinite"/>
851
+ </circle>
852
+ <circle cx="32" cy="59.5" r="1.9" fill="#ff6400" filter="url(#lgBloom)">
853
+ <animateTransform attributeName="transform" type="rotate" from="360 32 32" to="0 32 32" dur="5.5s" repeatCount="indefinite"/>
854
+ </circle>
855
+ </svg>
856
+ </div>
857
+ <div class="brand-txt"><b>IcyCache</b><span>I SEE CACHE — I BURN CACHE</span></div>
858
+ </div>
859
+
860
+ <div class="sep"><span>Navigate</span><i></i></div>
861
+ <nav class="nav" id="nav">
862
+ <button class="nav-btn on" data-v="overview" data-tip="<b>Overview</b>Live snapshot of the cache core — hit rate, tokens saved, model breakdown and the 3D activity chart.">
863
+ <span class="ico">◉</span><span class="lbl">Overview</span></button>
864
+ <button class="nav-btn" data-v="activity" data-tip="<b>Activity</b>Time-series of every request. The 3D chart stacks hits, misses and bypass per bucket.">
865
+ <span class="ico">📊</span><span class="lbl">Activity</span><span class="cnt" id="n-req">0</span></button>
866
+ <button class="nav-btn" data-v="cache" data-tip="<b>Cache</b>Every normalized prompt currently stored in SQLite, with its hash and hit counter.">
867
+ <span class="ico">🗄️</span><span class="lbl">Cache</span><span class="cnt" id="n-entries">0</span></button>
868
+ <button class="nav-btn" data-v="models" data-tip="<b>Models</b>Per-model requests, hits and token economy, ranked by the 3D savings chart.">
869
+ <span class="ico">🤖</span><span class="lbl">Models</span><span class="cnt" id="n-models">0</span></button>
870
+ </nav>
871
+
872
+ <div class="sep"><span>Live Telemetry</span><i></i></div>
873
+ <div class="rail">
874
+ <div class="rail-row" data-tip="<b>Hit Rate</b>Share of requests served from cache without re-running the model. 100% means zero CPU burned.">
875
+ <span class="rl">Hit Rate</span><b class="rv c-cyan" id="r-hr">—</b>
876
+ <canvas class="spark" id="sp-hr" width="500" height="60"></canvas>
877
+ </div>
878
+ <div class="rail-row" data-tip="<b>Tokens Saved</b>Tokens never generated because the answer came from cache. This is the number that pays for the whole setup.">
879
+ <span class="rl">Tokens Saved</span><b class="rv c-lime" id="r-saved">—</b>
880
+ <canvas class="spark" id="sp-saved" width="500" height="60"></canvas>
881
+ </div>
882
+ <div class="rail-row" data-tip="<b>Cache Entries</b>Distinct prompts stored in SQLite, capped by <code>cache.max_entries</code> with a TTL.">
883
+ <span class="rl">Cache Entries</span><b class="rv c-violet" id="r-entries">—</b>
884
+ <span class="rs" id="r-entries-s">—</span>
885
+ </div>
886
+ <div class="rail-row" data-tip="<b>Decode Speed</b>Tokens per second produced by the upstream model, measured on real (non-cached) requests.">
887
+ <span class="rl">Decode Speed</span><b class="rv c-amber" id="r-tks">—</b>
888
+ <canvas class="spark" id="sp-tks" width="500" height="60"></canvas>
889
+ </div>
890
+ <div class="rail-row" data-tip="<b>MCP Server</b>Model Context Protocol endpoint exposed over stdio and SSE. Tools are callable by any MCP client.">
891
+ <span class="rl">MCP Server</span><b class="rv c-lime" id="r-mcp">—</b>
892
+ <span class="rs"><i class="status-dot" id="r-mcp-dot"></i><span id="r-mcp-s">checking…</span></span>
893
+ </div>
894
+ <div class="rail-row" data-tip="<b>Upstream</b>The LLM backend behind the proxy. IcyCache sits in front of it and answers first whenever it can.">
895
+ <span class="rl">Upstream</span><b class="rv c-cyan" id="r-up">—</b>
896
+ <span class="rs"><i class="status-dot" id="r-up-dot"></i><span id="r-up-s">checking…</span></span>
897
+ </div>
898
+ </div>
899
+
900
+ <div class="spacer"></div>
901
+
902
+ <div class="side-actions">
903
+ <button class="act-btn" onclick="refreshAll(true)" data-tip="<b>Refresh</b>Pull every metric right now instead of waiting for the auto-refresh tick.">↻&nbsp; Refresh</button>
904
+ <button class="act-btn" onclick="exportJSON()" data-tip="<b>Export JSON</b>Download stats, metrics, time-series, models and the whole cache in one file.">⬇&nbsp; Export JSON</button>
905
+ <button class="act-btn" onclick="openLogs()" data-tip="<b>Logs</b>Open the raw API payload. Full server logs live in <code>logs/app.log</code>.">📜&nbsp; Logs</button>
906
+ <button class="act-btn danger" onclick="clearCache()" data-tip="<b>Clear Cache</b>Wipe every cached entry. Irreversible — the next request pays full price again.">🗑&nbsp; Clear Cache</button>
907
+ <button class="act-btn help" onclick="toggleHelp()" data-tip="<b>Help</b>Glossary, MCP tool reference, keyboard shortcuts and how the cache actually works.">?&nbsp; Help &amp; Shortcuts</button>
908
+ </div>
909
+ <div class="side-foot">100% Local · No Cloud</div>
910
+ </aside>
911
+
912
+ <!-- ==================== TOPBAR ==================== -->
913
+ <header class="top">
914
+ <h1><em>⬢</em><span id="view-title">Overview</span></h1>
915
+ <div class="pills">
916
+ <span class="pill ok" id="pill-proxy"><i></i>PROXY <b>:8080</b></span>
917
+ <span class="pill" id="pill-mcp"><i></i>MCP <b>:8081</b></span>
918
+ <span class="pill" id="pill-up"><i></i>UPSTREAM</span>
919
+ <span class="pill warn" id="live-pill" title="Flux temps réel (SSE) — les compteurs sont poussés à chaque événement proxy."><i></i>POLLING</span>
920
+ </div>
921
+ <div class="ticker">
922
+ <span class="tk-l">Live</span>
923
+ <span class="save-led" id="save-led"><i></i>Saving</span>
924
+ <div class="tk-track" id="tk-track"><span class="tk-empty">waiting for traffic…</span></div>
925
+ </div>
926
+ <div class="refr">
927
+ <label for="refr-sel">Refresh</label>
928
+ <select id="refr-sel" onchange="setRefresh(this.value)">
929
+ <option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="0">off</option>
930
+ </select>
931
+ <button onclick="refreshAll(true)" title="Refresh now">↻</button>
932
+ </div>
933
+ <div id="clock">--:--:--</div>
934
+ </header>
935
+
936
+ <!-- ==================== MAIN ==================== -->
937
+ <main class="main">
938
+
939
+ <!-- ========== OVERVIEW ========== -->
940
+ <section class="view on" id="v-overview">
941
+ <div class="grid tiles">
942
+ <div class="glass t-node" id="card-entries" data-tip="<b>Cache Entries</b>Number of distinct normalized prompts stored in SQLite — one node each in the neural core.">
943
+ <h3><span class="ln"></span>Cache Entries<span class="badge">nodes</span></h3>
944
+ <div class="v c-cyan" id="entries">—</div>
945
+ <div class="sub" id="entries-sub">nodes living in the neural core</div>
946
+ <div class="tile-dots" id="tile-dots"></div>
947
+ </div>
948
+ <div class="glass t-saved" id="card-saved" data-tip="<b>Tokens Saved</b>Prompt + completion tokens skipped because the answer was already cached.">
949
+ <h3><span class="ln"></span>Tokens Saved</h3>
950
+ <div class="v c-lime" id="saved-total">—</div>
951
+ <div class="v c-cyan" id="saved-in">—</div>
952
+ <div class="v c-pink" id="saved-out">—</div>
953
+ <div class="sub" id="saved-sub">—</div>
954
+ <div class="sub live-save idle" id="saved-live">waiting for the first hit…</div>
955
+ <div class="meter m-lime"><i id="bar-saved"></i></div>
956
+ </div>
957
+ <div class="glass t-pct" id="card-pct" data-tip="<b>% Saved</b>Share of all tokens that never had to be generated.">
958
+ <h3><span class="ln"></span>% Saved</h3>
959
+ <div class="v c-pink" id="m-pct">—</div>
960
+ <div class="sub" id="m-pct-s">—</div>
961
+ <div class="meter m-pink"><i id="bar-pct"></i></div>
962
+ </div>
963
+ <div class="glass t-tks" id="card-tks" data-tip="<b>Decode Speed</b>Tokens per second of the upstream model, averaged over real generation only.">
964
+ <h3><span class="ln"></span>Decode Speed</h3>
965
+ <div class="tks-row">
966
+ <div class="v c-amber" id="m-tks">—</div>
967
+ <div class="tks-meta">
968
+ <div class="sub" id="m-tks-s">—</div>
969
+ <div class="meter m-amber"><i id="bar-tks"></i></div>
970
+ </div>
971
+ </div>
972
+ </div>
973
+ </div>
974
+
975
+ <div class="grid split">
976
+ <div class="glass globe-card">
977
+ <h3 class="card-h"><span class="ln"></span>Human Neuron<span class="badge">DRAG · SCROLL · SHIFT-DRAG</span></h3>
978
+ <div class="globe-stage">
979
+
980
+ <canvas id="globe" width="1000" height="1000"></canvas>
981
+ <div class="globe-hud">
982
+ <span class="hud-chip" id="hud-spin">SPIN 0.10 rad/s</span>
983
+ <span class="hud-chip" id="hud-nodes">0 NODES</span>
984
+ <span class="hud-chip" id="hud-syn">0 SYNAPSES</span>
985
+ </div>
986
+ </div>
987
+ <div class="globe-side">
988
+ <div class="gctl">
989
+ <button class="hud-btn" data-g="replay">⟲ REPLAY</button>
990
+ <button class="hud-btn on" data-g="spin">⟳ Spin</button>
991
+ <button class="hud-btn on" data-g="labels">Labels</button>
992
+ <button class="hud-btn on" data-g="synapses">Synapses</button>
993
+ <button class="hud-btn on" data-g="spines">Spines</button>
994
+ <button class="hud-btn on" data-g="myelin">Myelin</button>
995
+ <button class="hud-btn" data-g="pulse">⚡ Fire</button>
996
+ <button class="hud-btn" data-g="center">Center</button>
997
+ <button class="hud-btn" data-g="zin">+</button>
998
+ <button class="hud-btn" data-g="zout">−</button>
999
+ <button class="hud-btn" data-g="reset">Reset</button>
1000
+ </div>
1001
+ <div class="kv">
1002
+ <div class="glass"><div class="k">Hit Rate</div><div class="val c-cyan" id="ov-hr">—</div></div>
1003
+ <div class="glass"><div class="k">Requests</div><div class="val c-pink" id="ov-req">—</div></div>
1004
+ <div class="glass"><div class="k">Hits</div><div class="val c-lime" id="ov-hits">—</div></div>
1005
+ <div class="glass"><div class="k">Misses</div><div class="val c-red" id="ov-miss">—</div></div>
1006
+ </div>
1007
+ <div class="cell-stats" id="cell-stats"></div>
1008
+ <div class="globe-legend">
1009
+ <span><i style="background:linear-gradient(135deg,#00ffff,#ff6400)"></i>One hue per model</span>
1010
+ <span><i style="background:#3dffa0"></i>Node size = hits</span>
1011
+ <span><i style="background:#9fefff"></i>Filaments = synapses</span>
1012
+ </div>
1013
+ <div class="inspector" id="node-menu">
1014
+ <div class="ins-head"><i id="nm-dot"></i><b id="nm-name">Human Neuron</b><span class="ins-tag">soma</span></div>
1015
+ <div class="ins-sub" id="nm-sub">dendrites · myelinated axon · synaptic boutons</div>
1016
+ <div class="ins-grid" id="nm-grid"></div>
1017
+ </div>
1018
+ <div class="sub">A real cell: soma, nucleus, branching dendrites, a myelinated axon and its terminals. Every cached prompt is a node wired into the cell — hover to inspect, drag to orbit, scroll to zoom, shift-drag to pan.</div>
1019
+ </div>
1020
+ <div class="globe-legend wide" id="globe-legend"></div>
1021
+ </div>
1022
+
1023
+ <div class="glass">
1024
+ <h3><span class="ln"></span>Agent Loop<span class="badge" id="ring-badge">∞ LEMNISCATE</span></h3>
1025
+ <div class="ring-wrap">
1026
+ <div class="ring-canvas">
1027
+
1028
+ <canvas id="ring3d" width="720" height="520"></canvas>
1029
+ <div class="ring-center">
1030
+ <div>
1031
+ <div class="big c-cyan" id="ring-val">—</div>
1032
+ <div class="lbl">Hit Rate</div>
1033
+ </div>
1034
+ </div>
1035
+ </div>
1036
+ <div class="loop-hud" id="loop-hud">
1037
+ <button class="hud-btn on" data-lp="run">▶ RUN PIPELINE</button>
1038
+ <button class="hud-btn on" data-lp="shape">∞ LOOP</button>
1039
+ <button class="hud-btn" data-lp="flight">FLIGHT</button>
1040
+ <button class="hud-btn on" data-lp="bolt">⚡ STORM</button>
1041
+ <button class="hud-btn on" data-lp="spin">SPIN</button>
1042
+ <button class="hud-btn" data-lp="thin">THIN</button>
1043
+ <button class="hud-btn" data-lp="thick">THICK</button>
1044
+ <button class="hud-btn" data-lp="slower">◂ SLOW</button>
1045
+ <button class="hud-btn" data-lp="faster">FAST ▸</button>
1046
+ <button class="hud-btn" data-lp="reset">RESET</button>
1047
+ </div>
1048
+ <div class="loop-stages" id="loop-stages"></div>
1049
+ <div class="ring-bottom">
1050
+ <div class="token-counter">
1051
+ <div class="tc-k">Tokens saved <i>live</i></div>
1052
+ <div class="tc-v c-lime" id="tc-val">0</div>
1053
+ <div class="tc-r" id="tc-rate">— /s</div>
1054
+ <div class="tc-bar"><i id="tc-bar"></i></div>
1055
+ <div class="tc-sub" id="tc-sub">waiting for traffic…</div>
1056
+ </div>
1057
+ <div class="leg">
1058
+ <div class="leg-row"><span class="dot" style="background:var(--lime);color:var(--lime)"></span><span class="nm">Hits</span><b class="c-lime" id="lg-hits">—</b></div>
1059
+ <div class="leg-row"><span class="dot" style="background:var(--cyan);color:var(--cyan)"></span><span class="nm">Misses</span><b class="c-cyan" id="lg-miss">—</b></div>
1060
+ <div class="leg-row"><span class="dot" style="background:var(--amber);color:var(--amber)"></span><span class="nm">Bypass</span><b class="c-amber" id="lg-bypass">—</b></div>
1061
+ <div class="sub" id="lg-note"></div>
1062
+ </div>
1063
+ </div>
1064
+ </div>
1065
+ </div>
1066
+ </div>
1067
+
1068
+ <div class="grid split-r">
1069
+ <div class="glass">
1070
+ <h3><span class="ln"></span>Traffic Volume<span class="badge">3D</span>
1071
+ <span style="margin-left:auto;display:flex;gap:6px">
1072
+ <button class="btn" data-range="1h" onclick="setRange('1h',this)">1h</button>
1073
+ <button class="btn on" data-range="24h" onclick="setRange('24h',this)">24h</button>
1074
+ <button class="btn" data-range="7j" onclick="setRange('7j',this)">7d</button>
1075
+ </span>
1076
+ </h3>
1077
+ <div class="bars-stage"><canvas id="bars3d"></canvas><div class="chart-empty" id="chart-empty">No traffic in this range</div></div>
1078
+ <div class="chart-legend">
1079
+ <span><i style="background:#3dffa0;color:#00ffff"></i>Cache hits</span>
1080
+ <span><i style="background:#ff4d6d;color:#ff4d6d"></i>Misses (paid)</span>
1081
+ <span><i style="background:#4d9dff;color:#4d9dff"></i>Bypass</span>
1082
+ <span><i style="background:#ffd24a;color:#ffaa00"></i>Tokens saved</span>
1083
+ </div>
1084
+ <div class="chart-tools">
1085
+ <button class="hud-btn on" data-c="ov" data-k="grid">Grid</button>
1086
+ <button class="hud-btn on" data-c="ov" data-k="labels">Labels</button>
1087
+ <button class="hud-btn on" data-c="ov" data-k="ribbon">Ribbon</button>
1088
+ <button class="hud-btn" data-c="ov" data-k="spin">⟳ Spin</button>
1089
+ <button class="hud-btn" data-c="ov" data-k="zin">+</button>
1090
+ <button class="hud-btn" data-c="ov" data-k="zout">−</button>
1091
+ <button class="hud-btn" data-c="ov" data-k="peak">◎ Peak</button>
1092
+ <button class="hud-btn" data-c="ov" data-k="reset">Reset</button>
1093
+ <span class="sp"></span>
1094
+ <span class="hint">DRAG ORBIT · WHEEL ZOOM · SHIFT-DRAG PAN</span>
1095
+ </div>
1096
+ </div>
1097
+
1098
+ <div class="stack">
1099
+ <div class="glass">
1100
+ <h3><span class="ln"></span>Target Provider</h3>
1101
+ <div class="provider-url" id="provider">—</div>
1102
+ <div class="kv2" style="margin-top:11px">
1103
+ <div class="row"><span class="nm">latency</span><span class="vl c-lime" id="up-lat">—</span></div>
1104
+ <div class="row"><span class="nm">transport</span><span class="vl c-cyan">stdio + SSE</span></div>
1105
+ </div>
1106
+ </div>
1107
+ <div class="glass">
1108
+ <h3><span class="ln"></span>Token Flow</h3>
1109
+ <div class="kv">
1110
+ <div class="glass"><div class="k">TK In</div><div class="val c-cyan" id="m-in">—</div><div class="sub" id="m-in-s">—</div></div>
1111
+ <div class="glass"><div class="k">TK Out</div><div class="val c-pink" id="m-out">—</div><div class="sub" id="m-out-s">—</div></div>
1112
+ </div>
1113
+ </div>
1114
+ <div class="glass">
1115
+ <h3><span class="ln"></span>MCP Tools<span class="badge" id="mcp-count">—</span></h3>
1116
+ <div class="kv2" id="mcp-tools"></div>
1117
+ <h3 style="margin-top:14px"><span class="ln"></span>Routed Plugin Servers<span class="badge" id="plugin-count">—</span></h3>
1118
+ <div class="kv2" id="mcp-plugins"></div>
1119
+ <div class="sub" id="plugin-note" style="margin-top:7px">MCP servers the proxy can reach and call on your behalf.</div>
1120
+ </div>
1121
+ </div>
1122
+ </div>
1123
+ </section>
1124
+
1125
+ <!-- ========== ACTIVITY ========== -->
1126
+ <section class="view" id="v-activity">
1127
+ <div class="glass">
1128
+ <h3><span class="ln"></span>Traffic Volume<span class="badge">3D EXTRUDED</span>
1129
+ <span style="margin-left:auto;display:flex;gap:6px">
1130
+ <button class="btn" data-range="1h" onclick="setRange('1h',this)">1h</button>
1131
+ <button class="btn on" data-range="24h" onclick="setRange('24h',this)">24h</button>
1132
+ <button class="btn" data-range="7j" onclick="setRange('7j',this)">7d</button>
1133
+ </span>
1134
+ </h3>
1135
+ <div class="bars-stage"><canvas id="bars-act"></canvas><div class="chart-empty" id="chart-empty-act">No traffic in this range</div></div>
1136
+ <div class="chart-legend">
1137
+ <span><i style="background:#3dffa0;color:#00ffff"></i>Cache hits</span>
1138
+ <span><i style="background:#ff4d6d;color:#ff4d6d"></i>Misses (paid)</span>
1139
+ <span><i style="background:#4d9dff;color:#4d9dff"></i>Bypass</span>
1140
+ <span><i style="background:#ffd24a;color:#ffaa00"></i>Tokens saved (ribbon)</span>
1141
+ </div>
1142
+ <div class="chart-tools">
1143
+ <button class="hud-btn on" data-c="act" data-k="grid">Grid</button>
1144
+ <button class="hud-btn on" data-c="act" data-k="labels">Labels</button>
1145
+ <button class="hud-btn on" data-c="act" data-k="ribbon">Ribbon</button>
1146
+ <button class="hud-btn" data-c="act" data-k="spin">⟳ Spin</button>
1147
+ <button class="hud-btn" data-c="act" data-k="zin">+</button>
1148
+ <button class="hud-btn" data-c="act" data-k="zout">−</button>
1149
+ <button class="hud-btn" data-c="act" data-k="peak">◎ Peak</button>
1150
+ <button class="hud-btn" data-c="act" data-k="reset">Reset</button>
1151
+ <span class="sp"></span>
1152
+ <span class="hint">DRAG ORBIT · WHEEL ZOOM · SHIFT-DRAG PAN</span>
1153
+ </div>
1154
+ </div>
1155
+
1156
+ <div class="grid g2" style="margin-top:15px">
1157
+ <div class="glass">
1158
+ <h3><span class="ln"></span>Models · Tokens</h3>
1159
+ <div class="tbl-wrap"><table>
1160
+ <thead><tr><th data-s="model">Model<span class="arr"></span></th><th data-s="requests">Requests<span class="arr"></span></th><th data-s="hits">Hits<span class="arr"></span></th><th data-s="spent">Spent<span class="arr"></span></th><th data-s="saved">Saved<span class="arr"></span></th></tr></thead>
1161
+ <tbody id="mdl-body"><tr><td colspan="5" class="empty">No data</td></tr></tbody>
1162
+ </table></div>
1163
+ </div>
1164
+ <div class="glass">
1165
+ <h3><span class="ln"></span>Recent Requests</h3>
1166
+ <div class="tbl-wrap"><table>
1167
+ <thead><tr><th>Time</th><th>Kind</th><th>Model</th><th>In</th><th>Out</th><th>TK/s</th><th>Latency</th><th>Saved</th></tr></thead>
1168
+ <tbody id="m-body"><tr><td colspan="8" class="empty">No requests yet</td></tr></tbody>
1169
+ </table></div>
1170
+ </div>
1171
+ </div>
1172
+ </section>
1173
+
1174
+ <!-- ========== CACHE ========== -->
1175
+ <section class="view" id="v-cache">
1176
+ <div class="glass">
1177
+ <h3><span class="ln"></span>Cached Prompts<span class="badge" id="cache-shown">0</span></h3>
1178
+ <div class="tools">
1179
+ <div class="search"><input id="cache-q" placeholder="Filter by hash, model or prompt…" oninput="renderCache()"></div>
1180
+ <button class="btn" onclick="exportCache()">⬇ Export cache</button>
1181
+ </div>
1182
+ <div class="tbl-wrap" style="max-height:60vh"><table>
1183
+ <thead><tr><th data-sc="hash">Hash<span class="arr"></span></th><th data-sc="model">Model<span class="arr"></span></th><th data-sc="normalized_prompt">Normalized prompt<span class="arr"></span></th><th data-sc="access_count">Hits<span class="arr"></span></th><th data-sc="created_at">Created<span class="arr"></span></th></tr></thead>
1184
+ <tbody id="cache-body"><tr><td colspan="5" class="empty">Loading…</td></tr></tbody>
1185
+ </table></div>
1186
+ </div>
1187
+ </section>
1188
+
1189
+ <!-- ========== MODELS ========== -->
1190
+ <section class="view" id="v-models">
1191
+ <div class="glass">
1192
+ <h3><span class="ln"></span>Token Economy by Model<span class="badge">3D</span></h3>
1193
+ <div class="bars-stage"><canvas id="bars-mdl"></canvas><div class="chart-empty" id="chart-empty-mdl">No model traffic yet</div></div>
1194
+ <div class="chart-legend">
1195
+ <span><i style="background:#3dffa0;color:#00ffff"></i>Tokens saved</span>
1196
+ <span><i style="background:#5a6a85;color:#5a6a85"></i>Tokens spent</span>
1197
+ </div>
1198
+ <div class="chart-tools">
1199
+ <button class="hud-btn on" data-c="mdl" data-k="grid">Grid</button>
1200
+ <button class="hud-btn on" data-c="mdl" data-k="labels">Labels</button>
1201
+ <button class="hud-btn" data-c="mdl" data-k="spin">⟳ Spin</button>
1202
+ <button class="hud-btn" data-c="mdl" data-k="zin">+</button>
1203
+ <button class="hud-btn" data-c="mdl" data-k="zout">−</button>
1204
+ <button class="hud-btn" data-c="mdl" data-k="peak">◎ Peak</button>
1205
+ <button class="hud-btn" data-c="mdl" data-k="reset">Reset</button>
1206
+ <span class="sp"></span>
1207
+ <span class="hint">DRAG ORBIT · WHEEL ZOOM · SHIFT-DRAG PAN</span>
1208
+ </div>
1209
+ </div>
1210
+ <div class="grid g4" id="mdl-cards" style="margin-top:15px"></div>
1211
+ <div class="glass">
1212
+ <h3><span class="ln"></span>Per-model Breakdown</h3>
1213
+ <div class="tbl-wrap"><table>
1214
+ <thead><tr><th data-s="model">Model<span class="arr"></span></th><th data-s="requests">Requests<span class="arr"></span></th><th data-s="hits">Hits<span class="arr"></span></th><th data-s="spent">Tokens spent<span class="arr"></span></th><th data-s="saved">Tokens saved<span class="arr"></span></th><th>Economy</th></tr></thead>
1215
+ <tbody id="mdl-body2"><tr><td colspan="6" class="empty">No data</td></tr></tbody>
1216
+ </table></div>
1217
+ </div>
1218
+ </section>
1219
+ </main>
1220
+ </div>
1221
+
1222
+ <!-- ==================== HELP ==================== -->
1223
+ <div class="help-scrim" id="help-scrim" onclick="toggleHelp(false)"></div>
1224
+ <aside class="help-panel" id="help">
1225
+ <div class="help-head">
1226
+ <h2>IcyCache Help</h2>
1227
+ <button onclick="toggleHelp(false)" title="Close">✕</button>
1228
+ </div>
1229
+ <div class="help-body">
1230
+ <div class="help-sec">
1231
+ <h4>What is IcyCache</h4>
1232
+ <p>An <b>async HTTP proxy</b> that sits between your client and a local LLM. It normalizes the prompt, hashes it, and serves the stored answer <b>instantly</b> instead of burning CPU and tokens again.</p>
1233
+ <p>Everything stays on this machine — no cloud, no paid API.</p>
1234
+ </div>
1235
+ <div class="help-sec">
1236
+ <h4>Request Flow</h4>
1237
+ <div class="flow">
1238
+ <div><span>1.</span> Normalize prompt <em>→</em> NFKD + lowercase + 1114 rules</div>
1239
+ <div><span>2.</span> Hash <em>→</em> SHA256 of model + messages + temperature</div>
1240
+ <div><span>3.</span> HIT <em>→</em> stored answer, zero generation</div>
1241
+ <div><span>4.</span> MISS <em>→</em> forward upstream, then store the answer</div>
1242
+ </div>
1243
+ </div>
1244
+ <div class="help-sec">
1245
+ <h4>Reading the Metrics</h4>
1246
+ <div class="help-list">
1247
+ <div class="help-item"><span class="k">HR</span><span class="t"><b>Hit Rate</b>Requests answered from cache. Higher is better — every point is CPU you didn't pay for.</span></div>
1248
+ <div class="help-item"><span class="k">TK</span><span class="t"><b>Tokens Saved</b>Prompt + completion tokens skipped. This is the real money.</span></div>
1249
+ <div class="help-item"><span class="k">TK/s</span><span class="t"><b>Decode Speed</b>Upstream generation speed, measured on real requests only.</span></div>
1250
+ <div class="help-item"><span class="k">%</span><span class="t"><b>% Saved</b>Share of total tokens that never had to be produced.</span></div>
1251
+ <div class="help-item"><span class="k">BY</span><span class="t"><b>Bypass</b>Requests that skipped the cache entirely (non-deterministic or streaming).</span></div>
1252
+ </div>
1253
+ </div>
1254
+ <div class="help-sec">
1255
+ <h4>MCP Tools</h4>
1256
+ <div class="help-list" id="help-mcp">
1257
+ <div class="help-item"><span class="k">01</span><span class="t"><b>get_cache_stats</b>Entries, hit rate and tokens saved.</span></div>
1258
+ <div class="help-item"><span class="k">02</span><span class="t"><b>clear_cache</b>Wipe the cache (requires confirm).</span></div>
1259
+ <div class="help-item"><span class="k">03</span><span class="t"><b>normalize_preview</b>Show how a prompt would be normalized, without caching it.</span></div>
1260
+ <div class="help-item"><span class="k">04</span><span class="t"><b>search_cached_prompts</b>Full-text search across cached prompts.</span></div>
1261
+ </div>
1262
+ </div>
1263
+ <div class="help-sec">
1264
+ <h4>Keyboard Shortcuts</h4>
1265
+ <div class="help-list">
1266
+ <div class="help-item"><span class="k">1-4</span><span class="t">Switch view — Overview, Activity, Cache, Models</span></div>
1267
+ <div class="help-item"><span class="k">R</span><span class="t">Refresh all metrics now</span></div>
1268
+ <div class="help-item"><span class="k">E</span><span class="t">Export everything as JSON</span></div>
1269
+ <div class="help-item"><span class="k">H</span><span class="t">Toggle this help panel</span></div>
1270
+ <div class="help-item"><span class="k">S</span><span class="t">Pause / resume animations</span></div>
1271
+ <div class="help-item"><span class="k">Esc</span><span class="t">Close this panel</span></div>
1272
+ </div>
1273
+ </div>
1274
+ <div class="help-sec">
1275
+ <h4>Tips</h4>
1276
+ <p>Drag the <b>cache core globe</b> to spin it — it keeps its momentum. Hover the 3D charts for per-bucket detail. The <b>Live</b> ticker flashes every time the cache saves you tokens.</p>
1277
+ </div>
1278
+ </div>
1279
+ </aside>
1280
+
1281
+ <script>
1282
+ 'use strict';
1283
+ /* ==================================================================
1284
+ IcyCache Console v4 — neon dashboard
1285
+ ================================================================== */
1286
+ const $ = id => document.getElementById(id);
1287
+ const fmt = v => (v === null || v === undefined || v === '') ? '—' : Number(v).toLocaleString('en-US');
1288
+ /* Compact K/M notation: keeps the counter width stable so a growing token
1289
+ total never shoves the layout around (23 456 -> "23K"). */
1290
+ const trim1 = x => (Math.round(x * 10) / 10).toLocaleString('en-US');
1291
+ function fmtK(v){
1292
+ if (v === null || v === undefined || v === '') return '—';
1293
+ const n = Number(v);
1294
+ if (!isFinite(n)) return '—';
1295
+ const a = Math.abs(n);
1296
+ if (a >= 1e9) return trim1(n / 1e9) + 'B';
1297
+ if (a >= 1e6) return trim1(n / 1e6) + 'M';
1298
+ if (a >= 1e4) return Math.round(n / 1e3).toLocaleString('en-US') + 'K';
1299
+ return n.toLocaleString('en-US');
1300
+ }
1301
+ const esc = s => (s === null || s === undefined || s === '') ? '—' : String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
1302
+ // Le serveur renvoie deja l'heure LOCALE (SQLite datetime(ts,'localtime')).
1303
+ // Ne PAS ajouter 'Z' ici : l'heure locale serait reinterpretée comme UTC et
1304
+ // chaque evenement decale de -4h (EDT) / -5h (EST). Un 'Z' eventuel reste
1305
+ // traite comme de l'UTC.
1306
+ const fmtLocalTs = s => {
1307
+ if (!s) return '—';
1308
+ const str = String(s);
1309
+ const m = str.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z)?$/);
1310
+ if (!m) return str;
1311
+ const p = n => String(n).padStart(2, '0');
1312
+ if (m[7]) { // marqueur UTC -> heure locale du navigateur
1313
+ const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
1314
+ if (isNaN(d.getTime())) return str;
1315
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
1316
+ p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
1317
+ }
1318
+ return m[1] + '-' + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5] + ':' + m[6];
1319
+ };
1320
+ const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1321
+
1322
+ const S = {
1323
+ stats:null, metrics:null, series:null, status:null, cache:null,
1324
+ RANGE:'24h', refreshMs:5000, timer:null, paused:REDUCED,
1325
+ cacheSort:{k:'created_at',dir:-1}, mdlSort:{k:'requests',dir:-1},
1326
+ lastSaved:null, lastHitRate:null, spark:{hr:[],saved:[],tks:[]},
1327
+ tokTarget:0, tokShown:0, tokHist:[], tokPct:0, lastDelta:0,
1328
+ liveES:null, lastLiveAt:0
1329
+ };
1330
+
1331
+ /* ==================== TOOLTIP ==================== */
1332
+ const tip = $('tip');
1333
+ function showTipAt(x, y, html){
1334
+ tip.innerHTML = html; tip.style.opacity = 1;
1335
+ const w = tip.offsetWidth, h = tip.offsetHeight;
1336
+ tip.style.left = Math.min(innerWidth - w - 12, Math.max(8, x + 14)) + 'px';
1337
+ tip.style.top = Math.min(innerHeight - h - 12, Math.max(8, y + 14)) + 'px';
1338
+ }
1339
+ function hideTip(){ tip.style.opacity = 0; }
1340
+ document.addEventListener('mouseover', e => {
1341
+ const el = e.target.closest && e.target.closest('[data-tip]');
1342
+ if (el) showTipAt(e.clientX, e.clientY, el.dataset.tip); else if (!e.target.closest || !e.target.closest('#tip')) hideTip();
1343
+ });
1344
+ window.addEventListener('scroll', hideTip, true);
1345
+
1346
+ /* ==================== TOAST ==================== */
1347
+ let toastT;
1348
+ function toast(msg, err){
1349
+ const t = $('toast');
1350
+ t.textContent = msg; t.classList.toggle('err', !!err); t.classList.add('show');
1351
+ clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 2800);
1352
+ }
1353
+
1354
+ /* ==================== NAV ==================== */
1355
+ const TITLES = {overview:'Overview', activity:'Activity', cache:'Cache', models:'Models'};
1356
+ const VIEWS = ['overview','activity','cache','models'];
1357
+ function go(v){
1358
+ document.querySelectorAll('.nav-btn').forEach(x => x.classList.toggle('on', x.dataset.v === v));
1359
+ document.querySelectorAll('.view').forEach(s => s.classList.toggle('on', s.id === 'v-' + v));
1360
+ $('view-title').textContent = TITLES[v] || v;
1361
+ if (v === 'activity' || v === 'models') loadSeries();
1362
+ requestAnimationFrame(() => { RESIZE_ALL(); });
1363
+ }
1364
+ document.querySelectorAll('.nav-btn').forEach(b => b.onclick = () => go(b.dataset.v));
1365
+
1366
+ /* ==================== CLOCK ==================== */
1367
+ function tickClock(){ const d=new Date(); const p=n=>String(n).padStart(2,'0'); $('clock').textContent = d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+' '+p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds())+' (local)'; }
1368
+ setInterval(tickClock, 1000); tickClock();
1369
+
1370
+ /* ==================== ANIMATED NUMBERS + FLASH ==================== */
1371
+ function animNum(id, target, compact){
1372
+ const el = $(id); if (!el) return;
1373
+ const from = parseFloat(el.dataset.v || '0');
1374
+ el.dataset.v = target;
1375
+ const render = compact ? fmtK : fmt;
1376
+ const t0 = performance.now(), dur = 620;
1377
+ (function step(t){
1378
+ const p = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - p, 3);
1379
+ el.textContent = render(Math.round(from + (target - from) * e));
1380
+ if (p < 1) requestAnimationFrame(step);
1381
+ })(t0);
1382
+ }
1383
+ function flashCard(id){
1384
+ const el = $(id); if (!el) return;
1385
+ el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');
1386
+ setTimeout(() => el.classList.remove('flash'), 900);
1387
+ }
1388
+ const setMeter = (id, r) => { const el = $(id); if (el) el.style.width = Math.max(2, Math.min(100, r * 100)) + '%'; };
1389
+
1390
+ /* ==================== LIVE TICKER ==================== */
1391
+ /* The counter climbs in real time instead of jumping between refreshes. */
1392
+ function tickTokenCounter(dt){
1393
+ S.tokShown += (S.tokTarget - S.tokShown) * Math.min(1, dt * 3.2);
1394
+ const el = $('tc-val');
1395
+ if (!el) return;
1396
+ const now = Date.now();
1397
+ if (S._tcAt && now - S._tcAt < 90) return;
1398
+ S._tcAt = now;
1399
+ el.textContent = fmt(Math.round(S.tokShown));
1400
+ const hist = S.tokHist || [];
1401
+ let rate = null;
1402
+ for (let i = hist.length - 1; i >= 0; i--){
1403
+ const age = now - hist[i].t;
1404
+ if (age >= 4000){ rate = (S.tokTarget - hist[i].v) / (age / 1000); break; }
1405
+ }
1406
+ const r = $('tc-rate');
1407
+ if (r) r.textContent = rate === null ? '— /s' : '+' + fmt(Math.round(rate)) + ' tok/s';
1408
+ const bar = $('tc-bar');
1409
+ if (bar) bar.style.width = Math.max(2, Math.min(100, S.tokPct * 100)) + '%';
1410
+ const sub = $('tc-sub');
1411
+ if (sub && !S.lastDelta) sub.textContent = 'waiting for traffic…';
1412
+ }
1413
+ function pushTicker(text, cls){
1414
+ const track = $('tk-track');
1415
+ if (track.querySelector('.tk-empty')) track.innerHTML = '';
1416
+ const el = document.createElement('span');
1417
+ el.className = 'tk-item ' + (cls || '');
1418
+ el.textContent = text;
1419
+ track.prepend(el);
1420
+ while (track.children.length > 7) track.removeChild(track.lastChild);
1421
+ setTimeout(() => { el.style.transition = 'opacity .5s,transform .5s'; el.style.opacity = '0'; el.style.transform = 'translateY(-10px)'; }, 9000);
1422
+ setTimeout(() => el.remove(), 9600);
1423
+ }
1424
+
1425
+ /* ==================== SPARKLINE ==================== */
1426
+ function drawSpark(canvas, data, color){
1427
+ if (!canvas || !data || data.length < 2) return;
1428
+ const ctx = canvas.getContext('2d');
1429
+ const W = canvas.width, H = canvas.height;
1430
+ ctx.clearRect(0, 0, W, H);
1431
+ const mn = Math.min(...data), mx = Math.max(...data);
1432
+ const rng = (mx - mn) || 1;
1433
+ const step = W / (data.length - 1);
1434
+ const pt = i => [i * step, H - 4 - ((data[i] - mn) / rng) * (H - 9)];
1435
+ // area
1436
+ const grad = ctx.createLinearGradient(0, 0, 0, H);
1437
+ grad.addColorStop(0, color + '44'); grad.addColorStop(1, color + '00');
1438
+ ctx.beginPath(); ctx.moveTo(0, H);
1439
+ data.forEach((_, i) => ctx.lineTo(...pt(i)));
1440
+ ctx.lineTo(W, H); ctx.closePath(); ctx.fillStyle = grad; ctx.fill();
1441
+ // glow stroke x2
1442
+ ctx.beginPath(); data.forEach((_, i) => i ? ctx.lineTo(...pt(i)) : ctx.moveTo(...pt(i)));
1443
+ ctx.strokeStyle = color + '55'; ctx.lineWidth = 4; ctx.stroke();
1444
+ ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.stroke();
1445
+ // head
1446
+ const last = pt(data.length - 1);
1447
+ ctx.fillStyle = color; ctx.beginPath(); ctx.arc(last[0], last[1], 2.6, 0, 7); ctx.fill();
1448
+ ctx.globalAlpha = .35; ctx.beginPath(); ctx.arc(last[0], last[1], 6, 0, 7); ctx.fill(); ctx.globalAlpha = 1;
1449
+ }
1450
+
1451
+ /* ==================================================================
1452
+ MOTEUR 3D
1453
+ ------------------------------------------------------------------
1454
+ L'ancien moteur 2D (classes Globe / Bars3D / Loop3D) a ete retire :
1455
+ il n'etait plus instancie depuis le passage a IcyWebGL (un canvas ne
1456
+ peut porter qu'un seul contexte 2D ou WebGL). Tout le rendu passe
1457
+ maintenant par static/icywebgl.js : neurone humain, lemniscate 3D,
1458
+ histogrammes extrudes.
1459
+ ================================================================== */
1460
+
1461
+ /* =========================================================================
1462
+ INSTANCES
1463
+ ================================================================== */
1464
+ /* 100% Three.js : les trois canvas visibles (#globe/#ring3d/#bars3d) et les
1465
+ deux canvas des onglets Activity / Models sont tous pilotes par IcyWebGL.
1466
+ Un canvas ne peut porter qu'un seul contexte (2D ou WebGL) : les anciens
1467
+ rendus 2D (Globe / Loop3D / Bars3D) ne sont donc plus instancies, et la
1468
+ donnee qu'ils portaient est desormais servie par les modules WebGL. */
1469
+ const CHART_OV = IcyWebGL.chart($('bars3d'), {maxBars:38, showFloor:true, savedLine:true});
1470
+ const CHART_ACT = IcyWebGL.chart($('bars-act'), {maxBars:44, showFloor:true, savedLine:true});
1471
+ const CHART_MDL = IcyWebGL.chart($('bars-mdl'), {maxBars:12, showFloor:true, savedLine:false, labelMode:'raw', maxBarW:0.55,
1472
+ colorMap: {hits: 0x00ffff, misses: 0x6b7a99, bypass: 0x50b4ff}});
1473
+ const RENDERERS = [CHART_OV, CHART_ACT, CHART_MDL];
1474
+
1475
+ /* Exposes au controleur d'options (data-c="ov|act|mdl"). */
1476
+ window._chartOV = CHART_OV;
1477
+ window._chartACT = CHART_ACT;
1478
+ window._chartMDL = CHART_MDL;
1479
+
1480
+ /* Les deux scenes "vivantes" du bandeau principal. Un seul contexte WebGL
1481
+ par canvas : c'est pourquoi elles sont creees ici, une fois pour toutes,
1482
+ et non dans un script de boot tardif. */
1483
+ window._neuron3D = IcyWebGL.neuron($('globe'));
1484
+ window._loop3D = IcyWebGL.loop($('ring3d'));
1485
+ window._chart3D = CHART_OV; // alias : le graphe 3D de l'Overview
1486
+
1487
+ /* Le survol d'un noeud alimente l'inspecteur de detail. */
1488
+ window._neuron3D.onHover(n => showNodeInfo(n));
1489
+
1490
+ function RESIZE_ALL(){
1491
+ for (const r of RENDERERS) if (r.resize) r.resize();
1492
+ if (window._neuron3D && window._neuron3D.resize) window._neuron3D.resize();
1493
+ if (window._loop3D && window._loop3D.resize) window._loop3D.resize();
1494
+ }
1495
+ window.addEventListener('resize', RESIZE_ALL);
1496
+
1497
+ let lastFrame = performance.now();
1498
+ function frame(now){
1499
+ const dt = Math.min(0.05, (now - lastFrame) / 1000);
1500
+ lastFrame = now;
1501
+ tickTokenCounter(dt);
1502
+ if (!S.paused){
1503
+ const view = document.querySelector('.view.on');
1504
+ const v = view ? view.id.replace('v-', '') : 'overview';
1505
+ if (v === 'overview'){ /* 3D canvases driven by IcyWebGL */ syncLoopStages(); }
1506
+ /* CHART_ACT / CHART_MDL s'animent eux-memes (rAF interne) et se mettent
1507
+ en pause quand leur onglet est masque : rien a piloter ici. */
1508
+ }
1509
+ requestAnimationFrame(frame);
1510
+ }
1511
+ requestAnimationFrame(frame);
1512
+
1513
+ /* ==================== BACKGROUND ==================== */
1514
+ (() => {
1515
+ const cv = $('bgfx'), ctx = cv.getContext('2d');
1516
+ let W, H; const nodes = [];
1517
+ function resize(){ W = cv.width = innerWidth; H = cv.height = innerHeight; }
1518
+ resize(); addEventListener('resize', resize);
1519
+ for (let i = 0; i < 46; i++) nodes.push({x:Math.random()*innerWidth, y:Math.random()*innerHeight, vx:(Math.random()-.5)*.32, vy:(Math.random()-.5)*.32});
1520
+ (function draw(){
1521
+ ctx.clearRect(0, 0, W, H);
1522
+ if (!S.paused){
1523
+ for (const n of nodes){
1524
+ n.x += n.vx; n.y += n.vy;
1525
+ if (n.x < 0 || n.x > W) n.vx *= -1;
1526
+ if (n.y < 0 || n.y > H) n.vy *= -1;
1527
+ ctx.fillStyle = 'rgba(0,255,255,.34)';
1528
+ ctx.beginPath(); ctx.arc(n.x, n.y, 1.5, 0, 7); ctx.fill();
1529
+ }
1530
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++){
1531
+ const a = nodes[i], b = nodes[j], d = Math.hypot(a.x - b.x, a.y - b.y);
1532
+ if (d < 185){ ctx.strokeStyle = 'rgba(0,255,255,' + ((1 - d / 185) * .11).toFixed(3) + ')'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); }
1533
+ }
1534
+ }
1535
+ requestAnimationFrame(draw);
1536
+ })();
1537
+ })();
1538
+
1539
+ /* ==================== DATA LOADERS ==================== */
1540
+ /* The API only returns buckets that actually contain events. Rebuild a
1541
+ continuous timeline so the 3D chart shows a real time axis, not 3 huge blocks. */
1542
+ const pad2 = n => (n < 10 ? '0' + n : '' + n);
1543
+ function fillSeries(points, range){
1544
+ const cfg = {
1545
+ '1h': {n:60, step:60000, key:'min'},
1546
+ '24h':{n:24, step:3600000, key:'hour'},
1547
+ '7j': {n:168,step:3600000, key:'hour'}
1548
+ }[range];
1549
+ if (!cfg) return points || [];
1550
+ const byKey = new Map();
1551
+ for (const p of (points || [])) byKey.set(p.t, p);
1552
+ const now = new Date();
1553
+ now.setSeconds(0, 0);
1554
+ if (cfg.key === 'hour') now.setMinutes(0, 0);
1555
+ /* La fenetre API couvre [now - hours, now] : elle contient `hours` seaux
1556
+ COMPLETS plus le seau courant (partiel). Rebuilder exactement `hours`
1557
+ seaux en demarrant au seau now-hours+1 faisait perdre le seau le plus
1558
+ ancien - celui qui porte le trafic quand la fenetre glisse - et le
1559
+ graphique affichait "no traffic" alors que la table, elle, montrait des
1560
+ requetes. On parcourt donc cfg.n + 1 seaux. */
1561
+ const out = [];
1562
+ for (let i = cfg.n; i >= 0; i--){
1563
+ const d = new Date(now.getTime() - i * cfg.step);
1564
+ const key = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
1565
+ + ' ' + pad2(d.getHours()) + (cfg.key === 'min' ? ':' + pad2(d.getMinutes()) : ':00');
1566
+ const p = byKey.get(key);
1567
+ out.push({
1568
+ t:key,
1569
+ hits:(p && p.hits) || 0,
1570
+ misses:(p && p.misses) || 0,
1571
+ bypass:(p && p.bypass) || 0,
1572
+ saved:(p && p.saved) || 0,
1573
+ spent:(p && p.spent) || 0
1574
+ });
1575
+ }
1576
+ return out;
1577
+ }
1578
+
1579
+ /* Feed the neural core with the real cache: one node per cached prompt. */
1580
+ function renderGraph(){
1581
+ const items = (S.cache || []).map(p => ({
1582
+ label: ((p.normalized_prompt || '').replace(/\s+/g, ' ').trim() || p.hash || '').slice(0, 46),
1583
+ model: p.model || 'unknown',
1584
+ hits: p.access_count || 0
1585
+ }));
1586
+ S.graphItems = items;
1587
+
1588
+ const core = window._neuron3D;
1589
+ if (core) core.setGraph(items);
1590
+
1591
+ /* La legende vient du module 3D : elle utilise la palette reellement
1592
+ appliquee aux noeuds, donc legende et scene ne peuvent pas diverger. */
1593
+ const L = (core && core.legend) || [];
1594
+ const el = $('globe-legend');
1595
+ if (el){
1596
+ el.innerHTML = L.length
1597
+ ? L.map(l => '<span><i style="background:rgb(' + l.col.join(',') + ');color:rgb(' + l.col.join(',') + ')"></i>'
1598
+ + esc(l.model) + ' <b>' + l.n + '</b></span>').join('')
1599
+ : '<span style="color:var(--dim)">no cache entries yet</span>';
1600
+ }
1601
+ const nodes = $('hud-nodes');
1602
+ if (nodes) nodes.textContent = (core ? core.nodeCount : items.length) + ' NODES';
1603
+ const syn = $('hud-syn');
1604
+ if (syn) syn.textContent = (core ? core.edgeCount : 0) + ' SYNAPSES';
1605
+ const dots = $('tile-dots');
1606
+ if (dots){
1607
+ dots.innerHTML = L.map(l =>
1608
+ '<span><i style="background:rgb(' + l.col.join(',') + ');color:rgb(' + l.col.join(',') + ')"></i>'
1609
+ + esc(l.model) + ' <b>' + l.n + '</b></span>').join('');
1610
+ }
1611
+ const esub = $('entries-sub');
1612
+ if (esub) esub.textContent = items.length + ' nodes wired into the neuron';
1613
+ showNodeInfo(null);
1614
+
1615
+ /* La cellule GRANDIT avec le cache : plus d'entrees, cellule plus
1616
+ developpee. Le taux de hit ajoute un regain de metabolisme. */
1617
+ if (core){
1618
+ const hr = (S.metrics && S.metrics.hit_rate_pct) || 0;
1619
+ const target = 0.72 + Math.min(0.8, items.length * 0.035) + (hr / 100) * 0.28;
1620
+ core.setVitality(target);
1621
+ }
1622
+ renderCellStats();
1623
+ }
1624
+
1625
+ /* Mini menu floating over the graph: what is under the cursor. */
1626
+ function showNodeInfo(n){
1627
+ const dot = $('nm-dot'), name = $('nm-name'), sub = $('nm-sub'), grid = $('nm-grid');
1628
+ const tag = document.querySelector('#node-menu .ins-tag');
1629
+ if (!dot) return;
1630
+ const core = window._neuron3D;
1631
+ if (!n){
1632
+ dot.style.background = 'var(--cyan)';
1633
+ name.textContent = 'Human Neuron';
1634
+ sub.textContent = 'dendrites · myelinated axon · synaptic boutons';
1635
+ if (tag) tag.textContent = 'soma';
1636
+ const pool = core ? core.nodeCount : 0;
1637
+ const syn = core ? core.edgeCount : 0;
1638
+ grid.innerHTML = '<div><span>nodes</span><b>' + pool + '</b></div>'
1639
+ + '<div><span>synapses</span><b>' + syn + '</b></div>'
1640
+ + '<div><span>labelled</span><b>' + (core && core.labels ? core.labels.length : 0) + '</b></div>'
1641
+ + '<div><span>cell</span><b>soma + dendrites</b></div>'
1642
+ + '<div><span>axon</span><b>myelinated</b></div>';
1643
+ return;
1644
+ }
1645
+ const c = n.color || 0x00ffff;
1646
+ dot.style.background = 'rgb(' + ((c >> 16) & 255) + ',' + ((c >> 8) & 255) + ',' + (c & 255) + ')';
1647
+ name.textContent = n.model || 'node';
1648
+ sub.textContent = n.label || '—';
1649
+ if (tag) tag.textContent = 'synapse';
1650
+ const p = n.pos;
1651
+ const rad = p ? Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z) : 0;
1652
+ grid.innerHTML = '<div><span>cache hits</span><b>' + fmt(n.hits || 0) + '</b></div>'
1653
+ + '<div><span>model</span><b>' + esc(n.model || '—') + '</b></div>'
1654
+ + '<div><span>distance to soma</span><b>' + rad.toFixed(2) + ' R</b></div>'
1655
+ + '<div><span>link</span><b>synapse</b></div>'
1656
+ + '<div><span>state</span><b>cached</b></div>';
1657
+ }
1658
+
1659
+ /* Graph controls — bridge to the IcyWebGL human-neuron module */
1660
+ function syncGraphButtons(){
1661
+ const n = window._neuron3D;
1662
+ document.querySelectorAll('.gctl .hud-btn').forEach(b => {
1663
+ const g = b.dataset.g;
1664
+ if (!n) return;
1665
+ if (g === 'spin') b.classList.toggle('on', n.autoSpin);
1666
+ if (g === 'labels') b.classList.toggle('on', n.showLabels);
1667
+ if (g === 'synapses') b.classList.toggle('on', n.showSynapses);
1668
+ if (g === 'spines') b.classList.toggle('on', n.showSpines);
1669
+ if (g === 'myelin') b.classList.toggle('on', n.showMyelin);
1670
+ });
1671
+ const chip = $('hud-spin');
1672
+ if (chip && n) chip.textContent = n.autoSpin ? 'SPIN 0.10 rad/s' : 'SPIN PAUSED';
1673
+ const syn = $('hud-syn');
1674
+ if (syn && n) syn.textContent = n.edgeCount + ' SYNAPSES';
1675
+ const nod = $('hud-nodes');
1676
+ if (nod && n) nod.textContent = n.nodeCount + ' NODES';
1677
+ }
1678
+
1679
+ function initGraphControls(){
1680
+ document.querySelectorAll('.gctl .hud-btn').forEach(b => {
1681
+ b.addEventListener('click', () => {
1682
+ const g = b.dataset.g;
1683
+ const n = window._neuron3D;
1684
+ if (!n) return;
1685
+ if (g === 'spin'){ n.autoSpin = !n.autoSpin; }
1686
+ else if (g === 'labels'){ n.showLabels = !n.showLabels; }
1687
+ else if (g === 'synapses'){ n.showSynapses = !n.showSynapses; }
1688
+ else if (g === 'spines'){ n.showSpines = !n.showSpines; }
1689
+ else if (g === 'myelin'){ n.showMyelin = !n.showMyelin; }
1690
+ else if (g === 'pulse'){ n.fireSoma(); toast('Action potential fired'); }
1691
+ else if (g === 'replay'){
1692
+ const evs = (S.metrics && S.metrics.recent) || [];
1693
+ if (!evs.length){ toast('No traffic to replay yet', true); return; }
1694
+ n.replay(evs);
1695
+ toast('Replaying ' + Math.min(24, evs.length) + ' requests through the cell');
1696
+ }
1697
+ else if (g === 'center'){ n.resetOrbit(); n.zoom = 1; }
1698
+ else if (g === 'zin'){ n.zoom = Math.min(2.6, n.zoom * 1.16); }
1699
+ else if (g === 'zout'){ n.zoom = Math.max(0.45, n.zoom / 1.16); }
1700
+ else if (g === 'reset'){ n.resetLayout(); n.autoSpin = true; n.resetOrbit(); n.zoom = 1; }
1701
+ syncGraphButtons();
1702
+ });
1703
+ });
1704
+ syncGraphButtons();
1705
+ }
1706
+
1707
+ /* Fiche anatomique : taille de la cellule, dendrites, epines, myeline...
1708
+ C'est l'info detaillee du panneau neurone. */
1709
+ function renderCellStats(){
1710
+ const el = $('cell-stats');
1711
+ const core = window._neuron3D;
1712
+ if (!el || !core || !core.stats) return;
1713
+ const s = core.stats;
1714
+ const vit = core._vitality || 1;
1715
+ const pct = Math.round((vit - 0.55) / (1.9 - 0.55) * 100);
1716
+ el.innerHTML =
1717
+ '<div><span>soma Ø</span><b>' + s.somaDiameter + ' u</b></div>' +
1718
+ '<div><span>vitality</span><b class="g">' + (vit * 100).toFixed(0) + '%</b></div>' +
1719
+ '<div><span>dendrites</span><b class="b">' + s.dendrites + '</b></div>' +
1720
+ '<div><span>branches</span><b>' + s.branches + '</b></div>' +
1721
+ '<div><span>epines</span><b class="b">' + s.spines + '</b></div>' +
1722
+ '<div><span>myeline</span><b class="y">' + s.myelin + '</b></div>' +
1723
+ '<div><span>terminaux</span><b class="y">' + s.terminals + '</b></div>' +
1724
+ '<div><span>synapses</span><b class="r">' + (core.edgeCount || 0) + '</b></div>' +
1725
+ '<div class="vitality">CELL GROWTH · driven by cache size' +
1726
+ '<div class="vitality-track"><i style="width:' + pct + '%"></i></div>' +
1727
+ '</div>';
1728
+ }
1729
+
1730
+ /* ==================== AGENT LOOP CONTROLS ==================== */
1731
+ /* Bridge to the IcyWebGL loop module (3D lemniscate of Gerono) */
1732
+ const LOOP_SHAPE_LABEL = {
1733
+ 'loop': '∞ LOOP',
1734
+ 'torus': '◎ TORUS',
1735
+ 'helix': '≋ HELIX',
1736
+ 'trefoil': '☘ TREFOIL'
1737
+ };
1738
+ const LOOP_SHAPE_BADGE = {
1739
+ 'loop': '∞ LEMNISCATE',
1740
+ 'torus': '3D TORUS',
1741
+ 'helix': 'DOUBLE HELIX',
1742
+ 'trefoil': 'TREFOIL KNOT'
1743
+ };
1744
+
1745
+ function initLoopControls(){
1746
+ const hud = $('loop-hud');
1747
+ if (!hud) return;
1748
+ const shapeBtn = hud.querySelector('[data-lp="shape"]');
1749
+ const flightBtn = hud.querySelector('[data-lp="flight"]');
1750
+ const boltBtn = hud.querySelector('[data-lp="bolt"]');
1751
+ const spinBtn = hud.querySelector('[data-lp="spin"]');
1752
+ const badge = $('ring-badge');
1753
+
1754
+ hud.addEventListener('click', e => {
1755
+ const b = e.target.closest('[data-lp]');
1756
+ if (!b) return;
1757
+ const k = b.dataset.lp;
1758
+ const n = window._loop3D;
1759
+ if (!n) return;
1760
+ if (k === 'shape'){
1761
+ const shapes = IcyWebGL.SHAPES || ['loop', 'torus', 'helix', 'trefoil'];
1762
+ const next = shapes[(shapes.indexOf(n.shape) + 1) % shapes.length];
1763
+ n.setShape(next);
1764
+ if (shapeBtn) shapeBtn.textContent = LOOP_SHAPE_LABEL[next] || next.toUpperCase();
1765
+ if (badge) badge.textContent = LOOP_SHAPE_BADGE[next] || next.toUpperCase();
1766
+ }
1767
+ else if (k === 'run'){
1768
+ n.runPipeline();
1769
+ toast('Pipeline: PROMPT → NORMALIZE → KEY → CACHE → SERVE');
1770
+ }
1771
+ else if (k === 'flight'){
1772
+ const on = !n.flight;
1773
+ n.setFlight(on);
1774
+ flightBtn.classList.toggle('on', on);
1775
+ flightBtn.textContent = on ? '◈ FLIGHT' : 'FLIGHT';
1776
+ if (badge && on) badge.textContent = 'NAVIGATING';
1777
+ }
1778
+ else if (k === 'bolt'){
1779
+ const next = ((n.boltLevel || 0) + 1) % 4;
1780
+ n.setBolts(next);
1781
+ boltBtn.classList.toggle('on', next > 0);
1782
+ boltBtn.textContent = next === 0 ? 'BOLTS' : (next === 1 ? '⚡ BOLTS' : (next === 2 ? '⚡ STORM' : '⚡ BLAZE'));
1783
+ }
1784
+ else if (k === 'spin'){
1785
+ n.autoSpin = !n.autoSpin;
1786
+ n.manual = !n.autoSpin;
1787
+ spinBtn.classList.toggle('on', n.autoSpin);
1788
+ }
1789
+ else if (k === 'thin'){ n.setThick(Math.max(0.5, +(n.thick - 0.2).toFixed(2))); }
1790
+ else if (k === 'thick'){ n.setThick(Math.min(2.2, +(n.thick + 0.2).toFixed(2))); }
1791
+ else if (k === 'slower'){ n.setSpeed(Math.max(0.2, +(n.speed * 0.6).toFixed(2))); toast('Loop speed ' + n.speed + '×'); }
1792
+ else if (k === 'faster'){ n.setSpeed(Math.min(4, +(n.speed * 1.6).toFixed(2))); toast('Loop speed ' + n.speed + '×'); }
1793
+ else if (k === 'reset'){
1794
+ /* Vrai reset : forme, eclairs, epaisseur, vitesse, vol et cadrage. */
1795
+ n.setShape('loop');
1796
+ n.setBolts(2);
1797
+ n.setThick(1);
1798
+ n.setSpeed(1);
1799
+ n.setFlight(false);
1800
+ n.resetOrbit();
1801
+ n.zoom = 1;
1802
+ flightBtn.classList.remove('on'); flightBtn.textContent = 'FLIGHT';
1803
+ boltBtn.classList.add('on'); boltBtn.textContent = '⚡ STORM';
1804
+ spinBtn.classList.add('on');
1805
+ if (shapeBtn) shapeBtn.textContent = LOOP_SHAPE_LABEL['loop'];
1806
+ if (badge) badge.textContent = LOOP_SHAPE_BADGE['loop'];
1807
+ toast('Agent loop reset');
1808
+ }
1809
+ });
1810
+
1811
+ const el = $('loop-stages');
1812
+ if (el && !el.children.length)
1813
+ el.innerHTML = IcyWebGL.STAGES.map(s => '<span>' + esc(s) + '</span>').join('');
1814
+ }
1815
+
1816
+ /* ==================== 3D CHART CONTROLS ==================== */
1817
+ /* Every chart is a real 3D scene: orbit / zoom / pan + its own switches. */
1818
+ const CHARTS = {};
1819
+ function initChartControls(){
1820
+ document.querySelectorAll('.chart-tools .hud-btn').forEach(b => {
1821
+ b.addEventListener('click', () => {
1822
+ const key = b.dataset.k;
1823
+ const c = window[b.dataset.c === 'ov' ? '_chartOV' : (b.dataset.c === 'act' ? '_chartACT' : '_chartMDL')];
1824
+ if (!c) return;
1825
+ if (key === 'grid') c.showGrid = !c.showGrid;
1826
+ else if (key === 'labels') c.showLabels = !c.showLabels;
1827
+ else if (key === 'ribbon') c.savedLine = !c.savedLine;
1828
+ else if (key === 'spin') c.autoSpin = !c.autoSpin;
1829
+ else if (key === 'zin') c.zoom = Math.min(2.4, c.zoom * 1.16);
1830
+ else if (key === 'zout') c.zoom = Math.max(0.5, c.zoom / 1.16);
1831
+ else if (key === 'peak'){
1832
+ const pt = c.focusPeak();
1833
+ if (pt) toast('Peak bucket · ' + (pt.t || '') + ' — ' +
1834
+ ((pt.hits || 0) + (pt.misses || 0) + (pt.bypass || 0)) + ' requests');
1835
+ else toast('No traffic to focus on', true);
1836
+ }
1837
+ else if (key === 'reset'){ c.resetOrbit(); c.zoom = 1; c.autoSpin = false; }
1838
+ syncChartButtons();
1839
+ });
1840
+ });
1841
+ syncChartButtons();
1842
+ }
1843
+
1844
+ /* Survol des barres 3D : on affiche le detail du seau (hits / misses /
1845
+ bypass / tokens) dans une bulle, ancree sur la barre. */
1846
+ function initChartHover(chart, canvasId){
1847
+ if (!chart) return;
1848
+ const cv = $(canvasId);
1849
+ if (!cv) return;
1850
+ let mx = 0, my = 0;
1851
+ cv.addEventListener('pointermove', e => {
1852
+ const r = cv.getBoundingClientRect();
1853
+ mx = e.clientX; my = e.clientY;
1854
+ });
1855
+ chart.onHover(pt => {
1856
+ if (!pt){ hideTip(); return; }
1857
+ const r = cv.getBoundingClientRect();
1858
+ const tot = (pt.hits || 0) + (pt.misses || 0) + (pt.bypass || 0);
1859
+ showTipAt(mx || (r.left + r.width / 2), my || (r.top + 10),
1860
+ '<b>' + esc(pt.t || '') + '</b>' +
1861
+ '<div style="margin-top:5px"><span style="color:var(--green)">hits</span> ' + fmt(pt.hits || 0) + '</div>' +
1862
+ '<div><span style="color:var(--red)">misses</span> ' + fmt(pt.misses || 0) + '</div>' +
1863
+ '<div><span style="color:var(--blue)">bypass</span> ' + fmt(pt.bypass || 0) + '</div>' +
1864
+ '<div><span style="color:var(--gold)">saved</span> ' + fmt(pt.saved || 0) + ' tok</div>' +
1865
+ '<div style="margin-top:4px;color:var(--muted)">total ' + fmt(tot) + ' requests</div>');
1866
+ });
1867
+ }
1868
+
1869
+ function syncChartButtons(){
1870
+ document.querySelectorAll('.chart-tools .hud-btn').forEach(b => {
1871
+ const key = b.dataset.k;
1872
+ const c = window[b.dataset.c === 'ov' ? '_chartOV' : (b.dataset.c === 'act' ? '_chartACT' : '_chartMDL')];
1873
+ if (!c) return;
1874
+ if (key === 'grid') b.classList.toggle('on', !!c.showGrid);
1875
+ if (key === 'labels') b.classList.toggle('on', !!c.showLabels);
1876
+ if (key === 'ribbon') b.classList.toggle('on', !!c.savedLine);
1877
+ if (key === 'spin') b.classList.toggle('on', !!c.autoSpin);
1878
+ });
1879
+ }
1880
+
1881
+ /* the station chips follow the charge head around the loop */
1882
+ function syncLoopStages(){
1883
+ const n = window._loop3D;
1884
+ if (!n) return;
1885
+ const el = $('loop-stages');
1886
+ if (!el || !el.children.length) return;
1887
+ const a = n.activeStage;
1888
+ if (a === n._stageShown) return;
1889
+ n._stageShown = a;
1890
+ for (let i = 0; i < el.children.length; i++) el.children[i].classList.toggle('on', i === a);
1891
+ }
1892
+
1893
+ async function loadStats(){
1894
+ try{
1895
+ const d = await (await fetch('/api/dashboard-stats')).json();
1896
+ S.stats = d;
1897
+ const prev = $('entries').dataset.v;
1898
+ animNum('entries', d.stats.total_entries);
1899
+ $('n-entries').textContent = d.stats.total_entries;
1900
+ $('r-entries').textContent = fmt(d.stats.total_entries);
1901
+ $('r-entries-s').textContent = fmt(d.stats.total_hits) + ' cache hits · ' + (d.stats.by_provider ? Object.keys(d.stats.by_provider).length : 0) + ' provider(s)';
1902
+ setMeter('bar-entries', Math.min(1, d.stats.total_entries / 80));
1903
+ if (prev !== undefined && Number(prev) !== d.stats.total_entries) flashCard('card-entries');
1904
+ $('provider').textContent = d.target_provider;
1905
+ S.cache = d.prompts || [];
1906
+ renderCache();
1907
+ renderGraph();
1908
+ }catch(e){ console.error('stats', e); }
1909
+ }
1910
+
1911
+ /* IcyCache metrics -> 3D graphs bridges */
1912
+ function emitIcyMetrics(d){ window.dispatchEvent(new CustomEvent('icymetrics',{detail:d})); }
1913
+ function emitIcyEvent(k){ window.dispatchEvent(new CustomEvent('icyevent',{detail:{type:k}})); }
1914
+
1915
+ async function loadMetrics(){
1916
+ try{
1917
+ const m = await (await fetch('/api/metrics')).json();
1918
+ applyMetrics(m);
1919
+ }catch(e){ console.error('metrics', e); }
1920
+ }
1921
+
1922
+ /* Applique un instantane de metriques a l'UI. Appele par le polling ET par
1923
+ le flux SSE /api/live : une seule definition, donc le temps reel et le
1924
+ refresh manuel ne peuvent pas diverger. */
1925
+ function applyMetrics(m){
1926
+ if (!m) return;
1927
+ S.metrics = m;
1928
+ const saved = (m.tokens_in_saved || 0) + (m.tokens_out_saved || 0);
1929
+ const spent = (m.tokens_in_spent || 0) + (m.tokens_out_spent || 0);
1930
+
1931
+ animNum('saved-in', m.tokens_in_saved, true);
1932
+ $('saved-in').textContent = fmt(m.tokens_in_saved);
1933
+ animNum('saved-out', m.tokens_out_saved, true);
1934
+ $('saved-out').textContent = fmt(m.tokens_out_saved);
1935
+ animNum('saved-total', saved, true);
1936
+ $('saved-sub').textContent = 'in ' + fmt(m.tokens_in_saved) + ' · out ' + fmt(m.tokens_out_saved);
1937
+ setMeter('bar-saved', saved / Math.max(1, saved + spent));
1938
+ /* live counter next to the gauge */
1939
+ S.tokTarget = saved;
1940
+ S.tokPct = saved / Math.max(1, saved + spent);
1941
+ S.tokHist.push({ t:Date.now(), v:saved });
1942
+ if (S.tokHist.length > 60) S.tokHist.shift();
1943
+
1944
+ animNum('m-tks', m.tks_decode_avg || 0);
1945
+ $('m-tks-s').textContent = 'last ' + fmt(m.tks_decode_last) + ' · e2e ' + fmt(m.tks_avg);
1946
+ setMeter('bar-tks', (m.tks_decode_avg || 0) / 50);
1947
+
1948
+ animNum('m-pct', m.pct_saved || 0);
1949
+ $('m-pct-s').textContent = 'hit rate ' + m.hit_rate_pct + '% · ' + fmt(m.hits) + '/' + fmt(m.requests);
1950
+ setMeter('bar-pct', (m.pct_saved || 0) / 100);
1951
+
1952
+ animNum('m-in', m.tokens_in_spent, true); $('m-in-s').textContent = 'saved ' + fmt(m.tokens_in_saved);
1953
+ animNum('m-out', m.tokens_out_spent, true); $('m-out-s').textContent = 'saved ' + fmt(m.tokens_out_saved);
1954
+
1955
+ $('ov-hr').textContent = (m.hit_rate_pct ?? '—') + '%';
1956
+ $('ov-req').textContent = fmt(m.requests);
1957
+ $('ov-hits').textContent = fmt(m.hits);
1958
+ $('ov-miss').textContent = fmt(m.misses);
1959
+ $('n-req').textContent = fmt(m.requests);
1960
+
1961
+ // the agent loop gauge
1962
+ $('ring-val').textContent = (m.hit_rate_pct || 0).toFixed(1) + '%';
1963
+
1964
+ // legend (real counts, no more phantom bypass)
1965
+ $('lg-hits').textContent = fmt(m.hits);
1966
+ $('lg-miss').textContent = fmt(m.misses);
1967
+ $('lg-bypass').textContent = fmt(m.bypass);
1968
+ $('lg-note').textContent = m.events_without_usage
1969
+ ? m.events_without_usage + ' events without usage data'
1970
+ : 'All ' + fmt(m.requests) + ' events carry usage data';
1971
+
1972
+ // sparklines
1973
+ S.spark.hr.push(m.hit_rate_pct || 0); if (S.spark.hr.length > 40) S.spark.hr.shift();
1974
+ S.spark.saved.push(saved); if (S.spark.saved.length > 40) S.spark.saved.shift();
1975
+ S.spark.tks.push(m.tks_decode_avg || 0); if (S.spark.tks.length > 40) S.spark.tks.shift();
1976
+ drawSpark($('sp-hr'), S.spark.hr, '#00ffff');
1977
+ drawSpark($('sp-saved'), S.spark.saved, '#00ffff');
1978
+ drawSpark($('sp-tks'), S.spark.tks, '#ffb400');
1979
+
1980
+ $('r-hr').textContent = (m.hit_rate_pct ?? '—') + '%';
1981
+ $('r-saved').textContent = fmtK(saved);
1982
+ $('r-tks').textContent = fmt(m.tks_decode_avg);
1983
+
1984
+ // live ticker + neuron firing on new savings
1985
+ if (S.lastSaved !== null && saved > S.lastSaved){
1986
+ const delta = saved - S.lastSaved;
1987
+ pushTicker('⚡ SAVED ' + fmt(delta) + ' TOKENS', '');
1988
+ flashCard('card-saved');
1989
+ if (window._neuron3D){ window._neuron3D.pulse(0x00ffff); window._neuron3D.fireSoma(); }
1990
+ $('hud-spin').textContent = 'HIT! NEURON FIRING';
1991
+ setTimeout(() => { $('hud-spin').textContent = 'SPIN 0.10 rad/s'; }, 1400);
1992
+ const led = $('save-led');
1993
+ if (led){ led.classList.add('hit'); setTimeout(() => led.classList.remove('hit'), 1300); }
1994
+ const live = $('saved-live');
1995
+ if (live){
1996
+ live.classList.remove('idle');
1997
+ live.textContent = '⚡ last save +' + fmt(delta) + ' tokens';
1998
+ }
1999
+ S.lastDelta = delta;
2000
+ const tcSub = $('tc-sub');
2001
+ if (tcSub){
2002
+ tcSub.classList.add('hit');
2003
+ tcSub.textContent = 'last save +' + fmt(delta) + ' tok';
2004
+ }
2005
+ }
2006
+ /* clear idle state if we already have saved tokens (initial load) */
2007
+ if (saved > 0) {
2008
+ const live = $('saved-live');
2009
+ if (live && live.classList.contains('idle')) {
2010
+ live.classList.remove('idle');
2011
+ live.textContent = '⚡ saved ' + fmt(saved) + ' tokens';
2012
+ }
2013
+ }
2014
+ if (S.lastHitRate !== null && m.hit_rate_pct > S.lastHitRate){
2015
+ if (window._neuron3D) window._neuron3D.pulse(0x00ffff);
2016
+ pushTicker('● CACHE HIT · ' + m.hit_rate_pct + '% HIT RATE', 'miss');
2017
+ }
2018
+ /* -- ICYCACHE WEBGL : pousse les METRIQUES REELLES vers les 3 graphes 3D -- */
2019
+ emitIcyMetrics({
2020
+ hits: m.hits||0, misses: m.misses||0, bypass: m.bypass||0,
2021
+ total: m.requests||0, hitRate: (m.hit_rate_pct||0)/100,
2022
+ tokensSaved: saved,
2023
+ entries: (S.stats && S.stats.stats ? S.stats.stats.total_entries : 0)||0,
2024
+ avgMs: m.tks_avg||0, lastMs: m.tks_decode_last||0
2025
+ });
2026
+ if (S.lastHits !== undefined){
2027
+ const dh = (m.hits||0) - S.lastHits, dm = (m.misses||0) - S.lastMisses;
2028
+ for (let i=0;i<Math.min(dh,12);i++) emitIcyEvent('hit');
2029
+ for (let i=0;i<Math.min(dm,12);i++) emitIcyEvent('miss');
2030
+ }
2031
+ S.lastHits = m.hits||0; S.lastMisses = m.misses||0;
2032
+ S.lastSaved = saved; S.lastHitRate = m.hit_rate_pct;
2033
+
2034
+ // recent table
2035
+ const kindTag = k => '<span class="tag t-' + ((k || '').includes('HIT') ? 'hit' : (k || '').includes('BYPASS') ? 'bypass' : 'miss') + '">' + esc(k) + '</span>';
2036
+ $('m-body').innerHTML = m.recent.length ? m.recent.map(e =>
2037
+ '<tr><td class="hash">' + esc(fmtLocalTs(e.ts)) + '</td><td>' + kindTag(e.kind) + '</td><td>' + esc(e.model) + '</td>'
2038
+ + '<td class="num">' + fmt(e.in) + '</td><td class="num">' + fmt(e.out) + '</td><td class="num">' + fmt(e.tks_decode ?? e.tks) + '</td>'
2039
+ + '<td class="num">' + fmt(e.latency_ms) + ' ms</td><td class="num c-lime">' + (e.saved ? fmt(e.saved) : '—') + '</td></tr>').join('')
2040
+ : '<tr><td colspan="8" class="empty">No requests yet</td></tr>';
2041
+ }
2042
+
2043
+ async function loadSeries(){
2044
+ try{
2045
+ const cfg = {'1h':['min', 1], '24h':['hour', 24], '7j':['hour', 168]}[S.RANGE];
2046
+ const d = await (await fetch('/api/metrics/series?bucket=' + cfg[0] + '&hours=' + cfg[1])).json();
2047
+ applySeries(d);
2048
+ }catch(e){ console.error('series', e); }
2049
+ }
2050
+
2051
+ /* Applique une serie temporelle aux graphes 3D. Partage par le polling et
2052
+ par le flux SSE. */
2053
+ function applySeries(d){
2054
+ if (!d) return;
2055
+ S.series = d;
2056
+ const pts = fillSeries(d.points, S.RANGE);
2057
+ S.timeline = pts;
2058
+ CHART_OV.setData(pts);
2059
+ CHART_ACT.setData(pts);
2060
+ const total = pts.reduce((s, p) => s + p.hits + p.misses + p.bypass, 0);
2061
+ ['chart-empty', 'chart-empty-act'].forEach(id => {
2062
+ const el = $(id); if (el) el.style.display = total ? 'none' : 'flex';
2063
+ });
2064
+ S.models = d.models || [];
2065
+ S.modelSaved = {};
2066
+ for (const m of S.models) S.modelSaved[m.model] = m.saved || 0;
2067
+ $('n-models').textContent = S.models.length;
2068
+ renderModels();
2069
+ renderModelCards();
2070
+ /* Economie par modele : ce qui est economise (cyan) vs ce qui a ete paye (acier) */
2071
+ CHART_MDL.setData(S.models.map(m => ({
2072
+ t: m.model, hits: m.saved || 0, misses: m.spent || 0, bypass: 0, saved: m.saved || 0
2073
+ })));
2074
+ const mdlEmpty = $('chart-empty-mdl');
2075
+ if (mdlEmpty) mdlEmpty.style.display = S.models.length ? 'none' : 'flex';
2076
+ }
2077
+
2078
+ /* ==================== LIVE STREAM (SSE) ==================== */
2079
+ /* Le dashboard est abonne a /api/live : chaque nouvel evenement proxy est
2080
+ pousse en < 1 s. Le polling 5 s reste en filet de securite si le flux
2081
+ tombe (EventSource se reconnecte tout seul de son cote). */
2082
+ function setLiveState(on){
2083
+ const el = $('live-pill');
2084
+ if (el){
2085
+ el.className = 'pill ' + (on ? 'ok' : 'warn');
2086
+ el.innerHTML = '<i></i>' + (on ? 'LIVE' : 'POLLING');
2087
+ }
2088
+ const tr = $('tk-track');
2089
+ if (tr) tr.dataset.live = on ? '1' : '0';
2090
+ }
2091
+
2092
+ function openLiveStream(){
2093
+ if (S.liveES){ try { S.liveES.close(); }catch(e){} S.liveES = null; }
2094
+ const cfg = {'1h':['min', 1], '24h':['hour', 24], '7j':['hour', 168]}[S.RANGE] || ['hour', 24];
2095
+ if (typeof EventSource === 'undefined'){ setLiveState(false); return; }
2096
+ try{
2097
+ const es = new EventSource('/api/live?bucket=' + cfg[0] + '&hours=' + cfg[1]);
2098
+ S.liveES = es;
2099
+ es.onopen = () => setLiveState(true);
2100
+ es.onmessage = ev => {
2101
+ setLiveState(true);
2102
+ let d; try { d = JSON.parse(ev.data); }catch(e){ return; }
2103
+ if (d.metrics) applyMetrics(d.metrics);
2104
+ if (d.series) applySeries(d.series);
2105
+ S.lastLiveAt = Date.now();
2106
+ };
2107
+ es.onerror = () => setLiveState(false);
2108
+ }catch(e){ setLiveState(false); }
2109
+ }
2110
+
2111
+ async function loadStatus(){
2112
+ try{
2113
+ const st = await (await fetch('/api/status')).json();
2114
+ S.status = st;
2115
+ const mcp = st.mcp || {}, upAll = st.upstream || {};
2116
+ /* /api/status renvoie un dict {nom_upstream: {alive,...}} : on lit l'upstream par defaut. */
2117
+ const up = (upAll.alive !== undefined) ? upAll : (upAll[(st.gateway || {}).default_upstream] || {});
2118
+ $('r-mcp').textContent = mcp.alive ? 'ONLINE' : 'DOWN';
2119
+ $('r-mcp-dot').className = 'status-dot ' + (mcp.alive ? 'on' : 'off');
2120
+ $('r-mcp-s').textContent = mcp.alive
2121
+ ? 'sse :' + mcp.port + ' · ' + mcp.latency_ms + ' ms · ' + (mcp.tools || []).length + ' tools'
2122
+ : 'not reachable on :' + mcp.port;
2123
+ const mp = $('pill-mcp');
2124
+ mp.className = 'pill ' + (mcp.alive ? 'ok' : 'bad');
2125
+ mp.innerHTML = '<i></i>MCP <b>:' + mcp.port + '</b>' + (mcp.alive ? ' ' + mcp.latency_ms + 'ms' : ' DOWN');
2126
+
2127
+ $('r-up').textContent = up.alive ? 'ONLINE' : 'DOWN';
2128
+ $('r-up-dot').className = 'status-dot ' + (up.alive ? 'on' : 'off');
2129
+ $('r-up-s').textContent = up.alive ? 'reachable · ' + up.latency_ms + ' ms' : 'not reachable';
2130
+ const up2 = $('pill-up');
2131
+ up2.className = 'pill ' + (up.alive ? 'ok' : 'warn');
2132
+ up2.innerHTML = '<i></i>UPSTREAM <b>' + (up.alive ? up.latency_ms + 'ms' : 'DOWN') + '</b>';
2133
+ $('up-lat').textContent = up.alive ? up.latency_ms + ' ms' : 'n/a';
2134
+
2135
+ const tools = mcp.tools || [];
2136
+ $('mcp-count').textContent = tools.length + ' EXPOSED';
2137
+ const DESC = {
2138
+ get_cache_stats:'stats + hit rate',
2139
+ clear_cache:'wipe cache (confirm)',
2140
+ normalize_preview:'preview normalization',
2141
+ search_cached_prompts:'search prompts'
2142
+ };
2143
+ $('mcp-tools').innerHTML = tools.length
2144
+ ? tools.map(t => '<div class="tool-row"><i></i><span class="fn">' + esc(t) + '</span><span class="dsc">' + (DESC[t] || 'mcp tool') + '</span></div>').join('')
2145
+ : '<div class="empty">MCP server unreachable</div>';
2146
+ }catch(e){ console.error('status', e); }
2147
+ }
2148
+
2149
+ /* ==================== ROUTED PLUGIN SERVERS ==================== */
2150
+ /* Les serveurs MCP que le proxy sait atteindre et appeler (les « plugins »).
2151
+ Les listes d'outils sont mises en cache cote serveur (TTL), on peut donc
2152
+ interroger chaque serveur sans relancer de process. */
2153
+ const PLUGIN_ICON = {
2154
+ fetch:'🌐', github:'🐙', pwsh:'⌨️', 'qwen-chroma':'🧠', time:'🕒'
2155
+ };
2156
+ async function loadPlugins(){
2157
+ try{
2158
+ const list = await (await fetch('/api/tools')).json();
2159
+ const names = list.servers || [];
2160
+ $('plugin-count').textContent = names.length + ' ROUTED';
2161
+ const el = $('mcp-plugins');
2162
+ if (!el) return;
2163
+ if (!names.length){
2164
+ el.innerHTML = '<div class="empty">No routable MCP server</div>';
2165
+ return;
2166
+ }
2167
+ el.innerHTML = names.map(n =>
2168
+ '<div class="tool-row"><i></i><span class="fn">' + (PLUGIN_ICON[n] || '🧩') + ' ' + esc(n)
2169
+ + '</span><span class="dsc" data-pc="' + esc(n) + '">checking…</span></div>').join('');
2170
+ /* Comptage des outils, serveur par serveur (cache cote serveur). */
2171
+ for (const n of names){
2172
+ try{
2173
+ const r = await (await fetch('/api/tools/' + encodeURIComponent(n))).json();
2174
+ const slot = el.querySelector('[data-pc="' + n + '"]');
2175
+ if (slot) slot.textContent = r.ok ? (r.count + ' tools') : ('unreachable');
2176
+ }catch(e){
2177
+ const slot = el.querySelector('[data-pc="' + n + '"]');
2178
+ if (slot) slot.textContent = 'unreachable';
2179
+ }
2180
+ }
2181
+ }catch(e){ console.error('plugins', e); }
2182
+ }
2183
+
2184
+ /* ==================== TABLES ==================== */
2185
+ function sortIcon(th, dir){
2186
+ document.querySelectorAll('th .arr').forEach(a => a.textContent = '');
2187
+ const a = th.querySelector('.arr'); if (a) a.textContent = dir > 0 ? '▲' : '▼';
2188
+ }
2189
+ function renderCache(){
2190
+ const q = ($('cache-q').value || '').toLowerCase();
2191
+ let rows = (S.cache || []).filter(p => !q || [p.hash, p.model, p.normalized_prompt].some(x => (x || '').toLowerCase().includes(q)));
2192
+ const {k, dir} = S.cacheSort;
2193
+ rows = rows.slice().sort((a, b) => ((a[k] > b[k]) ? 1 : (a[k] < b[k]) ? -1 : 0) * dir);
2194
+ $('cache-shown').textContent = rows.length;
2195
+ $('cache-body').innerHTML = rows.length ? rows.map(p =>
2196
+ '<tr><td class="hash">' + esc(p.hash) + '</td><td><span class="name">' + esc(p.model) + '</span></td>'
2197
+ + '<td class="prompt">' + esc(p.normalized_prompt) + '</td>'
2198
+ + '<td class="num count">' + p.access_count + '</td><td class="hash">' + esc(p.created_at) + '</td></tr>').join('')
2199
+ : '<tr><td colspan="5" class="empty">No entries</td></tr>';
2200
+ }
2201
+ document.querySelectorAll('[data-sc]').forEach(th => th.onclick = () => {
2202
+ const k = th.dataset.sc;
2203
+ S.cacheSort = {k, dir: S.cacheSort.k === k ? -S.cacheSort.dir : -1};
2204
+ sortIcon(th, S.cacheSort.dir); renderCache();
2205
+ });
2206
+ function sortedModels(){
2207
+ const {k, dir} = S.mdlSort;
2208
+ return (S.models || []).slice().sort((a, b) => ((a[k] > b[k]) ? 1 : (a[k] < b[k]) ? -1 : 0) * dir);
2209
+ }
2210
+ function renderModels(){
2211
+ const rows = sortedModels();
2212
+ const html = rows.length ? rows.map(m =>
2213
+ '<tr><td><span class="name">' + esc(m.model) + '</span></td><td class="num">' + fmt(m.requests) + '</td>'
2214
+ + '<td class="num count">' + fmt(m.hits) + '</td><td class="num">' + fmt(m.spent) + '</td>'
2215
+ + '<td class="num c-lime">' + fmt(m.saved) + '</td></tr>').join('')
2216
+ : '<tr><td colspan="5" class="empty">No data</td></tr>';
2217
+ $('mdl-body').innerHTML = html;
2218
+ $('mdl-body2').innerHTML = rows.length ? rows.map(m => {
2219
+ const eco = m.spent + m.saved > 0 ? Math.round(100 * m.saved / (m.spent + m.saved)) : 0;
2220
+ return '<tr><td><span class="name">' + esc(m.model) + '</span></td><td class="num">' + fmt(m.requests) + '</td>'
2221
+ + '<td class="num count">' + fmt(m.hits) + '</td><td class="num">' + fmt(m.spent) + '</td>'
2222
+ + '<td class="num c-lime">' + fmt(m.saved) + '</td>'
2223
+ + '<td><span class="tag ' + (eco > 30 ? 't-hit' : eco > 10 ? 't-miss' : 't-bypass') + '">' + eco + ' %</span></td></tr>';
2224
+ }).join('') : '<tr><td colspan="6" class="empty">No data</td></tr>';
2225
+ }
2226
+ document.querySelectorAll('[data-s]').forEach(th => th.onclick = () => {
2227
+ const k = th.dataset.s;
2228
+ S.mdlSort = {k, dir: S.mdlSort.k === k ? -S.mdlSort.dir : -1};
2229
+ sortIcon(th, S.mdlSort.dir); renderModels();
2230
+ });
2231
+ function renderModelCards(){
2232
+ const rows = sortedModels().slice(0, 4);
2233
+ const cls = ['c-cyan', 'c-pink', 'c-lime', 'c-violet'];
2234
+ const met = ['m-cyan', 'm-pink', 'm-lime', 'm-violet'];
2235
+ $('mdl-cards').innerHTML = rows.length ? rows.map((m, i) => {
2236
+ const eco = m.spent + m.saved > 0 ? Math.round(100 * m.saved / (m.spent + m.saved)) : 0;
2237
+ return '<div class="glass" data-tip="<b>' + esc(m.model) + '</b>' + fmt(m.requests) + ' requests · ' + fmt(m.hits) + ' hits · ' + eco + '% economy">'
2238
+ + '<h3><span class="ln"></span>' + esc(m.model) + '</h3>'
2239
+ + '<div class="v ' + cls[i % 4] + '">' + fmt(m.requests) + '<span style="font-size:12px;color:var(--muted)"> req</span></div>'
2240
+ + '<div class="sub">hits ' + fmt(m.hits) + ' · saved ' + fmt(m.saved) + '</div>'
2241
+ + '<div class="meter ' + met[i % 4] + '"><i style="width:' + Math.max(2, eco) + '%"></i></div></div>';
2242
+ }).join('') : '<div class="glass empty" style="grid-column:1/-1">No models</div>';
2243
+ }
2244
+
2245
+ /* ==================== EXPORT / LOGS / CLEAR ==================== */
2246
+ function exportJSON(){
2247
+ const data = {exported_at:new Date().toISOString(), range:S.RANGE, stats:S.stats, metrics:S.metrics, series:S.series, status:S.status, models:S.models, cache:S.cache};
2248
+ download(JSON.stringify(data, null, 2), 'icycache_export_' + Date.now() + '.json');
2249
+ toast('Export JSON downloaded');
2250
+ }
2251
+ function exportCache(){
2252
+ download(JSON.stringify(S.cache || [], null, 2), 'icycache_cache_' + Date.now() + '.json');
2253
+ toast('Cache exported');
2254
+ }
2255
+ function download(content, name){
2256
+ const a = document.createElement('a');
2257
+ a.href = URL.createObjectURL(new Blob([content], {type:'application/json'}));
2258
+ a.download = name; a.click(); URL.revokeObjectURL(a.href);
2259
+ }
2260
+ function openLogs(){ window.open('/api/dashboard-stats', '_blank'); toast('API payload opened'); }
2261
+ async function clearCache(){
2262
+ if (!confirm('Wipe the ENTIRE cache? This cannot be undone.')) return;
2263
+ try{
2264
+ const r = await fetch('/api/cache-clear', {method:'POST', headers:{'X-IcyCache-Confirm':'FORCE_DELETE'}});
2265
+ const d = await r.json();
2266
+ toast(d.message || d.detail || 'Done');
2267
+ loadStats(); loadMetrics();
2268
+ }catch(e){ toast('Error: ' + e, true); }
2269
+ }
2270
+
2271
+ /* ==================== HELP ==================== */
2272
+ function toggleHelp(force){
2273
+ const panel = $('help'), scrim = $('help-scrim');
2274
+ const on = force === undefined ? !panel.classList.contains('on') : force;
2275
+ panel.classList.toggle('on', on);
2276
+ scrim.classList.toggle('on', on);
2277
+ }
2278
+
2279
+ /* ==================== RANGE ==================== */
2280
+ function setRange(k, btn){
2281
+ S.RANGE = k;
2282
+ document.querySelectorAll('.rng, [data-range]').forEach(b => b.classList.toggle('on', b.dataset.range === k));
2283
+ loadSeries();
2284
+ /* le flux SSE emporte la fenetre temporelle : on le rouvre sur la nouvelle. */
2285
+ openLiveStream();
2286
+ }
2287
+
2288
+ /* ==================== REFRESH LOOP ==================== */
2289
+ function setRefresh(ms){
2290
+ S.refreshMs = Number(ms);
2291
+ if (S.timer) clearInterval(S.timer);
2292
+ S.timer = S.refreshMs ? setInterval(() => refreshAll(false), S.refreshMs) : null;
2293
+ }
2294
+ async function refreshAll(manual){
2295
+ await Promise.all([loadStats(), loadMetrics(), loadStatus()]);
2296
+ if (manual) toast('Metrics refreshed');
2297
+ if (S.RANGE) loadSeries();
2298
+ }
2299
+
2300
+ /* ==================== KEYBOARD ==================== */
2301
+ document.addEventListener('keydown', e => {
2302
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
2303
+ const k = e.key.toLowerCase();
2304
+ if (k >= '1' && k <= '4'){ go(VIEWS[Number(k) - 1]); }
2305
+ else if (k === 'r'){ refreshAll(true); }
2306
+ else if (k === 'e'){ exportJSON(); }
2307
+ else if (k === 'h'){ toggleHelp(); }
2308
+ else if (k === 's'){ S.paused = !S.paused; toast(S.paused ? 'Animations paused' : 'Animations resumed'); }
2309
+ else if (k === 'escape'){ toggleHelp(false); }
2310
+ });
2311
+
2312
+ /* ==================== BOOT ==================== */
2313
+ $('refr-sel').value = '5';
2314
+ setRefresh(5000);
2315
+ initGraphControls();
2316
+ initLoopControls();
2317
+ initChartControls();
2318
+ initChartHover(window._chartOV, 'bars3d');
2319
+ initChartHover(window._chartACT, 'bars-act');
2320
+ initChartHover(window._chartMDL, 'bars-mdl');
2321
+ openLiveStream();
2322
+ loadPlugins();
2323
+ /* Les listes d'outils sont cachees cote serveur : un rafraichissement
2324
+ toutes les 60 s suffit, pas besoin de poller a chaque tick. */
2325
+ setInterval(loadPlugins, 60000);
2326
+ /* La fiche anatomique (soma, dendrites, epines, myeline) et la vitalite
2327
+ sont animees en continu : on les rafraichit sur le meme rythme que
2328
+ l'ecran. */
2329
+ setInterval(() => { renderCellStats(); }, 700);
2330
+ refreshAll(false);
2331
+ setTimeout(loadSeries, 120);
2332
+ setTimeout(() => {
2333
+ const n = window._neuron3D;
2334
+ if (n) {
2335
+ $('hud-nodes').textContent = n.nodeCount + ' NODES';
2336
+ $('hud-syn').textContent = n.edgeCount + ' SYNAPSES';
2337
+ }
2338
+ showNodeInfo(null);
2339
+ }, 300);
2340
+ </script>
2341
+ <script id=wowjs>(function(){try{
2342
+ var q=function(s,r){return (r||document).querySelector(s)},qa=function(s,r){return Array.prototype.slice.call((r||document).querySelectorAll(s))};
2343
+ var side=q('.side'),acts=q('.side-actions'),sp=q('.spacer',side),anchor=sp||acts,pills=q('.top .pills'),refr=q('.top .refr'),gs=q('.globe-side'),kv=q('.kv',gs),gc=q('.gctl',gs),stage=q('.globe-stage'),card=q('.globe-card');
2344
+ if(side&&anchor){var sep=document.createElement('div');sep.className='sep';sep.innerHTML='<span>System</span><i></i>';side.insertBefore(sep,anchor);[kv,pills,refr].forEach(function(e){if(e)side.insertBefore(e,anchor)})}
2345
+ var re=q('#r-entries');if(re&&re.closest){var rr=re.closest('.rail-row');if(rr)rr.style.display='none'}
2346
+ if(stage&&gc){gc.className+=' gtool';stage.appendChild(gc)}
2347
+ var ins=q('#node-menu');if(stage&&ins)stage.appendChild(ins);
2348
+ if(card&&gs){var d=document.createElement('details');d.className='gdet';d.innerHTML='<summary>Details / legend / cell stats</summary>';[q('#cell-stats'),q('.globe-legend',gs),q('.sub',gs)].forEach(function(e){if(e)d.appendChild(e)});card.insertBefore(d,gs)}
2349
+ var spot=document.createElement('div');spot.id='wowspot';document.body.appendChild(spot);
2350
+ document.addEventListener('pointermove',function(e){spot.style.transform='translate3d('+(e.clientX-310)+'px,'+(e.clientY-310)+'px,0)';var g=e.target.closest&&e.target.closest('.glass');if(g){var r=g.getBoundingClientRect();g.style.setProperty('--mx',(e.clientX-r.left)+'px');g.style.setProperty('--my',(e.clientY-r.top)+'px')}},{passive:true});
2351
+ var fire=function(){var b=q('[data-g=pulse]');if(b)b.click()};
2352
+ var t=q('#r-saved'),last=t&&t.textContent;if(t)new MutationObserver(function(){var v=t.textContent;if(v!==last&&last&&last.charCodeAt(0)!==8212)fire();last=v}).observe(t,{childList:true,characterData:true,subtree:true});
2353
+ var cv=q('#globe');if(cv)cv.addEventListener('dblclick',fire);
2354
+ setTimeout(function(){var z=q('[data-g=zin]');if(z){}},1600);
2355
+ var pop=function(el){el.classList.remove('wowpop');void el.offsetWidth;el.classList.add('wowpop')};
2356
+ ['entries','saved-total','m-pct','tc-val','ov-hr','ov-req','ov-hits','ov-miss','r-hr','r-saved','r-tks'].forEach(function(id){var e=q('#'+id);if(!e)return;var l=e.textContent;new MutationObserver(function(){var v=e.textContent;if(v!==l){l=v;pop(e)}}).observe(e,{childList:true,characterData:true,subtree:true})});
2357
+ var main=q('.main'),mb=q('.nav-btn[data-v=models]');
2358
+ if(main&&mb){
2359
+ var kp=function(id,l){return '<div class=glass><div class=k>'+l+'</div><div class=val id='+id+'>-</div></div>'};
2360
+ var sec=document.createElement('section');sec.className='view';sec.id='v-tools';
2361
+ sec.innerHTML='<div class=tkpis>'+kp('tk-calls','Tool calls')+kp('tk-hits','Cache hits')+kp('tk-err','Errors')+kp('tk-lat','Latency saved')+kp('tk-mb','Payload')+'</div><div class=tgrid><div class=glass><h3><span class=ln></span>By server</h3><div id=tk-srv></div></div><div class=glass><h3><span class=ln></span>By client</h3><div id=tk-cli></div><div class=tnote>Only MCP tool calls routed through IcyCache appear here (client = who called). Chat LLM tokens never pass through this proxy.</div></div></div><div class=glass><h3><span class=ln></span>Recent calls<span class=tchips id=tk-f></span></h3><div class=ttbl id=tk-tbl></div></div>';
2362
+ main.appendChild(sec);
2363
+ var nb=document.createElement('button');nb.className='nav-btn';nb.setAttribute('data-v','tools');nb.setAttribute('data-tip','<b>Tools</b>MCP tool calls routed through IcyCache: who called, which tool, latency, errors, cache hits.');nb.innerHTML='<span class=ico>&#9881;</span><span class=lbl>Tools</span><span class=cnt id=n-tools>0</span>';
2364
+ mb.parentNode.insertBefore(nb,mb.nextSibling);
2365
+ var flt='all',data=[],stats={};
2366
+ var st=function(id,v){var e=q('#'+id);if(e)e.textContent=v};
2367
+ var esc=function(s){return String(s==null?'':s).replace(/[&<>]/g,function(c){return c==='&'?'&amp;':c==='<'?'&lt;':'&gt;'})};
2368
+ var tm=function(ts){var x=new Date(String(ts).replace(' ','T')+'Z');return isNaN(x)?String(ts):x.toTimeString().slice(0,8)};
2369
+ var by=function(key){var m={};data.forEach(function(e){var k=e[key]||'-';var o=m[k]||(m[k]={n:0,ms:0});o.n++;o.ms+=e.latency_ms||0});return Object.keys(m).map(function(k){return {k:k,n:m[k].n,avg:Math.round(m[k].ms/m[k].n)}}).sort(function(a,b){return b.n-a.n})};
2370
+ var bars=function(arr){var mx=arr.length?arr[0].n:1;return arr.slice(0,8).map(function(o){return '<div class=trow><span>'+esc(o.k)+'</span><div class=tbar><i style=width:'+Math.max(4,Math.round(o.n/mx*100))+'%></i></div><b>'+o.n+'</b><span>'+o.avg+' ms</span></div>'}).join('')};
2371
+ var lbl={tool_call:'CALL',tool_hit:'HIT',tool_error:'ERR'};
2372
+ var render=function(){var s=stats,c=function(k){return s[k]&&s[k].count||0};
2373
+ st('tk-calls',c('tool_call'));st('tk-hits',c('tool_hit'));st('tk-err',c('tool_error'));st('tk-lat',((s.tool_hit&&s.tool_hit.saved_latency_ms||0)/1000).toFixed(1)+' s');st('tk-mb',((s.tool_call&&s.tool_call.bytes||0)/1048576).toFixed(2)+' MB');st('n-tools',c('tool_call'));
2374
+ var a=q('#tk-srv'),b=q('#tk-cli');if(a)a.innerHTML=bars(by('server'));if(b)b.innerHTML=bars(by('client'));
2375
+ var cl=by('client').map(function(o){return o.k}),names=['all'].concat(cl).concat(['errors']),fb=q('#tk-f');
2376
+ if(fb){fb.innerHTML='';names.forEach(function(n){var x=document.createElement('button');x.className='hud-btn'+(n===flt?' on':'');x.textContent=n;x.onclick=function(){flt=n;render()};fb.appendChild(x)})}
2377
+ var rows=data.filter(function(e){return flt==='all'||(flt==='errors'?e.kind==='tool_error':e.client===flt)}).slice(0,120);
2378
+ var tb=q('#tk-tbl');if(tb)tb.innerHTML='<div class=tr th><b>Time</b><b>Kind</b><b>Server / tool</b><b>Client</b><b>Size</b><b>Latency</b></div>'.replace('tr th','tr th')+rows.map(function(e){return '<div class=tr><span>'+tm(e.ts)+'</span><span class=tk-'+esc(e.kind)+'>'+(lbl[e.kind]||esc(e.kind))+'</span><span>'+esc(e.server)+' / '+esc(e.tool)+'</span><span class=cli-'+esc(e.client)+'>'+esc(e.client)+'</span><span>'+((e.bytes||0)/1024).toFixed(1)+' kB</span><span>'+(e.kind==='tool_hit'?'-'+(e.saved_latency_ms||0):(e.latency_ms||0))+' ms</span></div>'}).join('')};
2379
+ var load=function(){Promise.all([fetch('/api/agent-events?limit=300').then(function(r){return r.json()}),fetch('/api/tools/stats').then(function(r){return r.json()})]).then(function(a){data=a[0].events||[];stats=a[1].events||{};render()}).catch(function(){})};
2380
+ nb.addEventListener('click',function(){qa('.nav-btn').forEach(function(x){x.classList.remove('on')});qa('.view').forEach(function(x){x.classList.remove('on')});nb.classList.add('on');sec.classList.add('on');var ti=q('#view-title');if(ti)ti.textContent='Tools';load()});
2381
+ setInterval(function(){if(sec.classList.contains('on'))load()},4000);load();
2382
+ }
2383
+ }catch(e){console.warn('wow patch',e)}})();</script>
2384
+ </body>
2385
+ </html>