claude-finops 0.9.0 → 0.10.1
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/README.md +40 -5
- package/finops/act_now.py +92 -0
- package/finops/analytics.py +25 -6
- package/finops/api.py +6 -0
- package/finops/integrate.py +31 -16
- package/package.json +1 -1
- package/run.py +4 -2
- package/web/app.js +167 -26
- package/web/styles.css +17 -1
package/README.md
CHANGED
|
@@ -63,6 +63,13 @@ npm install -g claude-finops # then `claude-finops` from anywhere
|
|
|
63
63
|
|
|
64
64
|
## What you actually get
|
|
65
65
|
|
|
66
|
+
**It acts, not just reports.** The overview opens with **⚡ Act now**: one-click
|
|
67
|
+
actions that apply to you right now. Compact a running session that is carrying too
|
|
68
|
+
much context (it types `/compact` into that session's terminal), hand it over to a
|
|
69
|
+
fresh session, turn a shell command you keep re-running into a skill, or move an
|
|
70
|
+
instruction you keep re-typing into `CLAUDE.md`. Every session row has **Resume**
|
|
71
|
+
(copies `claude --resume <id>`), and running ones have **Compact**.
|
|
72
|
+
|
|
66
73
|
**Where the money goes, per project.** Every project ranked by cost, drilling down
|
|
67
74
|
Project → Session → Prompt.
|
|
68
75
|
|
|
@@ -140,17 +147,21 @@ labelled as your own configured figures.
|
|
|
140
147
|
|
|
141
148
|
## What is in it
|
|
142
149
|
|
|
143
|
-
**Command center** — Executive overview (spend, tokens, usage %, remaining, forecast
|
|
144
|
-
|
|
150
|
+
**Command center** — Executive overview (spend, tokens, usage %, remaining, forecast,
|
|
151
|
+
today vs yesterday) opening with the ⚡ Act now strip, running sessions you can
|
|
152
|
+
interrupt, compact, hand over or close, an AI FinOps Advisor that answers "what should I do today?" from live data, and a
|
|
145
153
|
0–100 FinOps scorecard with per-dimension reasoning.
|
|
146
154
|
|
|
147
155
|
**Usage** — Interactive timeline across 10 metrics and 6 time ranges with day
|
|
148
|
-
drill-down
|
|
156
|
+
drill-down, plus a weekday × hour peak-hours heatmap in your local time; burn rate and
|
|
157
|
+
limits with a gauge, days-until-limit and projected overage, and your 5-hour and weekly
|
|
158
|
+
plan-limit history from the Claude desktop app;
|
|
149
159
|
per-model FinOps table with superlatives (most expensive, most used, most
|
|
150
160
|
token-efficient, best cost-per-output); context-size distribution and cache
|
|
151
161
|
with-vs-without analysis.
|
|
152
162
|
|
|
153
|
-
**Drill-down** — Project → Session → Prompt, everywhere
|
|
163
|
+
**Drill-down** — Project → Session → Prompt, everywhere, with Resume (and Compact for
|
|
164
|
+
running sessions) on every session row. A prompt explorer over every
|
|
154
165
|
prompt with full text, category, tokens, cache split, tool calls, files touched,
|
|
155
166
|
latency and efficiency; five leaderboards; prompt intelligence (spend by activity);
|
|
156
167
|
and a Claude Code view (tools, files, branches, cost per repository).
|
|
@@ -212,12 +223,14 @@ depends entirely on how agentic your workload is.
|
|
|
212
223
|
|
|
213
224
|
```
|
|
214
225
|
~/.claude/projects/**/*.jsonl
|
|
226
|
+
<Claude desktop app>/local-agent-mode-sessions/**/.claude/projects/**/*.jsonl (Cowork)
|
|
215
227
|
│
|
|
216
228
|
▼ finops/etl.py stream-parse, normalize, roll up
|
|
217
229
|
~/.claude-finops/data/finops.db SQLite warehouse
|
|
218
230
|
│
|
|
219
231
|
▼ finops/analytics.py KPIs · burn · forecast · waste · anomalies · scorecard
|
|
220
232
|
finops/api.py stdlib HTTP: JSON API + static files (127.0.0.1 only)
|
|
233
|
+
(+ <Claude desktop app>/plan-usage-history.json, read on request)
|
|
221
234
|
│
|
|
222
235
|
▼ web/ vanilla JS, inline-SVG charts, no build step
|
|
223
236
|
```
|
|
@@ -262,6 +275,8 @@ claude-finops --stop stop it
|
|
|
262
275
|
claude-finops --where where your data, settings and keys live
|
|
263
276
|
claude-finops --set-key store a provider API key (hidden prompt, 0600)
|
|
264
277
|
claude-finops --keys which provider keys are configured
|
|
278
|
+
claude-finops --install-statusline model, context % and 5-hour limit under every prompt
|
|
279
|
+
claude-finops --uninstall-statusline remove it again
|
|
265
280
|
claude-finops --help everything
|
|
266
281
|
```
|
|
267
282
|
|
|
@@ -293,6 +308,12 @@ place for you to delete once you are happy.
|
|
|
293
308
|
|
|
294
309
|
## Privacy
|
|
295
310
|
|
|
311
|
+
**What it reads, all read-only:** Claude Code transcripts in `~/.claude/projects`, and,
|
|
312
|
+
if the Claude desktop app is installed, its Cowork transcripts and
|
|
313
|
+
`plan-usage-history.json` (`~/Library/Application Support/Claude` on macOS,
|
|
314
|
+
`%APPDATA%\Claude` on Windows, `~/.config/Claude` on Linux), plus the other coding
|
|
315
|
+
agents listed under Multi-agent.
|
|
316
|
+
|
|
296
317
|
`~/.claude-finops/data/finops.db` and the prompt/CSV exports contain **your full prompt text**. The
|
|
297
318
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
298
319
|
generate as sensitive.
|
|
@@ -310,7 +331,8 @@ configure a key with `--set-key`.
|
|
|
310
331
|
|
|
311
332
|
The app has nothing tied to one person. Whoever runs it sees **their own** Claude usage:
|
|
312
333
|
|
|
313
|
-
- It reads `~/.claude/projects` on the machine it runs on (override with `CLAUDE_PROJECTS=/path`)
|
|
334
|
+
- It reads `~/.claude/projects` on the machine it runs on (override with `CLAUDE_PROJECTS=/path`),
|
|
335
|
+
plus the Claude desktop app's Cowork sessions when the default location is used.
|
|
314
336
|
- The account label comes from that machine's `~/.claude.json`.
|
|
315
337
|
- Budgets and limits you set in the UI are saved to `~/.claude-finops/settings.local.json`. The committed `config/settings.json` holds only shared defaults.
|
|
316
338
|
|
|
@@ -344,6 +366,19 @@ files on Windows.
|
|
|
344
366
|
`config/free_models.json`.
|
|
345
367
|
- **Skills & MCP** suggests MCP servers and skills from work you repeat, with the evidence.
|
|
346
368
|
"Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
|
|
369
|
+
The strongest skill suggestions also appear in **⚡ Act now** on the overview.
|
|
370
|
+
- **Compact** (Running sessions, Act now, session rows) types `/compact` into that session's
|
|
371
|
+
terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
|
|
372
|
+
command for you to paste. It asks you to click twice.
|
|
373
|
+
- **Hand over / split** (Running sessions) writes a brief of the session to
|
|
374
|
+
`~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
|
|
375
|
+
you list them; it can close the old session (resumable).
|
|
376
|
+
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal.
|
|
377
|
+
Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
378
|
+
interrupt one console process without hitting the others in its window.
|
|
379
|
+
- **Install statusline** (Act now, or `--install-statusline`) adds a `statusLine` entry to
|
|
380
|
+
`~/.claude/settings.json`, keeping a `.finops-backup` copy. It won't replace a statusline you
|
|
381
|
+
already have.
|
|
347
382
|
|
|
348
383
|
---
|
|
349
384
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""What to do right now: the few one-click actions that apply to this machine today.
|
|
2
|
+
|
|
3
|
+
Everything here already exists deeper in the dashboard (Running sessions, Skills &
|
|
4
|
+
MCP, Why so many tokens?). This picks the high-signal items only, so the overview
|
|
5
|
+
can act instead of just reporting:
|
|
6
|
+
|
|
7
|
+
session a running session carrying heavy context -> Compact / Hand over
|
|
8
|
+
skill a shell command you keep re-running -> Create skill
|
|
9
|
+
memory an instruction you keep re-typing -> Add to CLAUDE.md
|
|
10
|
+
statusline no statusline configured -> Install
|
|
11
|
+
|
|
12
|
+
Prompt-based skill suggestions and one-off memory lines are left to their own pages:
|
|
13
|
+
they are noisy enough that a one-click card would mostly be wrong.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
MAX_SESSIONS = 3
|
|
17
|
+
MAX_SKILLS = 2
|
|
18
|
+
MAX_MEMORY = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _k(n):
|
|
22
|
+
return f"{n / 1000:.0f}K" if n < 1_000_000 else f"{n / 1_000_000:.1f}M"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build(live, skills, memory, statusline):
|
|
26
|
+
"""live: procs.list_sessions rows; skills: actions.suggestions()["skills"];
|
|
27
|
+
memory: Diagnoser.memory_suggestions(); statusline: integrate.statusline_state()."""
|
|
28
|
+
items = []
|
|
29
|
+
heavy = sorted((x for x in live if x.get("severity") in ("medium", "high") and x.get("context")),
|
|
30
|
+
key=lambda x: -x["context"])[:MAX_SESSIONS]
|
|
31
|
+
for x in heavy:
|
|
32
|
+
items.append({
|
|
33
|
+
"kind": "session", "severity": x["severity"], "pid": x.get("pid"),
|
|
34
|
+
"session_id": x.get("session_id"), "hosts_dashboard": bool(x.get("hosts_dashboard")),
|
|
35
|
+
"title": f"{x.get('name') or 'A session'} is carrying {_k(x['context'])} tokens of context",
|
|
36
|
+
"detail": f"{x.get('project') or ''} · every message re-reads all of it. "
|
|
37
|
+
+ ("Hand it over to a fresh session." if x["severity"] == "high"
|
|
38
|
+
else "Compact it at the next break."),
|
|
39
|
+
})
|
|
40
|
+
cmds = sorted((s for s in skills if s.get("kind") == "command" and not s.get("installed")),
|
|
41
|
+
key=lambda s: -s.get("runs", 0))[:MAX_SKILLS]
|
|
42
|
+
for s in cmds:
|
|
43
|
+
items.append({
|
|
44
|
+
"kind": "skill", "skill": s,
|
|
45
|
+
"title": f"You ran `{s['trigger']}` {s['runs']} times in {s['sessions']} sessions",
|
|
46
|
+
"detail": "Make it a skill so Claude runs it the way this project expects, "
|
|
47
|
+
"without re-explaining each time.",
|
|
48
|
+
})
|
|
49
|
+
themes = sorted((m for m in memory if m.get("kind") == "theme" and not m.get("already_saved")),
|
|
50
|
+
key=lambda m: -m.get("sessions", 0))[:MAX_MEMORY]
|
|
51
|
+
for m in themes:
|
|
52
|
+
items.append({
|
|
53
|
+
"kind": "memory",
|
|
54
|
+
"title": f"You keep re-typing instructions about: {m['text']}",
|
|
55
|
+
"detail": f"Seen in {m['sessions']} sessions. Put it in {m['target']} once instead.",
|
|
56
|
+
})
|
|
57
|
+
if statusline is None:
|
|
58
|
+
items.append({
|
|
59
|
+
"kind": "statusline",
|
|
60
|
+
"title": "See model, context % and 5-hour limit under every Claude Code prompt",
|
|
61
|
+
"detail": "Adds a statusline to ~/.claude/settings.json (a backup is kept). "
|
|
62
|
+
"Undo any time with claude-finops --uninstall-statusline.",
|
|
63
|
+
})
|
|
64
|
+
return {"items": items}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def act_now(a):
|
|
68
|
+
"""Gather the live inputs and build the strip. Each source fails soft."""
|
|
69
|
+
from .procs import list_sessions
|
|
70
|
+
from .actions import suggestions
|
|
71
|
+
from .diagnose import Diagnoser
|
|
72
|
+
from .integrate import statusline_state
|
|
73
|
+
|
|
74
|
+
def safe(fn, default):
|
|
75
|
+
try:
|
|
76
|
+
return fn()
|
|
77
|
+
except Exception:
|
|
78
|
+
return default
|
|
79
|
+
|
|
80
|
+
live = safe(lambda: list_sessions(a.pricing), [])
|
|
81
|
+
# the process registry names sessions after their folder; the transcript title reads better
|
|
82
|
+
ids = [x["session_id"] for x in live if x.get("session_id")]
|
|
83
|
+
titles = safe(lambda: {r["id"]: r["title"] for r in a.q(
|
|
84
|
+
"SELECT id, title FROM sessions WHERE title IS NOT NULL AND id IN (%s)" % ",".join("?" * len(ids)),
|
|
85
|
+
ids)} if ids else {}, {})
|
|
86
|
+
for x in live:
|
|
87
|
+
if titles.get(x.get("session_id")):
|
|
88
|
+
x["name"] = titles[x["session_id"]]
|
|
89
|
+
return build(live,
|
|
90
|
+
safe(lambda: suggestions(a)["skills"], []),
|
|
91
|
+
safe(lambda: Diagnoser(a).memory_suggestions({}), []),
|
|
92
|
+
safe(statusline_state, "other"))
|
package/finops/analytics.py
CHANGED
|
@@ -168,6 +168,16 @@ def _cumsum(values):
|
|
|
168
168
|
yield total
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
def resume_command(sid, agent, source_file):
|
|
172
|
+
"""`claude --resume <id>` for a Claude Code session; None where it would not work.
|
|
173
|
+
|
|
174
|
+
Cowork transcripts live under the desktop app's own config dir, where
|
|
175
|
+
`claude --resume` would not find them.
|
|
176
|
+
"""
|
|
177
|
+
cowork = "local-agent-mode-sessions" in (source_file or "")
|
|
178
|
+
return f"claude --resume {sid}" if (agent or "claude") == "claude" and not cowork else None
|
|
179
|
+
|
|
180
|
+
|
|
171
181
|
class Analytics:
|
|
172
182
|
def __init__(self, db_path=DB_PATH):
|
|
173
183
|
# One connection per thread. The HTTP server is threaded, and a single sqlite
|
|
@@ -561,6 +571,7 @@ class Analytics:
|
|
|
561
571
|
rows = self.q(f"""
|
|
562
572
|
SELECT s.id session_id, s.title, s.git_branch, s.cli_version, s.started_at,
|
|
563
573
|
s.ended_at, s.duration_s, s.files_touched, pr.name project, pr.id project_id,
|
|
574
|
+
s.agent, s.source_file,
|
|
564
575
|
COUNT(DISTINCT r.prompt_id) prompts, COUNT(*) requests,
|
|
565
576
|
SUM(r.tool_call_count) tool_calls,
|
|
566
577
|
SUM(r.input_tokens) input_tokens, SUM(r.output_tokens) output_tokens,
|
|
@@ -579,6 +590,7 @@ class Analytics:
|
|
|
579
590
|
r["output_ratio"] = (r["output_tokens"] or 0) / r["tokens"] if r["tokens"] else 0
|
|
580
591
|
cr, cw = r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0
|
|
581
592
|
r["cache_hit_ratio"] = (cr / (cr + cw)) if (cr + cw) else None
|
|
593
|
+
r["resume"] = resume_command(r["session_id"], r.pop("agent"), r.pop("source_file"))
|
|
582
594
|
return rows
|
|
583
595
|
|
|
584
596
|
def sessions_total(self, f=None):
|
|
@@ -699,11 +711,7 @@ class Analytics:
|
|
|
699
711
|
s["files"] = self.q(
|
|
700
712
|
"SELECT path, GROUP_CONCAT(DISTINCT op) ops, COUNT(*) n FROM files_touched"
|
|
701
713
|
" WHERE session_id=? GROUP BY path ORDER BY n DESC LIMIT 100", (sid,))
|
|
702
|
-
|
|
703
|
-
# `claude --resume` would not find them.
|
|
704
|
-
cowork = "local-agent-mode-sessions" in (s.get("source_file") or "")
|
|
705
|
-
s["resume"] = (f"claude --resume {sid}"
|
|
706
|
-
if (s.get("agent") or "claude") == "claude" and not cowork else None)
|
|
714
|
+
s["resume"] = resume_command(sid, s.get("agent"), s.get("source_file"))
|
|
707
715
|
return s
|
|
708
716
|
|
|
709
717
|
# ---------------- categories ----------------
|
|
@@ -725,7 +733,7 @@ class Analytics:
|
|
|
725
733
|
|
|
726
734
|
# ---------------- leaderboards ----------------
|
|
727
735
|
def leaderboards(self, f=None, n=20):
|
|
728
|
-
|
|
736
|
+
out = {
|
|
729
737
|
"most_expensive": self.prompts(f, limit=n, order="cost"),
|
|
730
738
|
"most_token_heavy": self.prompts(f, limit=n, order="tokens"),
|
|
731
739
|
"cheapest": self.prompts(f, limit=n, order="cheapest"),
|
|
@@ -733,6 +741,17 @@ class Analytics:
|
|
|
733
741
|
"longest_sessions": self.sessions(f, limit=n, order="duration"),
|
|
734
742
|
"basis": "estimated",
|
|
735
743
|
}
|
|
744
|
+
prompt_rows = [r for k in ("most_expensive", "most_token_heavy", "cheapest", "most_efficient")
|
|
745
|
+
for r in out[k]]
|
|
746
|
+
sids = sorted({r["session_id"] for r in prompt_rows if r.get("session_id")})
|
|
747
|
+
src = {r["id"]: r for r in self.q(
|
|
748
|
+
"SELECT id, agent, source_file FROM sessions WHERE id IN (%s)" % ",".join("?" * len(sids)),
|
|
749
|
+
sids)} if sids else {}
|
|
750
|
+
for r in prompt_rows:
|
|
751
|
+
s = src.get(r.get("session_id")) or {}
|
|
752
|
+
r["resume"] = (resume_command(r["session_id"], s.get("agent"), s.get("source_file"))
|
|
753
|
+
if s else None)
|
|
754
|
+
return out
|
|
736
755
|
|
|
737
756
|
# ---------------- efficiency ----------------
|
|
738
757
|
def hygiene(self, f=None, top=12, trajectory_points=80):
|
package/finops/api.py
CHANGED
|
@@ -270,6 +270,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
270
270
|
return self.send_json({"job": j["id"]})
|
|
271
271
|
if parts == ["skill"]:
|
|
272
272
|
return self.send_json(X.create_skill(payload))
|
|
273
|
+
if parts == ["statusline"]:
|
|
274
|
+
from .integrate import install_statusline
|
|
275
|
+
return self.send_json(install_statusline())
|
|
273
276
|
if len(parts) == 2 and parts[0] == "mcp":
|
|
274
277
|
return self.send_json(X.add_mcp(parts[1], payload))
|
|
275
278
|
return self.send_json({"error": "unknown action"}, 404)
|
|
@@ -390,6 +393,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
390
393
|
return self.send_json(a.timeline(f, g("grain", "day")))
|
|
391
394
|
if route == "heatmap":
|
|
392
395
|
return self.send_json(a.heatmap(f))
|
|
396
|
+
if route == "act_now":
|
|
397
|
+
from .act_now import act_now
|
|
398
|
+
return self.send_json(act_now(a))
|
|
393
399
|
if route == "models":
|
|
394
400
|
return self.send_json(a.models(f))
|
|
395
401
|
if route == "projects":
|
package/finops/integrate.py
CHANGED
|
@@ -12,6 +12,7 @@ fail silent, never block, never take long.
|
|
|
12
12
|
"""
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
|
+
import shlex
|
|
15
16
|
import shutil
|
|
16
17
|
import sys
|
|
17
18
|
|
|
@@ -59,14 +60,17 @@ def hook():
|
|
|
59
60
|
# -------------------------------------------------------------- statusline ----
|
|
60
61
|
|
|
61
62
|
def statusline():
|
|
62
|
-
"""One line, refreshed constantly: model and
|
|
63
|
+
"""One line, refreshed constantly: model, context usage and the 5-hour limit."""
|
|
63
64
|
try:
|
|
64
65
|
d = _stdin_json()
|
|
65
|
-
pct = _dig(d, "context.percentUsed", "context.percent_used")
|
|
66
|
+
pct = _dig(d, "context_window.used_percentage", "context.percentUsed", "context.percent_used")
|
|
67
|
+
five = _dig(d, "rate_limits.five_hour.used_percentage")
|
|
66
68
|
name = _dig(d, "model.display_name", "session.model", "model") or "claude"
|
|
67
69
|
bits = [str(name)]
|
|
68
70
|
if isinstance(pct, (int, float)):
|
|
69
71
|
bits.append(f"{pct:.0f}% ctx")
|
|
72
|
+
if isinstance(five, (int, float)):
|
|
73
|
+
bits.append(f"{five:.0f}% 5h")
|
|
70
74
|
print(" · ".join(bits))
|
|
71
75
|
except Exception:
|
|
72
76
|
print("") # an empty statusline beats a stack trace under the prompt
|
|
@@ -81,7 +85,8 @@ def _command():
|
|
|
81
85
|
if exe:
|
|
82
86
|
return exe
|
|
83
87
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
84
|
-
|
|
88
|
+
# settings run this through a shell: an install path with a space would split
|
|
89
|
+
return f"{shlex.quote(sys.executable)} {shlex.quote(os.path.join(root, 'run.py'))}"
|
|
85
90
|
|
|
86
91
|
|
|
87
92
|
def _load_settings():
|
|
@@ -113,21 +118,31 @@ def install_hook(remove=False):
|
|
|
113
118
|
print("The statusline (claude-finops --install-statusline) still shows model and context %.")
|
|
114
119
|
|
|
115
120
|
|
|
121
|
+
def statusline_state():
|
|
122
|
+
""""ours" when our statusline is installed, "other" for someone else's, else None."""
|
|
123
|
+
cur = _load_settings().get("statusLine")
|
|
124
|
+
if not cur:
|
|
125
|
+
return None
|
|
126
|
+
return "ours" if "finops" in json.dumps(cur) else "other"
|
|
127
|
+
|
|
128
|
+
|
|
116
129
|
def install_statusline(remove=False):
|
|
117
|
-
|
|
130
|
+
"""Wire the statusline into ~/.claude/settings.json. Returns {"ok", "message"}.
|
|
131
|
+
|
|
132
|
+
Claude Code wants {"type": "command", "command": ...}; an existing statusline that
|
|
133
|
+
isn't ours is left alone.
|
|
134
|
+
"""
|
|
118
135
|
s = _load_settings()
|
|
119
136
|
if remove:
|
|
120
|
-
if
|
|
137
|
+
if statusline_state() == "ours":
|
|
121
138
|
s.pop("statusLine", None)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
s["statusLine"] = cmd
|
|
139
|
+
_save_settings(s)
|
|
140
|
+
return {"ok": True, "message": f"Removed the statusline from {SETTINGS}"}
|
|
141
|
+
if statusline_state() == "other":
|
|
142
|
+
return {"ok": False, "message": "You already have a statusline configured "
|
|
143
|
+
f"({json.dumps(s['statusLine'])}). Left it alone: remove it first if you want ours."}
|
|
144
|
+
s["statusLine"] = {"type": "command", "command": f"{_command()} --statusline"}
|
|
129
145
|
_save_settings(s)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
print(" Undo: claude-finops --uninstall-statusline")
|
|
146
|
+
return {"ok": True, "message": f"Installed the statusline in {SETTINGS}. It shows the model, "
|
|
147
|
+
"context % and 5-hour limit % under every prompt. "
|
|
148
|
+
"Undo: claude-finops --uninstall-statusline"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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
|
@@ -153,7 +153,7 @@ HELP = """Claude FinOps Command Center
|
|
|
153
153
|
claude-finops --version print the installed version, and whether a newer one is out
|
|
154
154
|
claude-finops --no-update-check skip the once-a-day npm version check
|
|
155
155
|
claude-finops --install-hook retired no-op (prints a message, changes nothing)
|
|
156
|
-
claude-finops --install-statusline show model
|
|
156
|
+
claude-finops --install-statusline show model, context % and 5-hour limit in your statusline
|
|
157
157
|
claude-finops --help this message
|
|
158
158
|
|
|
159
159
|
Environment:
|
|
@@ -296,7 +296,9 @@ def main():
|
|
|
296
296
|
return install_hook(remove="--uninstall-hook" in args)
|
|
297
297
|
if "--install-statusline" in args or "--uninstall-statusline" in args:
|
|
298
298
|
from finops.integrate import install_statusline
|
|
299
|
-
|
|
299
|
+
r = install_statusline(remove="--uninstall-statusline" in args)
|
|
300
|
+
print(r["message"])
|
|
301
|
+
return sys.exit(0 if r["ok"] else 1)
|
|
300
302
|
if "--where" in args:
|
|
301
303
|
return where()
|
|
302
304
|
if "--keys" in args:
|
package/web/app.js
CHANGED
|
@@ -685,10 +685,110 @@ VIEWS.overview = async (page) => {
|
|
|
685
685
|
api('diagnose').then(d => {
|
|
686
686
|
if (!page.isConnected) return;
|
|
687
687
|
const el = h(focusStrip(d) || '<div></div>');
|
|
688
|
-
page.insertBefore(el, page.firstChild);
|
|
688
|
+
page.insertBefore(el, page.querySelector('.actnow')?.nextSibling || page.firstChild);
|
|
689
|
+
page._focusData = d;
|
|
689
690
|
wireFocus(page);
|
|
690
691
|
}).catch(() => {});
|
|
692
|
+
if (hasClaude()) fetch('/api/act_now').then(r => r.json()).then(d => {
|
|
693
|
+
if (!page.isConnected || !(d.items || []).length) return;
|
|
694
|
+
const el = h(actNowStrip(d.items));
|
|
695
|
+
page.insertBefore(el, page.firstChild);
|
|
696
|
+
wireActNow(el, d.items);
|
|
697
|
+
}).catch(() => {});
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/* ---------- act now: one-click actions, on the page everyone opens first ---------- */
|
|
701
|
+
const ACT_ICON = {session: '🗜', skill: '⚙', memory: '📝', statusline: '▭'};
|
|
702
|
+
const codeTicks = t => esc(t).replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
703
|
+
function actNowStrip(items) {
|
|
704
|
+
const btn = it => it.kind === 'session'
|
|
705
|
+
? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>'
|
|
706
|
+
+ '<button class="act ghost" data-do="handover">⇢ Hand over</button>'
|
|
707
|
+
: it.kind === 'skill' ? '<button class="act" data-do="skill">+ Create skill</button>'
|
|
708
|
+
: it.kind === 'memory' ? '<button class="act ghost" data-do="memory">Show examples</button>'
|
|
709
|
+
: '<button class="act" data-do="statusline">Install statusline</button>';
|
|
710
|
+
return `<section class="actnow"><div class="focus-hd">⚡ Act now
|
|
711
|
+
<span class="note">one click each · nothing changes until you click</span></div>
|
|
712
|
+
${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
|
|
713
|
+
<span class="ai">${ACT_ICON[it.kind] || '•'}</span>
|
|
714
|
+
<div class="ft"><b>${codeTicks(it.title)}</b><div class="note">${esc(it.detail)}</div></div>
|
|
715
|
+
<div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
|
|
716
|
+
}
|
|
717
|
+
function wireActNow(root, items) {
|
|
718
|
+
root.querySelectorAll('.actnow-it').forEach(row => {
|
|
719
|
+
const it = items[+row.dataset.i], msg = row.querySelector('.live-msg');
|
|
720
|
+
const say = (t, ok) => { msg.textContent = t; msg.className = 'live-msg ' + (ok ? 'ok' : 'err'); };
|
|
721
|
+
row.querySelectorAll('[data-do]').forEach(b => b.onclick = async () => {
|
|
722
|
+
const what = b.dataset.do;
|
|
723
|
+
if (what === 'handover') return go('live');
|
|
724
|
+
if (what === 'memory') return jumpTo('diagnose', 'dx-mem');
|
|
725
|
+
if (what === 'compact' && !armed(b)) return;
|
|
726
|
+
b.disabled = true;
|
|
727
|
+
try {
|
|
728
|
+
if (what === 'compact') {
|
|
729
|
+
const r = await sessionAction(it.pid, 'compact', {agent: 'claude'});
|
|
730
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
731
|
+
say(r.ok ? r.message : `Failed: ${r.error}`, r.ok);
|
|
732
|
+
b.disabled = false; return;
|
|
733
|
+
}
|
|
734
|
+
const r = what === 'skill' ? await doAction('skill', it.skill) : await doAction('statusline');
|
|
735
|
+
say(what === 'skill' ? `Created ${r.path}. Use it with ${r.use}.` : r.message, r.ok !== false);
|
|
736
|
+
if (r.ok !== false) b.remove(); else b.disabled = false;
|
|
737
|
+
} catch (e) { say('Failed: ' + e.message, false); b.disabled = false; }
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// Two-click confirm, inline (no browser dialogs): the first click arms, the second runs.
|
|
742
|
+
function armed(b) {
|
|
743
|
+
if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = b.dataset.label; return true; }
|
|
744
|
+
b.dataset.label = b.textContent; b.classList.add('armed'); b.textContent = 'Click again to confirm';
|
|
745
|
+
setTimeout(() => { if (b.classList.contains('armed')) { b.classList.remove('armed'); b.textContent = b.dataset.label; } }, 4000);
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
// Open a view and scroll to an element that appears once the (possibly slow) view renders.
|
|
749
|
+
async function jumpTo(view, anchor) {
|
|
750
|
+
if (S.view !== view) go(view);
|
|
751
|
+
for (let i = 0; i < 60; i++) {
|
|
752
|
+
const el = document.getElementById(anchor);
|
|
753
|
+
if (el) { el.scrollIntoView({behavior: 'smooth', block: 'start'}); el.classList.add('flash');
|
|
754
|
+
setTimeout(() => el.classList.remove('flash'), 1600); return; }
|
|
755
|
+
await new Promise(r => setTimeout(r, 250));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/* ---------- row actions: Resume (copy) and, for a running session, Compact ---------- */
|
|
760
|
+
async function liveSessions() {
|
|
761
|
+
try {
|
|
762
|
+
const d = await fetch('/api/live?agents=claude').then(r => r.json());
|
|
763
|
+
return new Map((d.sessions || []).filter(x => x.signalable && x.pid).map(x => [x.session_id, x.pid]));
|
|
764
|
+
} catch { return new Map(); }
|
|
765
|
+
}
|
|
766
|
+
const rowActs = (r, live) => {
|
|
767
|
+
const pid = live && live.get(r.session_id);
|
|
768
|
+
return `<span class="row-acts">${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}"
|
|
769
|
+
title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
|
|
770
|
+
title="Running now: types /compact into its terminal">🗜 Compact</button>` : ''}</span>`;
|
|
691
771
|
};
|
|
772
|
+
function wireRowActs(host) {
|
|
773
|
+
if (!host) return;
|
|
774
|
+
host.querySelectorAll('[data-resume]').forEach(b => b.onclick = async e => {
|
|
775
|
+
e.stopPropagation();
|
|
776
|
+
try { await navigator.clipboard.writeText(b.dataset.resume); b.textContent = '✓ Copied'; }
|
|
777
|
+
catch { b.textContent = b.dataset.resume; }
|
|
778
|
+
setTimeout(() => { b.textContent = '⧉ Resume'; }, 1800);
|
|
779
|
+
});
|
|
780
|
+
host.querySelectorAll('[data-compact]').forEach(b => b.onclick = async e => {
|
|
781
|
+
e.stopPropagation();
|
|
782
|
+
if (!armed(b)) return;
|
|
783
|
+
b.disabled = true;
|
|
784
|
+
try {
|
|
785
|
+
const r = await sessionAction(+b.dataset.compact, 'compact', {agent: 'claude'});
|
|
786
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
787
|
+
b.textContent = r.ok ? '✓ Sent' : 'Failed'; b.title = r.ok ? r.message : r.error;
|
|
788
|
+
} catch (err) { b.textContent = 'Failed'; b.title = err.message; }
|
|
789
|
+
setTimeout(() => { b.disabled = false; b.textContent = '🗜 Compact'; }, 2500);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
692
792
|
|
|
693
793
|
function renderAdvisorHero(page, advisor) {
|
|
694
794
|
if (!page || !advisor || !Array.isArray(advisor.actions)) return;
|
|
@@ -1076,7 +1176,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1076
1176
|
const sp = S.sessPage;
|
|
1077
1177
|
const res = await api('sessions', `&limit=${sp.offset + sp.limit}&order=${order}`);
|
|
1078
1178
|
const rows = res.rows, total = res.total;
|
|
1079
|
-
const eff = await api('efficiency');
|
|
1179
|
+
const [eff, live] = await Promise.all([api('efficiency'), liveSessions()]);
|
|
1080
1180
|
const avgTok = rows.length ? rows.reduce((a, r) => a + r.tokens, 0) / rows.length : 0;
|
|
1081
1181
|
page.innerHTML = `
|
|
1082
1182
|
<div class="grid g4">
|
|
@@ -1121,6 +1221,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1121
1221
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1122
1222
|
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(r.title || shortId(r.session_id))}
|
|
1123
1223
|
<div class="sub mono">${esc(shortId(r.session_id))}</div>`},
|
|
1224
|
+
{h: '', f: r => rowActs(r, live)},
|
|
1124
1225
|
{h: 'Project', f: r => esc(r.project)},
|
|
1125
1226
|
{h: 'Branch', f: r => r.git_branch ? `<span class="mono">${esc(r.git_branch)}</span>` : '—'},
|
|
1126
1227
|
{h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
@@ -1141,6 +1242,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1141
1242
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1142
1243
|
], rows, {onRow: 1});
|
|
1143
1244
|
wireTable($('#st', page), rows, r => openSession(r.session_id));
|
|
1245
|
+
wireRowActs($('#st', page));
|
|
1144
1246
|
const effCards = [...page.querySelectorAll('.card')].filter(c =>
|
|
1145
1247
|
/output yield/.test(c.querySelector('h3')?.textContent || ''));
|
|
1146
1248
|
wireTable(effCards[0], eff.low_efficiency_sessions, r => openSession(r.session_id));
|
|
@@ -1219,7 +1321,7 @@ const RANK_TABS = [
|
|
|
1219
1321
|
['cheapest', 'Top 20 cheapest'], ['most_efficient', 'Top 20 most efficient'],
|
|
1220
1322
|
['longest_sessions', 'Top 20 longest sessions']];
|
|
1221
1323
|
VIEWS.rankings = async (page) => {
|
|
1222
|
-
const lb = await api('leaderboards', '&n=20');
|
|
1324
|
+
const [lb, live] = await Promise.all([api('leaderboards', '&n=20'), liveSessions()]);
|
|
1223
1325
|
const tab = S.rankTab || 'most_expensive';
|
|
1224
1326
|
const isSess = tab === 'longest_sessions';
|
|
1225
1327
|
const rows = lb[tab];
|
|
@@ -1233,6 +1335,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1233
1335
|
const cols = isSess ? [
|
|
1234
1336
|
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1235
1337
|
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1338
|
+
{h: '', f: r => rowActs(r, live)},
|
|
1236
1339
|
{h: 'Project', f: r => esc(r.project)},
|
|
1237
1340
|
{h: 'Duration', num: 1, f: r => dur(r.duration_s)},
|
|
1238
1341
|
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
@@ -1251,10 +1354,12 @@ VIEWS.rankings = async (page) => {
|
|
|
1251
1354
|
{h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
|
|
1252
1355
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
1253
1356
|
{h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
|
|
1357
|
+
{h: '', f: r => rowActs(r, live)},
|
|
1254
1358
|
{h: 'Date', f: r => esc(r.day)},
|
|
1255
1359
|
];
|
|
1256
1360
|
$('#rt', page).innerHTML = table(cols, rows, {onRow: 1});
|
|
1257
1361
|
wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
|
|
1362
|
+
wireRowActs($('#rt', page));
|
|
1258
1363
|
addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
|
|
1259
1364
|
rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
|
|
1260
1365
|
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
@@ -2297,45 +2402,81 @@ VIEWS.exports = async (page) => {
|
|
|
2297
2402
|
/* ============================ focus cues ============================ */
|
|
2298
2403
|
// "What needs me first": one ranked list built from the diagnosis, used for the nav
|
|
2299
2404
|
// badges and the Focus strip at the top of the overview and diagnosis pages.
|
|
2405
|
+
// "What to change": things to fix once and habits to change, each with its reason.
|
|
2406
|
+
// Running sessions are left to ⚡ Act now, which can act on them. Items are keyed so a
|
|
2407
|
+
// dismissed one comes back only if its evidence changes (e.g. more sessions affected).
|
|
2408
|
+
const firstSentence = t => { const m = String(t || '').match(/^.*?[.!?](\s|$)/); return (m ? m[0] : String(t || '')).trim(); };
|
|
2409
|
+
// "29.7% of X." + "35.6% of X." -> "29.7–35.6% of X." plus the shared advice; null when they differ.
|
|
2410
|
+
function sameFix(fixes) {
|
|
2411
|
+
const pct = /\d+(?:\.\d+)?%/, heads = fixes.map(firstSentence);
|
|
2412
|
+
const shape = heads.map(t => t.replace(pct, '#'));
|
|
2413
|
+
if (!shape.every(t => t === shape[0]) || !pct.test(heads[0])) return null;
|
|
2414
|
+
const nums = heads.map(t => parseFloat(t.match(pct)[0]));
|
|
2415
|
+
const range = `${Math.min(...nums)}–${Math.max(...nums)}%`;
|
|
2416
|
+
const rest = fixes[0].slice(heads[0].length).trim().replace(/\bthis repo\b/g, 'each repo');
|
|
2417
|
+
return `${shape[0].replace('#', range).replace(' here ', ' in these repos ')} ${rest}`.trim();
|
|
2418
|
+
}
|
|
2300
2419
|
function focusItems(d) {
|
|
2301
2420
|
const out = [];
|
|
2302
|
-
for (const x of d.live_sessions || []) if (x.severity !== 'ok')
|
|
2303
|
-
out.push({lvl: x.severity === 'high' ? 1 : 2, text: `Running session at ${fmtNum(x.context)} context: ${x.title || shortId(x.session_id)}`, view: 'diagnose', anchor: 'dx-live'});
|
|
2304
2421
|
for (const m of d.memory_suggestions || []) if (m.kind === 'security')
|
|
2305
|
-
out.push({lvl: 1,
|
|
2422
|
+
out.push({lvl: 1, kind: 'fix', key: `sec:${m.sessions}`, view: 'diagnose', anchor: 'dx-mem',
|
|
2423
|
+
text: `Rotate the credentials you pasted into prompts (${m.sessions} session${m.sessions === 1 ? '' : 's'})`,
|
|
2424
|
+
why: 'Closing the sessions does not help: the secrets stay in plain text in those transcripts on disk.'});
|
|
2425
|
+
const byIssue = (sev, lvl) => {
|
|
2426
|
+
const groups = new Map();
|
|
2427
|
+
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === sev) {
|
|
2428
|
+
if (!groups.has(i.title)) groups.set(i.title, []);
|
|
2429
|
+
groups.get(i.title).push({project: p.name, fix: i.fix});
|
|
2430
|
+
}
|
|
2431
|
+
for (const [title, ps] of groups) out.push({lvl, kind: 'fix', view: 'diagnose', anchor: 'dx-issues',
|
|
2432
|
+
key: `proj:${title}:${ps.map(x => x.project).sort().join(',')}`,
|
|
2433
|
+
text: ps.length === 1 ? `${ps[0].project}: ${title}` : `${title} in ${ps.length} projects: ${ps.map(x => x.project).join(', ')}`,
|
|
2434
|
+
why: ps.length === 1 ? ps[0].fix : sameFix(ps.map(x => x.fix))
|
|
2435
|
+
|| ps.slice(0, 3).map(x => `${x.project}: ${firstSentence(x.fix)}`).join(' · ')});
|
|
2436
|
+
};
|
|
2437
|
+
byIssue('high', 1);
|
|
2306
2438
|
for (const r of d.recommendations || []) if (r.priority === 1)
|
|
2307
|
-
out.push({lvl:
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
const s0 = (d.session_health || [])[0];
|
|
2311
|
-
if (s0) out.push({lvl: 2, text: `Heaviest session: ${s0.title || shortId(s0.session_id)} (peak ${fmtNum(s0.peak)})`, view: 'diagnose', anchor: 'dx-past'});
|
|
2312
|
-
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'medium')
|
|
2313
|
-
out.push({lvl: 2, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
|
|
2439
|
+
out.push({lvl: 2, kind: 'habit', key: `rec:${r.title}`, view: 'diagnose', anchor: 'dx-recs',
|
|
2440
|
+
text: r.title, why: `${firstSentence(r.why)} ${firstSentence(r.how)}`});
|
|
2441
|
+
byIssue('medium', 3);
|
|
2314
2442
|
return out.sort((a, b) => a.lvl - b.lvl);
|
|
2315
2443
|
}
|
|
2444
|
+
const FOCUS_KEY = 'finops.focus.hidden';
|
|
2445
|
+
const focusHidden = () => { try { return new Set(JSON.parse(localStorage.getItem(FOCUS_KEY) || '[]')); } catch { return new Set(); } };
|
|
2446
|
+
const setFocusHidden = set => { try { localStorage.setItem(FOCUS_KEY, JSON.stringify([...set])); } catch {} };
|
|
2316
2447
|
function focusStrip(d, n = 5) {
|
|
2317
|
-
const
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
<span class="
|
|
2448
|
+
const all = focusItems(d), hidden = focusHidden();
|
|
2449
|
+
const items = all.filter(i => !hidden.has(i.key));
|
|
2450
|
+
const nHidden = all.length - items.length;
|
|
2451
|
+
if (!all.length) return '';
|
|
2452
|
+
const fixes = items.filter(i => i.kind === 'fix').length, habits = items.filter(i => i.kind === 'habit').length;
|
|
2453
|
+
return `<section class="focus"><div class="focus-hd">🎯 What to change
|
|
2454
|
+
<span class="note">${fixes} to fix once · ${habits} habit${habits === 1 ? '' : 's'} from your past sessions</span>
|
|
2455
|
+
<span class="spacer"></span>${nHidden ? `<button class="act ghost" data-unhide="1">Show ${nHidden} hidden</button>` : ''}</div>
|
|
2456
|
+
${items.slice(0, n).map((it, k) => `<div class="focus-it l${it.lvl === 1 ? 1 : 2}" data-go="${it.view}" data-anchor="${it.anchor}" data-key="${esc(it.key)}">
|
|
2457
|
+
<span class="fn">${k + 1}</span>
|
|
2458
|
+
<span class="ft"><span class="fx">${esc(it.text)}</span><span class="fw">${esc(it.why || '')}</span></span>
|
|
2459
|
+
<span class="fl">${it.kind === 'habit' ? 'Habit' : 'Fix once'}</span>
|
|
2460
|
+
<button class="fd" title="Hide this until its evidence changes" aria-label="Hide">✕</button></div>`).join('')
|
|
2461
|
+
|| '<div class="note">Everything here is hidden. Nice.</div>'}</section>`;
|
|
2324
2462
|
}
|
|
2325
2463
|
function wireFocus(root) {
|
|
2326
|
-
root.
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2464
|
+
const redraw = () => { const old = root.querySelector('.focus'); if (!old || !root._focusData) return;
|
|
2465
|
+
const el = h(focusStrip(root._focusData) || '<div></div>'); old.replaceWith(el); wireFocus(root); };
|
|
2466
|
+
root.querySelectorAll('.focus-it').forEach(a => {
|
|
2467
|
+
a.onclick = () => jumpTo(a.dataset.go, a.dataset.anchor);
|
|
2468
|
+
a.querySelector('.fd').onclick = e => { e.stopPropagation();
|
|
2469
|
+
const hid = focusHidden(); hid.add(a.dataset.key); setFocusHidden(hid); redraw(); };
|
|
2331
2470
|
});
|
|
2471
|
+
const un = root.querySelector('[data-unhide]');
|
|
2472
|
+
if (un) un.onclick = () => { setFocusHidden(new Set()); redraw(); };
|
|
2332
2473
|
}
|
|
2333
2474
|
async function updateNavBadges() {
|
|
2334
2475
|
const set = (id, n, lvl) => { const el = document.querySelector(`[data-nb="${id}"]`);
|
|
2335
2476
|
if (el) { el.textContent = n ? n : ''; el.className = 'nb' + (n ? ' l' + lvl : ''); } };
|
|
2336
2477
|
try {
|
|
2337
2478
|
const [d, w, a] = await Promise.all([hasPriced() ? api('diagnose') : null, api('waste'), api('anomalies')]);
|
|
2338
|
-
if (d) { const f = focusItems(d), urgent = f.filter(i => i.lvl === 1).length;
|
|
2479
|
+
if (d) { const hid = focusHidden(), f = focusItems(d).filter(i => !hid.has(i.key)), urgent = f.filter(i => i.lvl === 1).length;
|
|
2339
2480
|
set('diagnose', urgent || f.length, urgent ? 1 : 2); }
|
|
2340
2481
|
const hw = w.findings.filter(x => x.severity === 'high').length;
|
|
2341
2482
|
set('waste', hw || w.findings.length, hw ? 1 : 2);
|
package/web/styles.css
CHANGED
|
@@ -331,6 +331,17 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
331
331
|
.nav a .nb.l1 { background: var(--critical); color: #fff; }
|
|
332
332
|
.nav a .nb.l2 { background: var(--warning); color: #3a2a00; }
|
|
333
333
|
|
|
334
|
+
.actnow { border: 1px solid color-mix(in srgb, var(--s1) 40%, var(--border));
|
|
335
|
+
background: color-mix(in srgb, var(--s1) 5%, var(--surface)); border-radius: var(--radius);
|
|
336
|
+
padding: 10px 12px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 6px; }
|
|
337
|
+
.actnow-it { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 7px 8px;
|
|
338
|
+
border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
|
|
339
|
+
.actnow-it .ai { width: 22px; text-align: center; font-size: 15px; }
|
|
340
|
+
.actnow-it .ft { flex: 1; min-width: 220px; }
|
|
341
|
+
.actnow-it .live-actions { margin-top: 0; }
|
|
342
|
+
.row-acts { white-space: nowrap; }
|
|
343
|
+
.row-acts .act { padding: 2px 8px; font-size: 11px; }
|
|
344
|
+
|
|
334
345
|
.focus { border: 1px solid color-mix(in srgb, var(--critical) 35%, var(--border));
|
|
335
346
|
background: color-mix(in srgb, var(--critical) 5%, var(--surface)); border-radius: var(--radius);
|
|
336
347
|
padding: 10px 12px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 5px; }
|
|
@@ -341,7 +352,12 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
341
352
|
.focus-it .fn { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center;
|
|
342
353
|
font-size: 11px; font-weight: 700; color: #fff; flex: none; }
|
|
343
354
|
.focus-it.l1 .fn { background: var(--critical); } .focus-it.l2 .fn { background: var(--warning); }
|
|
344
|
-
.focus-it .ft { flex: 1; min-width: 0; }
|
|
355
|
+
.focus-it .ft { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
356
|
+
.focus-it .fx { color: var(--s1); }
|
|
357
|
+
.focus-it .fw { font-size: 11.5px; color: var(--muted); }
|
|
358
|
+
.focus-it .fd { border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 12px;
|
|
359
|
+
padding: 2px 6px; border-radius: 6px; }
|
|
360
|
+
.focus-it .fd:hover { background: var(--surface-2); color: var(--text); }
|
|
345
361
|
.focus-it .fl { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
|
346
362
|
.focus-it.l1 .fl { color: var(--critical-ink); } .focus-it.l2 .fl { color: var(--warning-ink); }
|
|
347
363
|
|