claude-finops 0.4.2 → 0.4.4
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/package.json +1 -1
- package/run.py +121 -7
- package/web/app.js +4 -1
- package/web/styles.css +7 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/run.py
CHANGED
|
@@ -17,6 +17,7 @@ import os
|
|
|
17
17
|
import signal
|
|
18
18
|
import subprocess
|
|
19
19
|
import sys
|
|
20
|
+
import time
|
|
20
21
|
import zipfile
|
|
21
22
|
|
|
22
23
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -44,7 +45,86 @@ def _alive(pid):
|
|
|
44
45
|
return False
|
|
45
46
|
|
|
46
47
|
|
|
47
|
-
def
|
|
48
|
+
def _free_port(start):
|
|
49
|
+
"""The first free port at or above start+1, for the suggestion we print."""
|
|
50
|
+
import socket
|
|
51
|
+
for p in range(start + 1, start + 40):
|
|
52
|
+
with socket.socket() as sk:
|
|
53
|
+
try:
|
|
54
|
+
sk.bind(("127.0.0.1", p))
|
|
55
|
+
return p
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
return start + 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _port_owner(port):
|
|
62
|
+
"""PID listening on 127.0.0.1:<port>, or None.
|
|
63
|
+
|
|
64
|
+
The pidfile is not enough on its own: a dashboard started from a different
|
|
65
|
+
copy (a global npm install alongside a checkout) writes its own, and one
|
|
66
|
+
that was killed hard leaves a stale file behind. Asking the OS who actually
|
|
67
|
+
holds the port is the only answer that is always true.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
if IS_WIN:
|
|
71
|
+
out = subprocess.run(["netstat", "-ano", "-p", "TCP"],
|
|
72
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
73
|
+
for line in out.splitlines():
|
|
74
|
+
f = line.split()
|
|
75
|
+
if len(f) >= 5 and f[1].endswith(f":{port}") and f[3] == "LISTENING":
|
|
76
|
+
return int(f[4])
|
|
77
|
+
return None
|
|
78
|
+
out = subprocess.run(["lsof", "-tnP", f"-iTCP:{port}", "-sTCP:LISTEN"],
|
|
79
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
80
|
+
return int(out.split()[0]) if out.split() else None
|
|
81
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_ours(pid):
|
|
86
|
+
"""True only if that PID is a claude-finops server.
|
|
87
|
+
|
|
88
|
+
Whatever is on the port may be someone else's service. We stop our own
|
|
89
|
+
dashboard without asking; anything else we report and leave alone.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
if IS_WIN:
|
|
93
|
+
out = subprocess.run(
|
|
94
|
+
["wmic", "process", "where", f"ProcessId={pid}", "get", "CommandLine"],
|
|
95
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
96
|
+
else:
|
|
97
|
+
out = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
|
|
98
|
+
capture_output=True, text=True, timeout=10).stdout
|
|
99
|
+
except (OSError, subprocess.SubprocessError):
|
|
100
|
+
return False
|
|
101
|
+
return "finops.api" in out or "claude-finops" in out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _kill(pid):
|
|
105
|
+
if IS_WIN:
|
|
106
|
+
subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
|
|
107
|
+
else:
|
|
108
|
+
os.kill(pid, signal.SIGTERM)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def stop(port=None):
|
|
112
|
+
"""Stop a running dashboard. Returns True if we stopped one."""
|
|
113
|
+
if stop_pidfile():
|
|
114
|
+
return True
|
|
115
|
+
# No usable pidfile: fall back to whoever owns the port, but only if it is
|
|
116
|
+
# ours to stop.
|
|
117
|
+
pid = _port_owner(port or os.environ.get("PORT", "8787"))
|
|
118
|
+
if pid and _is_ours(pid):
|
|
119
|
+
try:
|
|
120
|
+
_kill(pid)
|
|
121
|
+
return True
|
|
122
|
+
except OSError:
|
|
123
|
+
return False
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def stop_pidfile():
|
|
48
128
|
for pidfile in (PIDFILE, LEGACY_PIDFILE): # LEGACY_: a server started before the move
|
|
49
129
|
try:
|
|
50
130
|
with open(pidfile) as fh:
|
|
@@ -55,10 +135,7 @@ def stop():
|
|
|
55
135
|
else:
|
|
56
136
|
return False
|
|
57
137
|
if _alive(pid):
|
|
58
|
-
|
|
59
|
-
subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
|
|
60
|
-
else:
|
|
61
|
-
os.kill(pid, signal.SIGTERM)
|
|
138
|
+
_kill(pid)
|
|
62
139
|
os.remove(pidfile)
|
|
63
140
|
return True
|
|
64
141
|
os.remove(pidfile)
|
|
@@ -105,6 +182,11 @@ Environment:
|
|
|
105
182
|
"""
|
|
106
183
|
|
|
107
184
|
|
|
185
|
+
def _version():
|
|
186
|
+
from finops.update import _installed
|
|
187
|
+
return _installed()
|
|
188
|
+
|
|
189
|
+
|
|
108
190
|
def version():
|
|
109
191
|
"""Which copy is this, and is it current?
|
|
110
192
|
|
|
@@ -222,13 +304,45 @@ def main():
|
|
|
222
304
|
return
|
|
223
305
|
if "--share" in args:
|
|
224
306
|
return share()
|
|
225
|
-
|
|
307
|
+
# A flag we do not know used to fall straight through and start the
|
|
308
|
+
# dashboard, so a typo (or a flag from a newer release than the one you
|
|
309
|
+
# have installed) looked like the command silently doing nothing.
|
|
310
|
+
known = {"--rebuild", "--detach", "--foreground"}
|
|
311
|
+
unknown = [a for a in args if a.startswith("-") and a not in known]
|
|
312
|
+
if unknown:
|
|
313
|
+
print(f"Unknown option: {unknown[0]}")
|
|
314
|
+
print(f"This is claude-finops {_version() or 'unknown'}; "
|
|
315
|
+
f"run --help to see what it supports.")
|
|
316
|
+
sys.exit(2)
|
|
226
317
|
port = os.environ.get("PORT", "8787")
|
|
318
|
+
# Decide from who actually holds *this* port, not from the pidfile alone:
|
|
319
|
+
# the pidfile may describe a dashboard serving some other port entirely.
|
|
320
|
+
owner = _port_owner(port)
|
|
321
|
+
if owner is None or _is_ours(owner):
|
|
322
|
+
if owner is not None:
|
|
323
|
+
# flush=True: execv below replaces this process without flushing,
|
|
324
|
+
# so an unflushed line would simply never reach your terminal.
|
|
325
|
+
print(f"Restarting the dashboard already running on {port} …", flush=True)
|
|
326
|
+
stop(port) # replace it, whoever started it
|
|
327
|
+
for _ in range(20): # give the socket a moment to clear
|
|
328
|
+
if _port_owner(port) is None:
|
|
329
|
+
break
|
|
330
|
+
time.sleep(0.25)
|
|
331
|
+
# Still occupied means it is not ours: say so instead of dying in a traceback.
|
|
332
|
+
if (busy := _port_owner(port)) is not None:
|
|
333
|
+
free = _free_port(int(port))
|
|
334
|
+
print(f"Port {port} is already in use by PID {busy}, and it is not a "
|
|
335
|
+
f"claude-finops dashboard.")
|
|
336
|
+
print(f"Start on another port: PORT={free} claude-finops")
|
|
337
|
+
print(f"Or find out what is holding it: "
|
|
338
|
+
+ (f"netstat -ano -p TCP | findstr :{port}" if IS_WIN
|
|
339
|
+
else f"lsof -iTCP:{port} -sTCP:LISTEN"))
|
|
340
|
+
sys.exit(1)
|
|
227
341
|
source = os.environ.get("CLAUDE_PROJECTS", os.path.join(os.path.expanduser("~"), ".claude", "projects"))
|
|
228
342
|
if "--rebuild" in args or not os.path.exists(DB_PATH):
|
|
229
343
|
if not os.path.isdir(source):
|
|
230
344
|
sys.exit(f"No Claude Code transcripts at {source}. Use Claude Code once, or set CLAUDE_PROJECTS.")
|
|
231
|
-
print(f"Building warehouse from {source} …")
|
|
345
|
+
print(f"Building warehouse from {source} …", flush=True)
|
|
232
346
|
subprocess.run(_python() + ["-m", "finops.etl", source], check=True)
|
|
233
347
|
rest = [a for a in args if a != "--rebuild"]
|
|
234
348
|
if IS_WIN:
|
package/web/app.js
CHANGED
|
@@ -485,7 +485,10 @@ async function openSession(id) {
|
|
|
485
485
|
/* ---------- charts added to table-first pages ---------- */
|
|
486
486
|
// Inserts a chart card after the page's KPI row (or at the top) and draws into it.
|
|
487
487
|
function addChart(page, title, draw, opts = {}) {
|
|
488
|
-
|
|
488
|
+
// .chart carries every chart style (text fill, gridlines, axes); .cchart is
|
|
489
|
+
// just the hook this helper looks up. Missing .chart left SVG labels at the
|
|
490
|
+
// browser default fill — black text, invisible on the dark theme.
|
|
491
|
+
const c = h(card(title, '<div class="chart cchart"></div>', opts));
|
|
489
492
|
const anchor = opts.after ? page.querySelector(opts.after) : page.querySelector(':scope > .grid');
|
|
490
493
|
if (anchor) anchor.after(c); else page.prepend(c);
|
|
491
494
|
draw(c.querySelector('.cchart'));
|
package/web/styles.css
CHANGED
|
@@ -76,15 +76,19 @@ button, input, select { font: inherit; color: inherit; }
|
|
|
76
76
|
|
|
77
77
|
/* ---------- shell ---------- */
|
|
78
78
|
.app { display: grid; grid-template-columns: 216px 1fr; min-height: 100vh; }
|
|
79
|
+
/* The sidebar itself no longer scrolls: the nav does. A sticky footer inside a
|
|
80
|
+
scrolling column still has the rest of the list running underneath it, which
|
|
81
|
+
showed as nav items bleeding through the account block. */
|
|
79
82
|
.sidebar { background: var(--surface); border-right: 1px solid var(--border);
|
|
80
|
-
padding: 14px 0; position: sticky; top:0; height:100vh; overflow
|
|
83
|
+
padding: 14px 0; position: sticky; top:0; height:100vh; overflow:hidden;
|
|
81
84
|
display:flex; flex-direction:column; }
|
|
82
85
|
.brand { padding: 0 16px 14px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
|
|
83
86
|
.brand .mark { display:flex; align-items:center; gap:8px; font-weight:660; font-size:14px;
|
|
84
87
|
letter-spacing:-0.01em; }
|
|
85
88
|
.brand .dot { width:9px; height:9px; border-radius:3px; background:var(--s2); flex:none; }
|
|
86
89
|
.brand .sub { font-size:10.5px; color:var(--muted); margin-top:3px; letter-spacing:.02em; }
|
|
87
|
-
.nav { display:flex; flex-direction:column; gap:1px; padding: 0 8px;
|
|
90
|
+
.nav { display:flex; flex-direction:column; gap:1px; padding: 0 8px;
|
|
91
|
+
flex:1 1 auto; min-height:0; overflow-y:auto; }
|
|
88
92
|
.nav .group { font-size:9.5px; text-transform:uppercase; letter-spacing:.09em; color:var(--muted);
|
|
89
93
|
padding: 13px 8px 5px; font-weight:600; }
|
|
90
94
|
.nav a { display:flex; align-items:center; gap:8px; padding:6px 9px; border-radius:7px;
|
|
@@ -401,8 +405,7 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
401
405
|
so every screenshot carries the name of the account it came from. */
|
|
402
406
|
/* Sticky, because the nav is taller than the viewport and an identity you have
|
|
403
407
|
to scroll to find is one nobody checks. */
|
|
404
|
-
.who {
|
|
405
|
-
display:flex; align-items:center; gap:9px;
|
|
408
|
+
.who { flex:none; display:flex; align-items:center; gap:9px;
|
|
406
409
|
padding:12px 16px; background:var(--surface);
|
|
407
410
|
border-top:1px solid var(--border); }
|
|
408
411
|
.who:empty { display:none; }
|