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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.4.2",
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 stop():
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
- if IS_WIN:
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
- stop() # replace a previous detached server
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
- const c = h(card(title, '<div class="cchart"></div>', opts));
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-y:auto;
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 { margin-top:auto; position:sticky; bottom:0; z-index:2;
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; }