claude-finops 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/charts.js ADDED
@@ -0,0 +1,340 @@
1
+ /* Minimal dependency-free SVG chart layer.
2
+ Every chart ships a hover/tooltip layer; every multi-series chart ships a legend. */
3
+ const NS = 'http://www.w3.org/2000/svg';
4
+ export const SERIES = ['--s1','--s2','--s3','--s4','--s5','--s6','--s7','--s8'];
5
+ export const seriesVar = i => `var(${SERIES[i % SERIES.length]})`;
6
+
7
+ let tipEl = null;
8
+ function tip() {
9
+ if (!tipEl) { tipEl = document.createElement('div'); tipEl.className = 'tip';
10
+ tipEl.style.display = 'none'; document.body.appendChild(tipEl); }
11
+ return tipEl;
12
+ }
13
+ export function showTip(html, x, y) {
14
+ const t = tip(); t.innerHTML = html; t.style.display = 'block';
15
+ const r = t.getBoundingClientRect();
16
+ let left = x + 14, top = y + 14;
17
+ if (left + r.width > innerWidth - 8) left = x - r.width - 14;
18
+ if (top + r.height > innerHeight - 8) top = innerHeight - r.height - 8;
19
+ t.style.left = Math.max(8, left) + 'px'; t.style.top = Math.max(8, top) + 'px';
20
+ }
21
+ export function hideTip() { if (tipEl) tipEl.style.display = 'none'; }
22
+
23
+ const el = (n, a = {}, kids = []) => {
24
+ const e = document.createElementNS(NS, n);
25
+ for (const k in a) if (a[k] !== undefined && a[k] !== null) e.setAttribute(k, a[k]);
26
+ for (const c of [].concat(kids)) e.appendChild(typeof c === 'string'
27
+ ? document.createTextNode(c) : c);
28
+ return e;
29
+ };
30
+ const nice = v => {
31
+ if (v <= 0) return 1;
32
+ const p = Math.pow(10, Math.floor(Math.log10(v)));
33
+ const r = v / p;
34
+ return (r <= 1 ? 1 : r <= 2 ? 2 : r <= 2.5 ? 2.5 : r <= 5 ? 5 : 10) * p;
35
+ };
36
+ export const fmtUSD = v => v == null ? '—' :
37
+ (Math.abs(v) >= 1000 ? '$' + v.toLocaleString(undefined, {maximumFractionDigits: 0})
38
+ : Math.abs(v) >= 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(v < 0.01 ? 4 : 3));
39
+ export const fmtNum = v => v == null ? '—' :
40
+ Math.abs(v) >= 1e9 ? (v/1e9).toFixed(2) + 'B' :
41
+ Math.abs(v) >= 1e6 ? (v/1e6).toFixed(1) + 'M' :
42
+ Math.abs(v) >= 1e3 ? (v/1e3).toFixed(1) + 'K' : Math.round(v).toLocaleString();
43
+ export const fmtInt = v => v == null ? '—' : Math.round(v).toLocaleString();
44
+ export const fmtPct = (v, d = 1) => v == null ? '—' : v.toFixed(d) + '%';
45
+
46
+ function axisLeft(g, y, max, W, ticks = 4, fmt = fmtNum) {
47
+ for (let i = 0; i <= ticks; i++) {
48
+ const v = max * i / ticks, yy = y(v);
49
+ g.appendChild(el('line', {x1: 0, x2: W, y1: yy, y2: yy, class: 'gl'}));
50
+ g.appendChild(el('text', {x: -7, y: yy + 3.5, 'text-anchor': 'end'}, fmt(v)));
51
+ }
52
+ }
53
+
54
+ /* ---------- time series: line / area / stacked bars ---------- */
55
+ export function timeSeries(host, opts) {
56
+ const {rows, x: xk, series, fmt = fmtNum, type = 'area', height = 230,
57
+ onClick, xLabel = d => d} = opts;
58
+ host.innerHTML = '';
59
+ if (!rows.length) { host.innerHTML = '<div class="empty">No data in range</div>'; return; }
60
+ const off = new Set(opts.hidden || []);
61
+ const active = series.filter(s => !off.has(s.key));
62
+ const W = Math.max(host.clientWidth || 640, 320), H = height;
63
+ const m = {t: 12, r: 14, b: 26, l: 52};
64
+ const iw = W - m.l - m.r, ih = H - m.t - m.b;
65
+
66
+ const stacked = type === 'bar' && active.length > 1;
67
+ const totals = rows.map(r => stacked
68
+ ? active.reduce((a, s) => a + (+r[s.key] || 0), 0)
69
+ : Math.max(...active.map(s => +r[s.key] || 0), 0));
70
+ const max = nice(Math.max(...totals, 0) || 1);
71
+ const y = v => m.t + ih - (v / max) * ih;
72
+ const n = rows.length;
73
+ const bw = Math.max(2, Math.min(34, iw / n * 0.72));
74
+ const cx = i => m.l + (n === 1 ? iw / 2 : (i + 0.5) * (iw / n));
75
+ const px = i => m.l + (n === 1 ? iw / 2 : i * (iw / (n - 1)));
76
+
77
+ const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H, role: 'img'});
78
+ const g = el('g', {transform: `translate(0,0)`});
79
+ const gi = el('g', {transform: `translate(${m.l},0)`});
80
+ axisLeft(gi, y, max, iw, 4, fmt);
81
+ svg.appendChild(gi);
82
+ svg.appendChild(el('line', {x1: m.l, x2: m.l + iw, y1: y(0), y2: y(0), class: 'ax'}));
83
+
84
+ if (type === 'bar') {
85
+ rows.forEach((r, i) => {
86
+ let base = 0;
87
+ active.forEach((s, si) => {
88
+ const v = +r[s.key] || 0; if (!v) return;
89
+ const y0 = y(base + v), y1 = y(base);
90
+ const h = Math.max(1, y1 - y0 - (stacked ? 2 : 0)); // 2px surface gap between segments
91
+ const isTop = stacked ? (base + v >= totals[i] - 1e-9) : true;
92
+ const rect = el('rect', {
93
+ x: cx(i) - bw / 2, y: y0, width: bw, height: h,
94
+ rx: isTop ? Math.min(4, bw / 2) : 0,
95
+ fill: s.color || seriesVar(series.indexOf(s))});
96
+ g.appendChild(rect); base += v;
97
+ });
98
+ });
99
+ } else {
100
+ active.forEach((s) => {
101
+ const idx = series.indexOf(s), col = s.color || seriesVar(idx);
102
+ const pts = rows.map((r, i) => [px(i), y(+r[s.key] || 0)]);
103
+ const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' ');
104
+ if (type === 'area' && active.length === 1) {
105
+ g.appendChild(el('path', {d: `${d} L${pts[pts.length-1][0]},${y(0)} L${pts[0][0]},${y(0)} Z`,
106
+ fill: col, 'fill-opacity': .13}));
107
+ }
108
+ g.appendChild(el('path', {d, fill: 'none', stroke: col, 'stroke-width': 2,
109
+ 'stroke-linejoin': 'round', 'stroke-linecap': 'round'}));
110
+ if (n <= 40) pts.forEach(p => g.appendChild(el('circle', {
111
+ cx: p[0], cy: p[1], r: 3.2, fill: col, stroke: 'var(--surface)', 'stroke-width': 2})));
112
+ });
113
+ }
114
+ svg.appendChild(g);
115
+
116
+ // x labels — thinned to avoid collisions
117
+ const step = Math.max(1, Math.ceil(n / Math.max(3, Math.floor(iw / 74))));
118
+ rows.forEach((r, i) => {
119
+ if (i % step && i !== n - 1) return;
120
+ svg.appendChild(el('text', {x: (type === 'bar' ? cx(i) : px(i)), y: H - 8,
121
+ 'text-anchor': 'middle'}, xLabel(r[xk])));
122
+ });
123
+
124
+ // hover layer
125
+ const cross = el('line', {y1: m.t, y2: m.t + ih, class: 'crosshair', opacity: 0});
126
+ svg.appendChild(cross);
127
+ const hit = el('rect', {x: m.l, y: m.t, width: iw, height: ih, fill: 'transparent',
128
+ style: onClick ? 'cursor:pointer' : ''});
129
+ svg.appendChild(hit);
130
+ const idxAt = ev => {
131
+ const b = svg.getBoundingClientRect();
132
+ const rx = (ev.clientX - b.left) * (W / b.width);
133
+ return Math.max(0, Math.min(n - 1, Math.round((rx - m.l) / (iw / (type === 'bar' ? n : Math.max(n - 1, 1)))
134
+ - (type === 'bar' ? 0.5 : 0))));
135
+ };
136
+ hit.addEventListener('mousemove', ev => {
137
+ const i = idxAt(ev), r = rows[i];
138
+ cross.setAttribute('opacity', 1);
139
+ cross.setAttribute('x1', type === 'bar' ? cx(i) : px(i));
140
+ cross.setAttribute('x2', type === 'bar' ? cx(i) : px(i));
141
+ const body = active.map(s => `<div class="row"><span class="k">
142
+ <span class="swatch" style="background:${s.color || seriesVar(series.indexOf(s))}"></span>
143
+ ${s.label}</span><span class="v">${(s.fmt || fmt)(+r[s.key] || 0)}</span></div>`).join('');
144
+ showTip(`<div class="t">${xLabel(r[xk])}</div>${body}`, ev.clientX, ev.clientY);
145
+ });
146
+ hit.addEventListener('mouseleave', () => { hideTip(); cross.setAttribute('opacity', 0); });
147
+ if (onClick) hit.addEventListener('click', ev => onClick(rows[idxAt(ev)]));
148
+ host.appendChild(svg);
149
+ }
150
+
151
+ /* ---------- horizontal ranked bars ---------- */
152
+ export function barsH(host, opts) {
153
+ const {rows, label, value, fmt = fmtUSD, height, color, onClick, sub, max: fixedMax} = opts;
154
+ host.innerHTML = '';
155
+ if (!rows.length) { host.innerHTML = '<div class="empty">No data in range</div>'; return; }
156
+ const rh = 26, W = Math.max(host.clientWidth || 520, 300);
157
+ const lw = Math.min(210, Math.max(110, W * 0.3)), vw = 82;
158
+ const H = height || rows.length * rh + 6;
159
+ const iw = Math.max(40, W - lw - vw - 10);
160
+ const max = fixedMax || Math.max(...rows.map(value), 1e-9);
161
+ const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H});
162
+ rows.forEach((r, i) => {
163
+ const y0 = i * rh + 4, v = value(r), w = Math.max(2, (v / max) * iw);
164
+ const col = typeof color === 'function' ? color(r, i) : (color || seriesVar(0));
165
+ const grp = el('g', {style: onClick ? 'cursor:pointer' : ''});
166
+ grp.appendChild(el('rect', {x: 0, y: y0 - 4, width: W, height: rh, fill: 'transparent'}));
167
+ const t = el('text', {x: 0, y: y0 + 13, class: 'val'}, label(r).length > 30
168
+ ? label(r).slice(0, 29) + '…' : label(r));
169
+ grp.appendChild(t);
170
+ grp.appendChild(el('rect', {x: lw, y: y0 + 4, width: w, height: 11, rx: 4, fill: col}));
171
+ grp.appendChild(el('text', {x: W - 2, y: y0 + 13, 'text-anchor': 'end', class: 'val'}, fmt(v)));
172
+ grp.addEventListener('mousemove', ev => showTip(
173
+ `<div class="t">${label(r)}</div><div class="row"><span class="k">
174
+ <span class="swatch" style="background:${col}"></span>Value</span>
175
+ <span class="v">${fmt(v)}</span></div>${sub ? sub(r) : ''}`, ev.clientX, ev.clientY));
176
+ grp.addEventListener('mouseleave', hideTip);
177
+ if (onClick) grp.addEventListener('click', () => onClick(r));
178
+ svg.appendChild(grp);
179
+ });
180
+ host.appendChild(svg);
181
+ }
182
+
183
+ /* ---------- donut ---------- */
184
+ export function donut(host, opts) {
185
+ const {rows, label, value, fmt = fmtUSD, size = 190, centerLabel, centerValue, onClick,
186
+ color} = opts;
187
+ host.innerHTML = '';
188
+ const total = rows.reduce((a, r) => a + value(r), 0);
189
+ if (!total) { host.innerHTML = '<div class="empty">No data in range</div>'; return; }
190
+ const R = size / 2, r0 = R * 0.60, cx = R, cy = R;
191
+ const svg = el('svg', {viewBox: `0 0 ${size} ${size}`, height: size, width: size,
192
+ style: 'width:' + size + 'px;flex:none'});
193
+ let a0 = -Math.PI / 2;
194
+ rows.forEach((row, i) => {
195
+ const v = value(row); if (v <= 0) return;
196
+ const a1 = a0 + (v / total) * Math.PI * 2;
197
+ const gap = 0.012; // 2px-equivalent surface gap between segments
198
+ const s = a0 + gap, e = Math.max(s, a1 - gap);
199
+ const P = (ang, rad) => [cx + Math.cos(ang) * rad, cy + Math.sin(ang) * rad];
200
+ const large = (e - s) > Math.PI ? 1 : 0;
201
+ const [x1, y1] = P(s, R), [x2, y2] = P(e, R), [x3, y3] = P(e, r0), [x4, y4] = P(s, r0);
202
+ // colour follows the entity, never its rank in this particular slice order
203
+ const col = typeof color === 'function' ? color(row, i) : seriesVar(i);
204
+ const p = el('path', {d: `M${x1},${y1} A${R},${R} 0 ${large} 1 ${x2},${y2}
205
+ L${x3},${y3} A${r0},${r0} 0 ${large} 0 ${x4},${y4} Z`, fill: col,
206
+ style: onClick ? 'cursor:pointer' : ''});
207
+ p.addEventListener('mousemove', ev => showTip(
208
+ `<div class="t">${label(row)}</div><div class="row"><span class="k">
209
+ <span class="swatch" style="background:${col}"></span>Estimated</span>
210
+ <span class="v">${fmt(v)}</span></div><div class="row"><span class="k">Share</span>
211
+ <span class="v">${(100 * v / total).toFixed(1)}%</span></div>`, ev.clientX, ev.clientY));
212
+ p.addEventListener('mouseleave', hideTip);
213
+ if (onClick) p.addEventListener('click', () => onClick(row));
214
+ svg.appendChild(p);
215
+ a0 = a1;
216
+ });
217
+ if (centerValue) {
218
+ svg.appendChild(el('text', {x: cx, y: cy - 2, 'text-anchor': 'middle', class: 'val',
219
+ style: 'font-size:19px;font-weight:660;fill:var(--text)'}, centerValue));
220
+ svg.appendChild(el('text', {x: cx, y: cy + 14, 'text-anchor': 'middle',
221
+ style: 'font-size:10px'}, centerLabel || ''));
222
+ }
223
+ host.appendChild(svg);
224
+ }
225
+
226
+ /* ---------- gauge ---------- */
227
+ export function gauge(host, {pct, status = 'healthy', label = '', size = 176}) {
228
+ host.innerHTML = '';
229
+ const W = size, H = size * 0.62, R = size * 0.42, cx = W / 2, cy = H - 6, sw = 13;
230
+ const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H});
231
+ const arc = (frac, color, op) => {
232
+ const a = Math.PI * Math.min(Math.max(frac, 0), 1);
233
+ const x1 = cx - R, y1 = cy, x2 = cx - Math.cos(a) * R, y2 = cy - Math.sin(a) * R;
234
+ return el('path', {d: `M${x1},${y1} A${R},${R} 0 ${a > Math.PI ? 1 : 0} 1 ${x2},${y2}`,
235
+ fill: 'none', stroke: color, 'stroke-width': sw, 'stroke-linecap': 'round',
236
+ 'stroke-opacity': op});
237
+ };
238
+ svg.appendChild(arc(1, 'var(--surface-3)', 1));
239
+ svg.appendChild(arc(Math.min(pct, 100) / 100, `var(--${status === 'healthy' ? 'good'
240
+ : status === 'high' ? 'warning' : status === 'approaching' ? 'serious' : 'critical'})`, 1));
241
+ svg.appendChild(el('text', {x: cx, y: cy - 12, 'text-anchor': 'middle',
242
+ style: 'font-size:25px;font-weight:680;fill:var(--text)'}, pct.toFixed(0) + '%'));
243
+ svg.appendChild(el('text', {x: cx, y: cy + 4, 'text-anchor': 'middle',
244
+ style: 'font-size:10px'}, label));
245
+ host.appendChild(svg);
246
+ }
247
+
248
+ /* ---------- forecast fan ---------- */
249
+ export function forecastFan(host, {history, scenarios, remainingDays, height = 230}) {
250
+ host.innerHTML = '';
251
+ if (!history.length) { host.innerHTML = '<div class="empty">Not enough history</div>'; return; }
252
+ const W = Math.max(host.clientWidth || 640, 320), H = height;
253
+ const m = {t: 12, r: 14, b: 26, l: 56};
254
+ const iw = W - m.l - m.r, ih = H - m.t - m.b;
255
+ let cum = 0;
256
+ const hist = history.map(d => ({day: d.day, v: (cum += d.cost)}));
257
+ const base = cum, n = hist.length, total = n + remainingDays;
258
+ const paths = {};
259
+ for (const k of ['conservative', 'expected', 'high']) {
260
+ const rate = scenarios[k].daily_rate;
261
+ paths[k] = Array.from({length: remainingDays + 1}, (_, i) => base + rate * i);
262
+ }
263
+ const max = nice(Math.max(base, ...paths.high) || 1);
264
+ const X = i => m.l + (total <= 1 ? 0 : i * (iw / (total - 1)));
265
+ const Y = v => m.t + ih - (v / max) * ih;
266
+ const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H});
267
+ const gi = el('g', {transform: `translate(${m.l},0)`});
268
+ axisLeft(gi, Y, max, iw, 4, fmtUSD); svg.appendChild(gi);
269
+
270
+ const band = [];
271
+ paths.high.forEach((v, i) => band.push(`${i ? 'L' : 'M'}${X(n - 1 + i)},${Y(v)}`));
272
+ for (let i = paths.conservative.length - 1; i >= 0; i--)
273
+ band.push(`L${X(n - 1 + i)},${Y(paths.conservative[i])}`);
274
+ svg.appendChild(el('path', {d: band.join(' ') + ' Z', fill: 'var(--s1)', 'fill-opacity': .13}));
275
+
276
+ const line = (pts, color, dash) => el('path', {
277
+ d: pts.map((p, i) => (i ? 'L' : 'M') + p[0] + ',' + p[1]).join(' '), fill: 'none',
278
+ stroke: color, 'stroke-width': 2, 'stroke-dasharray': dash || null,
279
+ 'stroke-linecap': 'round'});
280
+ svg.appendChild(line(hist.map((d, i) => [X(i), Y(d.v)]), 'var(--s1)'));
281
+ svg.appendChild(line(paths.expected.map((v, i) => [X(n - 1 + i), Y(v)]), 'var(--s1)', '5 4'));
282
+
283
+ const labels = [['Actual to date', 'var(--s1)', fmtUSD(base), X(n - 1), Y(base)],
284
+ ['Expected', 'var(--s1)', fmtUSD(paths.expected.at(-1)), X(total - 1), Y(paths.expected.at(-1))]];
285
+ labels.forEach(([t, c, v, x, y]) => {
286
+ svg.appendChild(el('circle', {cx: x, cy: y, r: 3.5, fill: c, stroke: 'var(--surface)',
287
+ 'stroke-width': 2}));
288
+ });
289
+ svg.appendChild(el('text', {x: X(total - 1), y: Y(paths.high.at(-1)) - 6, 'text-anchor': 'end',
290
+ class: 'val'}, 'High ' + fmtUSD(paths.high.at(-1))));
291
+ svg.appendChild(el('text', {x: X(total - 1), y: Y(paths.expected.at(-1)) - 6, 'text-anchor': 'end',
292
+ class: 'val'}, 'Expected ' + fmtUSD(paths.expected.at(-1))));
293
+ svg.appendChild(el('text', {x: X(0), y: H - 8}, history[0].day));
294
+ svg.appendChild(el('text', {x: X(n - 1), y: H - 8, 'text-anchor': 'middle'}, 'today'));
295
+ svg.appendChild(el('text', {x: X(total - 1), y: H - 8, 'text-anchor': 'end'}, 'period end'));
296
+
297
+ const hit = el('rect', {x: m.l, y: m.t, width: iw, height: ih, fill: 'transparent'});
298
+ hit.addEventListener('mousemove', ev => {
299
+ const b = svg.getBoundingClientRect();
300
+ const i = Math.max(0, Math.min(total - 1, Math.round(((ev.clientX - b.left) * (W / b.width) - m.l)
301
+ / (iw / (total - 1)))));
302
+ if (i < n) showTip(`<div class="t">${hist[i].day} · actual</div>
303
+ <div class="row"><span class="k">Cumulative</span><span class="v">${fmtUSD(hist[i].v)}</span></div>`,
304
+ ev.clientX, ev.clientY);
305
+ else {
306
+ const j = i - n + 1;
307
+ showTip(`<div class="t">Day +${j} · forecast</div>` + ['conservative','expected','high']
308
+ .map(k => `<div class="row"><span class="k">${k}</span>
309
+ <span class="v">${fmtUSD(paths[k][j])}</span></div>`).join(''), ev.clientX, ev.clientY);
310
+ }
311
+ });
312
+ hit.addEventListener('mouseleave', hideTip);
313
+ svg.appendChild(hit);
314
+ host.appendChild(svg);
315
+ }
316
+
317
+ /* ---------- sparkline ---------- */
318
+ export function spark(host, values, color = 'var(--s1)', h = 30) {
319
+ host.innerHTML = '';
320
+ if (!values.length) return;
321
+ const W = Math.max(host.clientWidth || 120, 60), max = Math.max(...values, 1e-9);
322
+ const svg = el('svg', {viewBox: `0 0 ${W} ${h}`, height: h});
323
+ const pts = values.map((v, i) => [i * (W / Math.max(values.length - 1, 1)), h - 2 - (v / max) * (h - 4)]);
324
+ svg.appendChild(el('path', {d: pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ','
325
+ + p[1].toFixed(1)).join(' '), fill: 'none', stroke: color, 'stroke-width': 1.6,
326
+ 'stroke-linejoin': 'round'}));
327
+ host.appendChild(svg);
328
+ }
329
+
330
+ /* ---------- legend ---------- */
331
+ export function legend(host, items, onToggle) {
332
+ host.innerHTML = '';
333
+ items.forEach(it => {
334
+ const s = document.createElement('span');
335
+ s.className = 'it' + (it.off ? ' off' : '');
336
+ s.innerHTML = `<span class="swatch" style="background:${it.color}"></span>${it.label}`;
337
+ if (onToggle) { s.style.cursor = 'pointer'; s.onclick = () => onToggle(it.key); }
338
+ host.appendChild(s);
339
+ });
340
+ }
package/web/index.html ADDED
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Claude FinOps Command Center</title>
7
+ <link rel="stylesheet" href="/styles.css">
8
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23eb6834'/><path d='M9 22V13h3.2v9H9zm5.4 0V10h3.2v12h-3.2zm5.4 0v-6H23v6h-3.2z' fill='%23fff'/></svg>">
9
+ </head>
10
+ <body>
11
+ <noscript><p style="padding:24px">This dashboard requires JavaScript.</p></noscript>
12
+ <script type="module" src="/app.js"></script>
13
+ </body>
14
+ </html>