opencode-dashboard-server 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- opencode_dashboard_server-0.5.0/LICENSE.md +21 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/PKG-INFO +4 -1
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/aggregate.py +58 -19
- opencode_dashboard_server-0.5.0/app.py +354 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/PKG-INFO +4 -1
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/SOURCES.txt +1 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/pyproject.toml +3 -1
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/tests/test_aggregate.py +57 -0
- opencode_dashboard_server-0.5.0/tests/test_app.py +258 -0
- opencode_dashboard_server-0.4.0/app.py +0 -165
- opencode_dashboard_server-0.4.0/tests/test_app.py +0 -70
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/README.md +0 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/cli.py +0 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/db.py +0 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/dependency_links.txt +0 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/entry_points.txt +0 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/requires.txt +0 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/top_level.txt +0 -0
- {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/setup.cfg +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GCS-ZHN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-dashboard-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: FastAPI aggregator over opencode's SQLite storage
|
|
5
|
+
License-Expression: MIT
|
|
5
6
|
Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
|
|
6
7
|
Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
|
|
7
8
|
Project-URL: Issues, https://github.com/GCS-ZHN/opencode-dashboard/issues
|
|
8
9
|
Requires-Python: >=3.10
|
|
9
10
|
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.md
|
|
10
12
|
Requires-Dist: fastapi<1,>=0.115
|
|
11
13
|
Requires-Dist: uvicorn<1,>=0.30
|
|
12
14
|
Requires-Dist: platformdirs<5,>=4
|
|
13
15
|
Requires-Dist: PyYAML<7,>=6
|
|
16
|
+
Dynamic: license-file
|
|
14
17
|
|
|
15
18
|
# opencode-dashboard-server
|
|
16
19
|
|
|
@@ -38,7 +38,9 @@ CASE WHEN p.worktree IS NOT NULL AND p.worktree != '/' THEN s.project_id
|
|
|
38
38
|
|
|
39
39
|
# Join + effective-key mapping live in a derived table so `id`/`worktree` are
|
|
40
40
|
# unambiguous result columns (s.id and p.id would otherwise clash in GROUP BY).
|
|
41
|
-
|
|
41
|
+
# `{time_filter}` ("" or a WHERE on s.time_created) is filled in per request for
|
|
42
|
+
# the since/until window.
|
|
43
|
+
ROLLUP_SQL = """
|
|
42
44
|
SELECT gid AS id,
|
|
43
45
|
MAX(worktree) AS worktree,
|
|
44
46
|
COUNT(*) AS session_count,
|
|
@@ -57,11 +59,12 @@ FROM (
|
|
|
57
59
|
SELECT s.project_id, s.id AS sid, s.parent_id, s.cost, s.tokens_input,
|
|
58
60
|
s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
|
|
59
61
|
s.tokens_cache_write,
|
|
60
|
-
{
|
|
62
|
+
{eff_sql} AS gid,
|
|
61
63
|
CASE WHEN p.worktree IS NOT NULL AND p.worktree != '/' THEN p.worktree
|
|
62
64
|
ELSE s.directory END AS worktree
|
|
63
65
|
FROM session s
|
|
64
66
|
LEFT JOIN project p ON p.id = s.project_id
|
|
67
|
+
{time_filter}
|
|
65
68
|
) AS eff
|
|
66
69
|
"""
|
|
67
70
|
|
|
@@ -72,13 +75,13 @@ SELECT id, parent_id, project_id, title, agent, model, cost,
|
|
|
72
75
|
FROM session
|
|
73
76
|
"""
|
|
74
77
|
|
|
75
|
-
PROJECT_SESSIONS_SQL =
|
|
78
|
+
PROJECT_SESSIONS_SQL = """
|
|
76
79
|
SELECT s.id, s.parent_id, s.project_id, s.title, s.agent, s.model, s.cost,
|
|
77
80
|
s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
|
|
78
81
|
s.tokens_cache_write, s.time_created, s.time_updated
|
|
79
82
|
FROM session s
|
|
80
83
|
LEFT JOIN project p ON p.id = s.project_id
|
|
81
|
-
WHERE {
|
|
84
|
+
WHERE {eff_sql} = ?{time_filter}
|
|
82
85
|
ORDER BY s.cost DESC, s.id ASC
|
|
83
86
|
"""
|
|
84
87
|
|
|
@@ -99,6 +102,29 @@ WHERE json_extract(data, '$.role') = 'assistant'
|
|
|
99
102
|
"""
|
|
100
103
|
|
|
101
104
|
|
|
105
|
+
def _time_fragment(col: str, since, until) -> tuple[str, tuple]:
|
|
106
|
+
"""Half-open [since, until) filter on `col`. Fragment starts with ' AND '
|
|
107
|
+
so it appends after an existing WHERE; callers with no WHERE strip it.
|
|
108
|
+
Returns ("", ()) when neither bound is given."""
|
|
109
|
+
conds, params = [], []
|
|
110
|
+
if since is not None:
|
|
111
|
+
conds.append(f"{col} >= ?")
|
|
112
|
+
params.append(since)
|
|
113
|
+
if until is not None:
|
|
114
|
+
conds.append(f"{col} < ?")
|
|
115
|
+
params.append(until)
|
|
116
|
+
if not conds:
|
|
117
|
+
return "", ()
|
|
118
|
+
return " AND " + " AND ".join(conds), tuple(params)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rollup(since=None, until=None) -> tuple[str, tuple]:
|
|
122
|
+
tf, tp = _time_fragment("s.time_created", since, until)
|
|
123
|
+
if tf:
|
|
124
|
+
tf = "WHERE " + tf[5:]
|
|
125
|
+
return ROLLUP_SQL.format(eff_sql=EFF_SQL, time_filter=tf), tp
|
|
126
|
+
|
|
127
|
+
|
|
102
128
|
def normalize_model(mid) -> str | None:
|
|
103
129
|
"""Strip a provider prefix if present: deepseek/deepseek-v4-flash -> deepseek-v4-flash."""
|
|
104
130
|
if not mid:
|
|
@@ -174,8 +200,9 @@ def updated_at(runner) -> int:
|
|
|
174
200
|
return int(row["m"] or 0)
|
|
175
201
|
|
|
176
202
|
|
|
177
|
-
def overview(runner) -> dict:
|
|
178
|
-
|
|
203
|
+
def overview(runner, since=None, until=None) -> dict:
|
|
204
|
+
sql, tp = _rollup(since, until)
|
|
205
|
+
r = runner.query(sql, tp)[0]
|
|
179
206
|
return {
|
|
180
207
|
"projectCount": int(r["project_count"] or 0),
|
|
181
208
|
"sessionCount": int(r["session_count"] or 0),
|
|
@@ -186,22 +213,29 @@ def overview(runner) -> dict:
|
|
|
186
213
|
}
|
|
187
214
|
|
|
188
215
|
|
|
189
|
-
def projects(runner) -> list[dict]:
|
|
216
|
+
def projects(runner, since=None, until=None) -> list[dict]:
|
|
217
|
+
sql, tp = _rollup(since, until)
|
|
190
218
|
rows = runner.query(
|
|
191
|
-
|
|
192
|
-
" ORDER BY cost DESC, id ASC"
|
|
219
|
+
sql + " GROUP BY id, worktree"
|
|
220
|
+
" ORDER BY cost DESC, id ASC",
|
|
221
|
+
tp,
|
|
193
222
|
)
|
|
194
223
|
return [_project(r) for r in rows]
|
|
195
224
|
|
|
196
225
|
|
|
197
|
-
def project_detail(runner, project_id) -> tuple[dict, list[dict]] | None:
|
|
226
|
+
def project_detail(runner, project_id, since=None, until=None) -> tuple[dict, list[dict]] | None:
|
|
227
|
+
sql, tp = _rollup(since, until)
|
|
198
228
|
rows = runner.query(
|
|
199
|
-
|
|
200
|
-
(project_id,),
|
|
229
|
+
sql + " GROUP BY id, worktree HAVING id = ?",
|
|
230
|
+
tp + (project_id,),
|
|
201
231
|
)
|
|
202
232
|
if not rows:
|
|
203
233
|
return None
|
|
204
|
-
|
|
234
|
+
tf, tps = _time_fragment("s.time_created", since, until)
|
|
235
|
+
sessions = runner.query(
|
|
236
|
+
PROJECT_SESSIONS_SQL.format(eff_sql=EFF_SQL, time_filter=tf),
|
|
237
|
+
(project_id,) + tps,
|
|
238
|
+
)
|
|
205
239
|
return _project(rows[0]), [_session(r) for r in sessions]
|
|
206
240
|
|
|
207
241
|
|
|
@@ -216,20 +250,22 @@ def _model_entry(d: dict) -> dict:
|
|
|
216
250
|
}
|
|
217
251
|
|
|
218
252
|
|
|
219
|
-
def session_detail(runner, session_id) -> tuple[dict, list[dict]] | None:
|
|
220
|
-
|
|
253
|
+
def session_detail(runner, session_id, since=None, until=None) -> tuple[dict, list[dict]] | None:
|
|
254
|
+
tf, tp = _time_fragment("time_created", since, until)
|
|
255
|
+
rows = runner.query(SESSIONS_SQL + " WHERE id = ?" + tf, (session_id,) + tp)
|
|
221
256
|
if not rows:
|
|
222
257
|
return None
|
|
258
|
+
mf, mp = _time_fragment("message.time_created", since, until)
|
|
223
259
|
msg_rows = runner.query_tsv(
|
|
224
|
-
MESSAGES_SQL + " AND session_id = ? GROUP BY model_id, provider, mode",
|
|
225
|
-
(session_id,),
|
|
260
|
+
MESSAGES_SQL + " AND session_id = ?" + mf + " GROUP BY model_id, provider, mode",
|
|
261
|
+
(session_id,) + mp,
|
|
226
262
|
)
|
|
227
263
|
models = [_model_entry(dict(zip(_MSG_KEYS, row))) for row in msg_rows]
|
|
228
264
|
models.sort(key=lambda m: (-m["cost"], m["model"] or ""))
|
|
229
265
|
return _session(rows[0]), models
|
|
230
266
|
|
|
231
267
|
|
|
232
|
-
def models(runner) -> list[dict]:
|
|
268
|
+
def models(runner, since=None, until=None) -> list[dict]:
|
|
233
269
|
"""Whole-host per-model rollup from message.data (same shape as the
|
|
234
270
|
per-session model breakdown, but aggregated across all sessions).
|
|
235
271
|
|
|
@@ -238,7 +274,10 @@ def models(runner) -> list[dict]:
|
|
|
238
274
|
deepseek/deepseek-v4-flash vs deepseek-v4-flash) don't create duplicate
|
|
239
275
|
slices. On merge, the higher-cost row keeps its provider/mode label.
|
|
240
276
|
"""
|
|
241
|
-
|
|
277
|
+
mf, mp = _time_fragment("message.time_created", since, until)
|
|
278
|
+
msg_rows = runner.query_tsv(
|
|
279
|
+
MESSAGES_SQL + mf + " GROUP BY model_id, provider, mode", mp
|
|
280
|
+
)
|
|
242
281
|
merged: dict[str, dict] = {}
|
|
243
282
|
for row in msg_rows:
|
|
244
283
|
e = _model_entry(dict(zip(_MSG_KEYS, row)))
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""FastAPI app implementing the API.md contract over the opencode aggregator.
|
|
2
|
+
|
|
3
|
+
Aggregation runs against a large DB, so results are cached in memory and
|
|
4
|
+
refreshed on a fixed poll interval instead of being recomputed per request.
|
|
5
|
+
|
|
6
|
+
Run: uv run uvicorn app:app --reload
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import importlib.metadata
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
23
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
24
|
+
from fastapi.responses import StreamingResponse
|
|
25
|
+
|
|
26
|
+
import aggregate
|
|
27
|
+
from db import CliRunner
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("dashboard")
|
|
30
|
+
|
|
31
|
+
# Comma-separated CORS allow-list; defaults to the loopback dev origins. Tight
|
|
32
|
+
# by default so a random webpage can't exfiltrate local project/session data.
|
|
33
|
+
def default_cors_origins() -> list[str]:
|
|
34
|
+
env = os.environ.get("DASHBOARD_CORS_ORIGINS", "").strip()
|
|
35
|
+
if env:
|
|
36
|
+
return [o.strip() for o in env.split(",") if o.strip()]
|
|
37
|
+
return [
|
|
38
|
+
"http://localhost:5173", "http://127.0.0.1:5173",
|
|
39
|
+
"http://localhost:4173", "http://127.0.0.1:4173",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache(maxsize=8)
|
|
44
|
+
def opencode_version(executable: str = "opencode") -> str:
|
|
45
|
+
try:
|
|
46
|
+
return subprocess.run(
|
|
47
|
+
[executable, "--version"], capture_output=True, text=True, check=True
|
|
48
|
+
).stdout.strip()
|
|
49
|
+
except (OSError, subprocess.CalledProcessError):
|
|
50
|
+
return "unknown" # e.g. CI without the opencode CLI; don't fail the request
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dashboard_version() -> str:
|
|
54
|
+
try:
|
|
55
|
+
return importlib.metadata.version("opencode-dashboard-server")
|
|
56
|
+
except importlib.metadata.PackageNotFoundError:
|
|
57
|
+
return "unknown" # e.g. pytest from a plain checkout; don't fail the request
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Cache:
|
|
61
|
+
"""In-memory result cache keyed by (kind, id, since, until) where kind is
|
|
62
|
+
the endpoint, id the project/session id (None for whole-dataset endpoints),
|
|
63
|
+
and since/until the time-range query params (None = unbounded) — so
|
|
64
|
+
`/overview?since=A` and `/overview?since=B` are distinct entries and never
|
|
65
|
+
collide with each other or with the all-time result.
|
|
66
|
+
|
|
67
|
+
Values fill lazily on first request. Whole-dataset kinds (overview,
|
|
68
|
+
projects, models) are refreshed by the background poll loop across every
|
|
69
|
+
observed range variant (bounded); per-id kinds (project, session) are
|
|
70
|
+
refreshed only when a request finds them older than ttl, so the heavy
|
|
71
|
+
session_detail SQL runs at most once per ttl per session and _values stays
|
|
72
|
+
bounded. A failed refresh keeps the previous value (stale-serve); a failed
|
|
73
|
+
first fill propagates to the caller (500 — nothing cached yet).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
_WHOLE = frozenset({"overview", "projects", "models"})
|
|
77
|
+
_MAX_PER_KIND = 512 # per-id kinds: evict oldest beyond this cap
|
|
78
|
+
_MAX_RANGE_VARIANTS = 16 # whole kinds: bound distinct since/until combos
|
|
79
|
+
|
|
80
|
+
def __init__(self, ttl=None):
|
|
81
|
+
self._lock = threading.Lock()
|
|
82
|
+
self._loaders: dict[str, Callable] = {}
|
|
83
|
+
self._values: dict[tuple, tuple] = {}
|
|
84
|
+
self._ranges: dict[str, list[tuple]] = {} # whole kind -> observed (since, until), oldest first
|
|
85
|
+
self._ttl = ttl # per-id kinds refresh on request when older than this
|
|
86
|
+
|
|
87
|
+
def register(self, kind: str, loader):
|
|
88
|
+
self._loaders[kind] = loader
|
|
89
|
+
|
|
90
|
+
def get(self, kind: str, key=None, since=None, until=None):
|
|
91
|
+
full = (kind, key, since, until)
|
|
92
|
+
with self._lock:
|
|
93
|
+
entry = self._values.get(full) # None = never cached
|
|
94
|
+
if entry is not None:
|
|
95
|
+
value, stored_at = entry
|
|
96
|
+
if kind in self._WHOLE or self._ttl is None \
|
|
97
|
+
or time.monotonic() - stored_at < self._ttl:
|
|
98
|
+
return value
|
|
99
|
+
try: # stale per-id entry: refresh on request, keep stale on failure
|
|
100
|
+
value = self._loaders[kind](key, since, until)
|
|
101
|
+
except Exception:
|
|
102
|
+
logger.exception("stale cache refresh failed for %s; serving stale", full)
|
|
103
|
+
return value
|
|
104
|
+
with self._lock:
|
|
105
|
+
self._values[full] = (value, time.monotonic())
|
|
106
|
+
return value
|
|
107
|
+
value = self._loaders[kind](key, since, until)
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._values[full] = (value, time.monotonic())
|
|
110
|
+
self._evict_if_grown(kind, full)
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
def refresh(self, full: tuple):
|
|
114
|
+
kind, key, since, until = full
|
|
115
|
+
value = self._loaders[kind](key, since, until)
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._values[full] = (value, time.monotonic())
|
|
118
|
+
self._evict_if_grown(kind, full)
|
|
119
|
+
|
|
120
|
+
def _evict_if_grown(self, kind, full):
|
|
121
|
+
"""Bound _values: whole kinds by distinct range-variant count, per-id
|
|
122
|
+
kinds by entry count. Drop the least-recently-stored entry."""
|
|
123
|
+
if kind in self._WHOLE:
|
|
124
|
+
ranges = self._ranges.setdefault(kind, [])
|
|
125
|
+
variant = full[2:]
|
|
126
|
+
if variant not in ranges:
|
|
127
|
+
ranges.append(variant)
|
|
128
|
+
if len(ranges) > self._MAX_RANGE_VARIANTS:
|
|
129
|
+
self._values.pop((kind, None) + ranges.pop(0), None)
|
|
130
|
+
return
|
|
131
|
+
entries = [k for k in self._values if k[0] == kind]
|
|
132
|
+
if len(entries) > self._MAX_PER_KIND:
|
|
133
|
+
del self._values[min(entries, key=lambda k: self._values[k][1])]
|
|
134
|
+
|
|
135
|
+
def poll_keys(self):
|
|
136
|
+
"""Keys the background loop refreshes: whole-dataset kinds across every
|
|
137
|
+
observed range variant, plus the all-time variant (always included, so
|
|
138
|
+
pre-warm and the stream's updatedAt signal never depend on a prior
|
|
139
|
+
request); per-id entries are handled lazily in get()."""
|
|
140
|
+
with self._lock:
|
|
141
|
+
keys = []
|
|
142
|
+
for kind in self._loaders:
|
|
143
|
+
if kind not in self._WHOLE:
|
|
144
|
+
continue
|
|
145
|
+
variants = set(self._ranges.get(kind) or [])
|
|
146
|
+
variants.add((None, None)) # all-time variant always refreshed
|
|
147
|
+
keys += [(kind, None, since, until) for since, until in variants]
|
|
148
|
+
return keys
|
|
149
|
+
|
|
150
|
+
def overview(self):
|
|
151
|
+
"""All-time overview (stream signal). updatedAt is a DB-wide max and
|
|
152
|
+
range-independent, so the all-time entry drives /stream correctly."""
|
|
153
|
+
with self._lock:
|
|
154
|
+
entry = self._values.get(("overview", None, None, None))
|
|
155
|
+
return entry[0] if entry else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class StreamHub:
|
|
159
|
+
"""Fan-out for /stream subscribers. The poll loop broadcasts to every
|
|
160
|
+
connected SSE client; clients never query the DB themselves."""
|
|
161
|
+
|
|
162
|
+
def __init__(self):
|
|
163
|
+
self._lock = asyncio.Lock()
|
|
164
|
+
self._subs: set[asyncio.Queue] = set()
|
|
165
|
+
|
|
166
|
+
async def subscribe(self) -> asyncio.Queue:
|
|
167
|
+
q: asyncio.Queue = asyncio.Queue()
|
|
168
|
+
async with self._lock:
|
|
169
|
+
self._subs.add(q)
|
|
170
|
+
return q
|
|
171
|
+
|
|
172
|
+
async def unsubscribe(self, q: asyncio.Queue) -> None:
|
|
173
|
+
async with self._lock:
|
|
174
|
+
self._subs.discard(q)
|
|
175
|
+
|
|
176
|
+
async def broadcast(self, payload) -> None:
|
|
177
|
+
async with self._lock:
|
|
178
|
+
subs = list(self._subs)
|
|
179
|
+
for q in subs:
|
|
180
|
+
q.put_nowait(payload)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def poll_event(overview, last_updated, ticks):
|
|
184
|
+
"""Stream event for one cache-refresh tick. Emits `updated` only when the
|
|
185
|
+
overview's updatedAt advances; otherwise a heartbeat every 3rd tick (None
|
|
186
|
+
between). Returns (payload | None, new_last_updated)."""
|
|
187
|
+
if overview and overview.get("updatedAt") and overview["updatedAt"] != last_updated:
|
|
188
|
+
return {"type": "updated", "at": overview["updatedAt"], "scope": "overview"}, \
|
|
189
|
+
overview["updatedAt"]
|
|
190
|
+
if ticks % 3 == 0:
|
|
191
|
+
return {"type": "heartbeat"}, last_updated
|
|
192
|
+
return None, last_updated
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def create_app(runner=None, cors_origins=None, poll_seconds=None, opencode_bin=None) -> FastAPI:
|
|
196
|
+
bin = opencode_bin or os.environ.get("OPENCODE_BIN") or "opencode"
|
|
197
|
+
if runner is None:
|
|
198
|
+
runner = CliRunner(executable=bin)
|
|
199
|
+
origins = cors_origins if cors_origins is not None else default_cors_origins()
|
|
200
|
+
if poll_seconds is None:
|
|
201
|
+
try:
|
|
202
|
+
poll_seconds = float(os.environ.get("DASHBOARD_POLL_SECONDS", "5"))
|
|
203
|
+
except ValueError:
|
|
204
|
+
poll_seconds = 5.0
|
|
205
|
+
|
|
206
|
+
cache = Cache(ttl=poll_seconds)
|
|
207
|
+
hub = StreamHub()
|
|
208
|
+
|
|
209
|
+
cache.register("overview", lambda key, since, until: (
|
|
210
|
+
aggregate.overview(runner, since, until)
|
|
211
|
+
| {"host": socket.gethostname(), "opencodeVersion": opencode_version(bin),
|
|
212
|
+
"dashboardVersion": dashboard_version()}
|
|
213
|
+
))
|
|
214
|
+
cache.register("projects", lambda key, since, until: aggregate.projects(runner, since, until))
|
|
215
|
+
cache.register("models", lambda key, since, until: aggregate.models(runner, since, until))
|
|
216
|
+
|
|
217
|
+
def load_project(key, since, until):
|
|
218
|
+
res = aggregate.project_detail(runner, key, since, until)
|
|
219
|
+
if res is None:
|
|
220
|
+
return None
|
|
221
|
+
proj, sessions = res
|
|
222
|
+
return {"project": proj, "sessions": sessions}
|
|
223
|
+
|
|
224
|
+
def load_session(key, since, until):
|
|
225
|
+
res = aggregate.session_detail(runner, key, since, until)
|
|
226
|
+
if res is None:
|
|
227
|
+
return None
|
|
228
|
+
sess, models = res
|
|
229
|
+
return {"session": sess, "models": models}
|
|
230
|
+
|
|
231
|
+
cache.register("project", load_project)
|
|
232
|
+
cache.register("session", load_session)
|
|
233
|
+
|
|
234
|
+
async def poll():
|
|
235
|
+
loop = asyncio.get_running_loop()
|
|
236
|
+
last_updated = None
|
|
237
|
+
ticks = 0
|
|
238
|
+
while True:
|
|
239
|
+
# Only whole-dataset kinds refresh on the clock, across every
|
|
240
|
+
# observed range variant; per-id entries are refreshed lazily in
|
|
241
|
+
# Cache.get, so session_detail's heavy SQL runs once per ttl per
|
|
242
|
+
# session and _values stays bounded.
|
|
243
|
+
for full in cache.poll_keys():
|
|
244
|
+
try:
|
|
245
|
+
await loop.run_in_executor(None, cache.refresh, full)
|
|
246
|
+
except Exception:
|
|
247
|
+
logger.exception("cache refresh failed for %s; serving stale", full)
|
|
248
|
+
ticks += 1
|
|
249
|
+
event, last_updated = poll_event(cache.overview(), last_updated, ticks)
|
|
250
|
+
if event is not None:
|
|
251
|
+
await hub.broadcast(event)
|
|
252
|
+
# True period is refresh-time + poll_seconds (refresh is synchronous
|
|
253
|
+
# on the executor above, so this sleep starts only after it).
|
|
254
|
+
await asyncio.sleep(poll_seconds)
|
|
255
|
+
|
|
256
|
+
@asynccontextmanager
|
|
257
|
+
async def lifespan(app):
|
|
258
|
+
loop = asyncio.get_running_loop()
|
|
259
|
+
# Pre-warm whole-dataset keys so /stream's first updated event (and the
|
|
260
|
+
# first request) don't depend on someone GETting /overview first.
|
|
261
|
+
for full in cache.poll_keys():
|
|
262
|
+
try:
|
|
263
|
+
await loop.run_in_executor(None, cache.refresh, full)
|
|
264
|
+
except Exception:
|
|
265
|
+
logger.exception("initial cache fill failed for %s; will retry on request", full)
|
|
266
|
+
task = asyncio.create_task(poll())
|
|
267
|
+
try:
|
|
268
|
+
yield
|
|
269
|
+
finally:
|
|
270
|
+
task.cancel()
|
|
271
|
+
try:
|
|
272
|
+
await task
|
|
273
|
+
except asyncio.CancelledError:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
app = FastAPI(title="opencode token dashboard", lifespan=lifespan)
|
|
277
|
+
app.state.cache = cache
|
|
278
|
+
# Loopback-only API; restrict origins so a random webpage can't exfiltrate
|
|
279
|
+
# local project/session data from the browser (the client runs from Vite).
|
|
280
|
+
app.add_middleware(
|
|
281
|
+
CORSMiddleware,
|
|
282
|
+
allow_origins=origins,
|
|
283
|
+
allow_methods=["*"],
|
|
284
|
+
allow_headers=["*"],
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def handle(fn):
|
|
288
|
+
try:
|
|
289
|
+
return fn()
|
|
290
|
+
except HTTPException:
|
|
291
|
+
raise
|
|
292
|
+
except Exception:
|
|
293
|
+
logger.exception("aggregation failed")
|
|
294
|
+
raise HTTPException(500, detail="aggregation failed")
|
|
295
|
+
|
|
296
|
+
@app.get("/health")
|
|
297
|
+
def health():
|
|
298
|
+
return {"status": "ok", "version": opencode_version(bin)}
|
|
299
|
+
|
|
300
|
+
@app.get("/overview")
|
|
301
|
+
def overview(since: int | None = Query(None), until: int | None = Query(None)):
|
|
302
|
+
return handle(lambda: cache.get("overview", since=since, until=until))
|
|
303
|
+
|
|
304
|
+
@app.get("/projects")
|
|
305
|
+
def projects(since: int | None = Query(None), until: int | None = Query(None)):
|
|
306
|
+
return handle(lambda: cache.get("projects", since=since, until=until))
|
|
307
|
+
|
|
308
|
+
@app.get("/models")
|
|
309
|
+
def models(since: int | None = Query(None), until: int | None = Query(None)):
|
|
310
|
+
return handle(lambda: cache.get("models", since=since, until=until))
|
|
311
|
+
|
|
312
|
+
@app.get("/projects/{project_id}")
|
|
313
|
+
def project(project_id: str, since: int | None = Query(None), until: int | None = Query(None)):
|
|
314
|
+
def run():
|
|
315
|
+
res = cache.get("project", project_id, since, until)
|
|
316
|
+
if res is None:
|
|
317
|
+
raise HTTPException(404, detail=f"project {project_id} not found")
|
|
318
|
+
return res
|
|
319
|
+
|
|
320
|
+
return handle(run)
|
|
321
|
+
|
|
322
|
+
@app.get("/sessions/{session_id}")
|
|
323
|
+
def session(session_id: str, since: int | None = Query(None), until: int | None = Query(None)):
|
|
324
|
+
def run():
|
|
325
|
+
res = cache.get("session", session_id, since, until)
|
|
326
|
+
if res is None:
|
|
327
|
+
raise HTTPException(404, detail=f"session {session_id} not found")
|
|
328
|
+
return res
|
|
329
|
+
|
|
330
|
+
return handle(run)
|
|
331
|
+
|
|
332
|
+
@app.get("/stream")
|
|
333
|
+
async def stream():
|
|
334
|
+
q = await hub.subscribe()
|
|
335
|
+
await q.put({"type": "heartbeat"})
|
|
336
|
+
|
|
337
|
+
async def gen():
|
|
338
|
+
try:
|
|
339
|
+
while True:
|
|
340
|
+
payload = await q.get()
|
|
341
|
+
data = json.dumps(payload)
|
|
342
|
+
if payload["type"] == "updated":
|
|
343
|
+
yield f"event: update\ndata: {data}\n\n"
|
|
344
|
+
else:
|
|
345
|
+
yield f"data: {data}\n\n"
|
|
346
|
+
finally:
|
|
347
|
+
await hub.unsubscribe(q)
|
|
348
|
+
|
|
349
|
+
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
350
|
+
|
|
351
|
+
return app
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
app = create_app()
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-dashboard-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: FastAPI aggregator over opencode's SQLite storage
|
|
5
|
+
License-Expression: MIT
|
|
5
6
|
Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
|
|
6
7
|
Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
|
|
7
8
|
Project-URL: Issues, https://github.com/GCS-ZHN/opencode-dashboard/issues
|
|
8
9
|
Requires-Python: >=3.10
|
|
9
10
|
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.md
|
|
10
12
|
Requires-Dist: fastapi<1,>=0.115
|
|
11
13
|
Requires-Dist: uvicorn<1,>=0.30
|
|
12
14
|
Requires-Dist: platformdirs<5,>=4
|
|
13
15
|
Requires-Dist: PyYAML<7,>=6
|
|
16
|
+
Dynamic: license-file
|
|
14
17
|
|
|
15
18
|
# opencode-dashboard-server
|
|
16
19
|
|
|
@@ -4,9 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-dashboard-server"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "FastAPI aggregator over opencode's SQLite storage"
|
|
9
9
|
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE.md"]
|
|
10
12
|
requires-python = ">=3.10"
|
|
11
13
|
dependencies = [
|
|
12
14
|
"fastapi>=0.115,<1",
|
|
@@ -149,3 +149,60 @@ def test_models_hostwide_rollup(runner):
|
|
|
149
149
|
assert kimi["messageCount"] == 1 # only m4; the m5 user message is excluded
|
|
150
150
|
assert kimi["tokens"] == ZERO_TOKENS
|
|
151
151
|
assert kimi["cost"] == 0.0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_overview_time_filter(runner):
|
|
155
|
+
o = overview(runner, since=140000)
|
|
156
|
+
assert o["sessionCount"] == 4 # s4, s5, s6, s8
|
|
157
|
+
assert o["mainSessionCount"] == 4
|
|
158
|
+
assert o["projectCount"] == 3
|
|
159
|
+
assert o["cost"] == 4.6
|
|
160
|
+
assert overview(runner, since=150000)["sessionCount"] == 3
|
|
161
|
+
assert overview(runner, until=150000)["sessionCount"] == 5 # s1..s4 + s7
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_overview_window_is_half_open(runner):
|
|
165
|
+
# s3.time_created == 115000 must NOT match until=115000
|
|
166
|
+
o = overview(runner, until=115000)
|
|
167
|
+
assert o["sessionCount"] == 2 # s1, s7
|
|
168
|
+
assert o["cost"] == 2.1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_projects_time_filter(runner):
|
|
172
|
+
ps = projects(runner, since=140000)
|
|
173
|
+
assert [p["id"] for p in ps] == ["proj-ccc", "proj-bbb", DIR_DASH]
|
|
174
|
+
bbb = ps[1]
|
|
175
|
+
assert bbb["sessionCount"] == 2 # s4, s5
|
|
176
|
+
assert bbb["cost"] == 0.9
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_project_detail_time_filter(runner):
|
|
180
|
+
proj, sessions = project_detail(runner, "proj-aaa", since=115000)
|
|
181
|
+
assert proj["cost"] == 0.7 # s2 + s3 (s1 outside the window)
|
|
182
|
+
# s3 stays a subagent: its parent s1 is only outside the window, not the DB
|
|
183
|
+
assert proj["mainSessionCount"] == 1
|
|
184
|
+
assert [s["id"] for s in sessions] == ["s2", "s3"] # cost desc
|
|
185
|
+
assert project_detail(runner, "proj-aaa", since=130000) is None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_session_detail_time_filter(runner):
|
|
189
|
+
sess, ms = session_detail(runner, "s4", until=143500)
|
|
190
|
+
assert sess["id"] == "s4" # s4.time_created (140000) < until
|
|
191
|
+
# m6 (144000) excluded; m1+m2 merge into the deepseek/build entry
|
|
192
|
+
assert [m["model"] for m in ms] == ["deepseek-v4-flash", "claude-sonnet-4.5"]
|
|
193
|
+
assert ms[0]["messageCount"] == 2
|
|
194
|
+
assert ms[0]["cost"] == 0.6
|
|
195
|
+
assert ms[1]["messageCount"] == 1
|
|
196
|
+
assert ms[1]["cost"] == 0.3
|
|
197
|
+
assert session_detail(runner, "s4", since=150000) is None # session itself out of window
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_models_message_time_filter(runner):
|
|
201
|
+
ms = models(runner, since=143000)
|
|
202
|
+
assert [m["model"] for m in ms] == ["claude-sonnet-4.5", "deepseek-v4-flash", "kimi-k3"]
|
|
203
|
+
assert ms[0]["cost"] == 0.3
|
|
204
|
+
assert ms[1]["provider"] == "openrouter" # m6 only; m1+m2 are before since
|
|
205
|
+
assert ms[1]["messageCount"] == 1
|
|
206
|
+
assert ms[1]["cost"] == 0.1
|
|
207
|
+
assert ms[2]["messageCount"] == 1 # m4 only
|
|
208
|
+
assert models(runner, since=152000) == [] # no token-bearing messages left
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""HTTP-level smoke tests + the SQL-inlining boundary (CliRunner's only injection
|
|
2
|
+
surface). Driven via create_app(SqliteRunner) over the in-memory fixture, so no
|
|
3
|
+
`opencode db` CLI spawn happens."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from fastapi.testclient import TestClient
|
|
10
|
+
|
|
11
|
+
import app as appmod
|
|
12
|
+
from db import SqliteRunner, _inline
|
|
13
|
+
from tests.conftest import SCHEMA, seed
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_client() -> TestClient:
|
|
17
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
18
|
+
conn.executescript(SCHEMA)
|
|
19
|
+
seed(conn)
|
|
20
|
+
return TestClient(appmod.create_app(SqliteRunner(conn))), conn
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CountingRunner:
|
|
24
|
+
"""Wraps a SqliteRunner and counts query calls (proves cache hits)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, inner):
|
|
27
|
+
self._inner = inner
|
|
28
|
+
self.queries = 0
|
|
29
|
+
|
|
30
|
+
def query(self, sql, params=()):
|
|
31
|
+
self.queries += 1
|
|
32
|
+
return self._inner.query(sql, params)
|
|
33
|
+
|
|
34
|
+
def query_tsv(self, sql, params=()):
|
|
35
|
+
self.queries += 1
|
|
36
|
+
return self._inner.query_tsv(sql, params)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_inline_quotes_and_escapes():
|
|
40
|
+
sql = _inline("WHERE id = ? AND cost > ?", ("a'b\";\nDROP", 5))
|
|
41
|
+
assert sql == "WHERE id = 'a''b\";\nDROP' AND cost > 5"
|
|
42
|
+
assert 'DROP' in sql # payload stays inside one quoted literal
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_unknown_project_404():
|
|
46
|
+
c, _ = make_client()
|
|
47
|
+
r = c.get("/projects/nope")
|
|
48
|
+
assert r.status_code == 404
|
|
49
|
+
assert r.json() == {"detail": "project nope not found"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_overview_keys():
|
|
53
|
+
c, _ = make_client()
|
|
54
|
+
r = c.get("/overview")
|
|
55
|
+
assert r.status_code == 200
|
|
56
|
+
assert {"host", "opencodeVersion", "dashboardVersion", "projectCount", "sessionCount",
|
|
57
|
+
"tokens", "cost", "updatedAt"} <= set(r.json())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_models_keys():
|
|
61
|
+
c, _ = make_client()
|
|
62
|
+
r = c.get("/models")
|
|
63
|
+
assert r.status_code == 200
|
|
64
|
+
rows = r.json()
|
|
65
|
+
assert rows # fixture seeds assistant token-bearing messages
|
|
66
|
+
assert {"model", "provider", "mode", "messageCount", "tokens", "cost"} <= set(rows[0])
|
|
67
|
+
assert rows == sorted(rows, key=lambda m: (-m["cost"], m["model"] or ""))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_foreign_origin_not_allowed_by_cors():
|
|
71
|
+
c, _ = make_client()
|
|
72
|
+
r = c.options("/projects", headers={
|
|
73
|
+
"Origin": "https://evil.example",
|
|
74
|
+
"Access-Control-Request-Method": "GET",
|
|
75
|
+
})
|
|
76
|
+
assert "access-control-allow-origin" not in r.headers
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_cors_origins_injection():
|
|
80
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
81
|
+
conn.executescript(SCHEMA)
|
|
82
|
+
seed(conn)
|
|
83
|
+
app = appmod.create_app(SqliteRunner(conn), cors_origins=["http://a.com"])
|
|
84
|
+
c = TestClient(app)
|
|
85
|
+
ok = c.options("/projects", headers={"Origin": "http://a.com", "Access-Control-Request-Method": "GET"})
|
|
86
|
+
assert ok.headers.get("access-control-allow-origin") == "http://a.com"
|
|
87
|
+
nope = c.options("/projects", headers={"Origin": "http://a.co", "Access-Control-Request-Method": "GET"})
|
|
88
|
+
assert "access-control-allow-origin" not in nope.headers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_since_until_pass_through():
|
|
92
|
+
c, _ = make_client()
|
|
93
|
+
r = c.get("/projects", params={"since": 140000})
|
|
94
|
+
assert r.status_code == 200
|
|
95
|
+
assert [p["id"] for p in r.json()] == ["proj-ccc", "proj-bbb", "dir:2f55736572732f746573742f70726f6a656374732f6f70656e636f64652d64617368626f617264"]
|
|
96
|
+
r = c.get("/overview", params={"since": 140000, "until": 150000})
|
|
97
|
+
assert r.status_code == 200
|
|
98
|
+
assert r.json()["sessionCount"] == 1 # only s4
|
|
99
|
+
r = c.get("/sessions/s4", params={"until": 143500})
|
|
100
|
+
assert r.status_code == 200
|
|
101
|
+
assert [m["model"] for m in r.json()["models"]] == ["deepseek-v4-flash", "claude-sonnet-4.5"]
|
|
102
|
+
# session outside the window 404s
|
|
103
|
+
r = c.get("/sessions/s4", params={"since": 150000})
|
|
104
|
+
assert r.status_code == 404
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_non_integer_since_is_422():
|
|
108
|
+
c, _ = make_client()
|
|
109
|
+
assert c.get("/overview", params={"since": "abc"}).status_code == 422
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def make_cached_client():
|
|
113
|
+
"""App over a counting runner so cache behavior is observable via query count.
|
|
114
|
+
|
|
115
|
+
Note: TestClient is used WITHOUT a context manager, so lifespan (background
|
|
116
|
+
poll + pre-warm) never runs — cache state is driven purely by the requests
|
|
117
|
+
below. The one lifespan-driven test is test_lifespan_prewarms_whole_dataset.
|
|
118
|
+
"""
|
|
119
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
120
|
+
conn.executescript(SCHEMA)
|
|
121
|
+
seed(conn)
|
|
122
|
+
counter = CountingRunner(SqliteRunner(conn))
|
|
123
|
+
return TestClient(appmod.create_app(counter)), conn, counter
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_repeated_overview_served_from_cache():
|
|
127
|
+
c, _, counter = make_cached_client()
|
|
128
|
+
first = c.get("/overview")
|
|
129
|
+
assert first.status_code == 200
|
|
130
|
+
after_first = counter.queries
|
|
131
|
+
assert after_first > 0
|
|
132
|
+
second = c.get("/overview")
|
|
133
|
+
assert second.json() == first.json()
|
|
134
|
+
assert counter.queries == after_first # cache hit: no re-aggregation
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_refresh_recomputes_overview_after_db_change():
|
|
138
|
+
c, conn, counter = make_cached_client()
|
|
139
|
+
before = c.get("/overview").json()
|
|
140
|
+
assert before["sessionCount"] == 8
|
|
141
|
+
c.get("/overview") # warm the cache
|
|
142
|
+
c.get("/overview")
|
|
143
|
+
counter.queries = 0
|
|
144
|
+
conn.execute(
|
|
145
|
+
"INSERT INTO session (id, project_id, slug, directory, title, version,"
|
|
146
|
+
" time_created, time_updated) VALUES ('s9','proj-aaa','s9','/x','new',"
|
|
147
|
+
" 'local', 200000, 200000)"
|
|
148
|
+
)
|
|
149
|
+
appmod.Cache.refresh(c.app.state.cache, ("overview", None, None, None))
|
|
150
|
+
counter.queries = 0
|
|
151
|
+
after = c.get("/overview").json()
|
|
152
|
+
assert after["sessionCount"] == 9
|
|
153
|
+
assert after["updatedAt"] == 200000
|
|
154
|
+
assert counter.queries == 0 # served from the refreshed cache, no request-time query
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_failed_refresh_serves_stale_value():
|
|
158
|
+
c, _, counter = make_cached_client()
|
|
159
|
+
before = c.get("/overview").json()
|
|
160
|
+
c.get("/overview")
|
|
161
|
+
orig_query = counter._inner.query
|
|
162
|
+
def boom(sql, params=()):
|
|
163
|
+
raise RuntimeError("db down")
|
|
164
|
+
counter._inner.query = boom
|
|
165
|
+
with pytest.raises(RuntimeError):
|
|
166
|
+
appmod.Cache.refresh(c.app.state.cache, ("overview", None, None, None)) # refresh fails...
|
|
167
|
+
counter._inner.query = orig_query
|
|
168
|
+
after = c.get("/overview").json()
|
|
169
|
+
assert after == before # ...but the stale value is still served
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_unknown_project_404_cached():
|
|
173
|
+
c, _, counter = make_cached_client()
|
|
174
|
+
assert c.get("/projects/nope").status_code == 404
|
|
175
|
+
after_first = counter.queries
|
|
176
|
+
assert after_first > 0
|
|
177
|
+
assert c.get("/projects/nope").status_code == 404
|
|
178
|
+
assert counter.queries == after_first # 404s are cached too, no re-scan
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_first_fill_failure_returns_500():
|
|
182
|
+
c, _, counter = make_cached_client()
|
|
183
|
+
def boom(sql, params=()):
|
|
184
|
+
raise RuntimeError("db down")
|
|
185
|
+
counter._inner.query = boom
|
|
186
|
+
r = c.get("/overview") # nothing cached yet, so the fill failure must surface
|
|
187
|
+
assert r.status_code == 500
|
|
188
|
+
assert r.json() == {"detail": "aggregation failed"}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_lifespan_prewarms_whole_dataset_kinds():
|
|
192
|
+
"""with TestClient(...) runs lifespan: whole-dataset keys are pre-warmed (no
|
|
193
|
+
request needed) and poll_seconds is huge so the background loop just idles."""
|
|
194
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
195
|
+
conn.executescript(SCHEMA)
|
|
196
|
+
seed(conn)
|
|
197
|
+
counter = CountingRunner(SqliteRunner(conn))
|
|
198
|
+
app = appmod.create_app(counter, poll_seconds=3600)
|
|
199
|
+
with TestClient(app) as c:
|
|
200
|
+
assert counter.queries > 0 # pre-warm queried the DB with no request
|
|
201
|
+
assert c.app.state.cache.overview()["sessionCount"] == 8
|
|
202
|
+
assert {("projects", None, None, None), ("models", None, None, None)} <= set(c.app.state.cache._values)
|
|
203
|
+
first = c.get("/overview")
|
|
204
|
+
assert first.status_code == 200
|
|
205
|
+
assert first.json()["sessionCount"] == 8
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_poll_event_emits_updated_on_change_only():
|
|
209
|
+
payload, last = appmod.poll_event({"updatedAt": 100}, None, 1)
|
|
210
|
+
assert payload == {"type": "updated", "at": 100, "scope": "overview"}
|
|
211
|
+
assert last == 100
|
|
212
|
+
# no change, not a heartbeat tick -> nothing
|
|
213
|
+
assert appmod.poll_event({"updatedAt": 100}, 100, 2) == (None, 100)
|
|
214
|
+
# no change on a heartbeat tick (every 3rd) -> heartbeat
|
|
215
|
+
assert appmod.poll_event({"updatedAt": 100}, 100, 3) == ({"type": "heartbeat"}, 100)
|
|
216
|
+
# uncached overview -> nothing
|
|
217
|
+
assert appmod.poll_event(None, None, 3) == ({"type": "heartbeat"}, None)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_stream_hub_fans_out_to_subscribers():
|
|
221
|
+
async def scenario():
|
|
222
|
+
hub = appmod.StreamHub()
|
|
223
|
+
q1, q2 = await hub.subscribe(), await hub.subscribe()
|
|
224
|
+
await hub.broadcast({"type": "updated", "at": 1, "scope": "overview"})
|
|
225
|
+
return await q1.get(), await q2.get()
|
|
226
|
+
|
|
227
|
+
e1, e2 = asyncio.run(scenario())
|
|
228
|
+
assert e1 == e2 == {"type": "updated", "at": 1, "scope": "overview"}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_cache_keys_are_range_specific():
|
|
233
|
+
"""The cache must key by (kind, id, since, until): two different ranges (and
|
|
234
|
+
the all-time view) on the same endpoint must not collide."""
|
|
235
|
+
c, _, counter = make_cached_client()
|
|
236
|
+
all_time = c.get("/overview").json()
|
|
237
|
+
window = c.get("/overview", params={"since": 140000, "until": 150000}).json()
|
|
238
|
+
assert all_time["sessionCount"] == 8
|
|
239
|
+
assert window["sessionCount"] == 1 # only s4
|
|
240
|
+
assert c.get("/overview").json() == all_time # all-time entry untouched
|
|
241
|
+
assert c.get("/overview", params={"since": 140000, "until": 150000}).json() == window
|
|
242
|
+
after = counter.queries
|
|
243
|
+
# all variants are cached now — no re-aggregation on repeat hits
|
|
244
|
+
c.get("/overview")
|
|
245
|
+
c.get("/overview", params={"since": 140000, "until": 150000})
|
|
246
|
+
assert counter.queries == after
|
|
247
|
+
# distinct keys in the cache
|
|
248
|
+
keys = {k for k in c.app.state.cache._values if k[0] == "overview"}
|
|
249
|
+
assert ("overview", None, None, None) in keys
|
|
250
|
+
assert ("overview", None, 140000, 150000) in keys
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_poll_keys_cover_observed_range_variants():
|
|
254
|
+
c, _, _ = make_cached_client()
|
|
255
|
+
c.get("/overview", params={"since": 140000})
|
|
256
|
+
keys = c.app.state.cache.poll_keys()
|
|
257
|
+
assert ("overview", None, 140000, None) in keys # observed variant refreshed
|
|
258
|
+
assert ("overview", None, None, None) in keys # all-time always refreshed
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
"""FastAPI app implementing the API.md contract over the opencode aggregator.
|
|
2
|
-
|
|
3
|
-
Run: uv run uvicorn app:app --reload
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import asyncio
|
|
7
|
-
import json
|
|
8
|
-
import logging
|
|
9
|
-
import os
|
|
10
|
-
import socket
|
|
11
|
-
import subprocess
|
|
12
|
-
from functools import lru_cache
|
|
13
|
-
|
|
14
|
-
from fastapi import FastAPI, HTTPException
|
|
15
|
-
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
-
from fastapi.responses import StreamingResponse
|
|
17
|
-
|
|
18
|
-
import aggregate
|
|
19
|
-
from db import CliRunner
|
|
20
|
-
|
|
21
|
-
logger = logging.getLogger("dashboard")
|
|
22
|
-
|
|
23
|
-
# Comma-separated CORS allow-list; defaults to the loopback dev origins. Tight
|
|
24
|
-
# by default so a random webpage can't exfiltrate local project/session data.
|
|
25
|
-
def default_cors_origins() -> list[str]:
|
|
26
|
-
env = os.environ.get("DASHBOARD_CORS_ORIGINS", "").strip()
|
|
27
|
-
if env:
|
|
28
|
-
return [o.strip() for o in env.split(",") if o.strip()]
|
|
29
|
-
return [
|
|
30
|
-
"http://localhost:5173", "http://127.0.0.1:5173",
|
|
31
|
-
"http://localhost:4173", "http://127.0.0.1:4173",
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@lru_cache(maxsize=8)
|
|
36
|
-
def opencode_version(executable: str = "opencode") -> str:
|
|
37
|
-
try:
|
|
38
|
-
return subprocess.run(
|
|
39
|
-
[executable, "--version"], capture_output=True, text=True, check=True
|
|
40
|
-
).stdout.strip()
|
|
41
|
-
except (OSError, subprocess.CalledProcessError):
|
|
42
|
-
return "unknown" # e.g. CI without the opencode CLI; don't fail the request
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def create_app(runner=None, cors_origins=None, poll_seconds=None, opencode_bin=None) -> FastAPI:
|
|
46
|
-
bin = opencode_bin or os.environ.get("OPENCODE_BIN") or "opencode"
|
|
47
|
-
if runner is None:
|
|
48
|
-
runner = CliRunner(executable=bin)
|
|
49
|
-
origins = cors_origins if cors_origins is not None else default_cors_origins()
|
|
50
|
-
if poll_seconds is None:
|
|
51
|
-
try:
|
|
52
|
-
poll_seconds = float(os.environ.get("DASHBOARD_POLL_SECONDS", "5"))
|
|
53
|
-
except ValueError:
|
|
54
|
-
poll_seconds = 5.0
|
|
55
|
-
app = FastAPI(title="opencode token dashboard")
|
|
56
|
-
# Loopback-only API; restrict origins so a random webpage can't exfiltrate
|
|
57
|
-
# local project/session data from the browser (the client runs from Vite).
|
|
58
|
-
app.add_middleware(
|
|
59
|
-
CORSMiddleware,
|
|
60
|
-
allow_origins=origins,
|
|
61
|
-
allow_methods=["*"],
|
|
62
|
-
allow_headers=["*"],
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
def handle(fn):
|
|
66
|
-
try:
|
|
67
|
-
return fn()
|
|
68
|
-
except HTTPException:
|
|
69
|
-
raise
|
|
70
|
-
except Exception:
|
|
71
|
-
logger.exception("aggregation failed")
|
|
72
|
-
raise HTTPException(500, detail="aggregation failed")
|
|
73
|
-
|
|
74
|
-
@app.get("/health")
|
|
75
|
-
def health():
|
|
76
|
-
return {"status": "ok", "version": opencode_version(bin)}
|
|
77
|
-
|
|
78
|
-
@app.get("/overview")
|
|
79
|
-
def overview():
|
|
80
|
-
def run():
|
|
81
|
-
data = aggregate.overview(runner)
|
|
82
|
-
data["host"] = socket.gethostname()
|
|
83
|
-
data["opencodeVersion"] = opencode_version(bin)
|
|
84
|
-
return data
|
|
85
|
-
|
|
86
|
-
return handle(run)
|
|
87
|
-
|
|
88
|
-
@app.get("/projects")
|
|
89
|
-
def projects():
|
|
90
|
-
return handle(lambda: aggregate.projects(runner))
|
|
91
|
-
|
|
92
|
-
@app.get("/models")
|
|
93
|
-
def models():
|
|
94
|
-
return handle(lambda: aggregate.models(runner))
|
|
95
|
-
|
|
96
|
-
@app.get("/projects/{project_id}")
|
|
97
|
-
def project(project_id: str):
|
|
98
|
-
def run():
|
|
99
|
-
res = aggregate.project_detail(runner, project_id)
|
|
100
|
-
if res is None:
|
|
101
|
-
raise HTTPException(404, detail=f"project {project_id} not found")
|
|
102
|
-
proj, sessions = res
|
|
103
|
-
return {"project": proj, "sessions": sessions}
|
|
104
|
-
|
|
105
|
-
return handle(run)
|
|
106
|
-
|
|
107
|
-
@app.get("/sessions/{session_id}")
|
|
108
|
-
def session(session_id: str):
|
|
109
|
-
def run():
|
|
110
|
-
res = aggregate.session_detail(runner, session_id)
|
|
111
|
-
if res is None:
|
|
112
|
-
raise HTTPException(404, detail=f"session {session_id} not found")
|
|
113
|
-
sess, models = res
|
|
114
|
-
return {"session": sess, "models": models}
|
|
115
|
-
|
|
116
|
-
return handle(run)
|
|
117
|
-
|
|
118
|
-
@app.get("/stream")
|
|
119
|
-
async def stream():
|
|
120
|
-
q: asyncio.Queue = asyncio.Queue()
|
|
121
|
-
stop = asyncio.Event()
|
|
122
|
-
|
|
123
|
-
async def poll():
|
|
124
|
-
loop = asyncio.get_running_loop()
|
|
125
|
-
last = None
|
|
126
|
-
ticks = 0
|
|
127
|
-
while not stop.is_set():
|
|
128
|
-
try:
|
|
129
|
-
ts = await loop.run_in_executor(None, aggregate.updated_at, runner)
|
|
130
|
-
if ts and ts != last:
|
|
131
|
-
last = ts
|
|
132
|
-
await q.put({"type": "updated", "at": ts, "scope": "overview"})
|
|
133
|
-
else:
|
|
134
|
-
ticks += 1
|
|
135
|
-
if ticks % 3 == 0: # heartbeat every ~15s (poll = 5s)
|
|
136
|
-
await q.put({"type": "heartbeat"})
|
|
137
|
-
except Exception:
|
|
138
|
-
logger.exception("stream poll failed")
|
|
139
|
-
try:
|
|
140
|
-
await asyncio.wait_for(stop.wait(), poll_seconds)
|
|
141
|
-
except asyncio.TimeoutError:
|
|
142
|
-
pass
|
|
143
|
-
|
|
144
|
-
task = asyncio.create_task(poll())
|
|
145
|
-
await q.put({"type": "heartbeat"})
|
|
146
|
-
|
|
147
|
-
async def gen():
|
|
148
|
-
try:
|
|
149
|
-
while True:
|
|
150
|
-
payload = await q.get()
|
|
151
|
-
data = json.dumps(payload)
|
|
152
|
-
if payload["type"] == "updated":
|
|
153
|
-
yield f"event: update\ndata: {data}\n\n"
|
|
154
|
-
else:
|
|
155
|
-
yield f"data: {data}\n\n"
|
|
156
|
-
finally:
|
|
157
|
-
stop.set()
|
|
158
|
-
task.cancel()
|
|
159
|
-
|
|
160
|
-
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
161
|
-
|
|
162
|
-
return app
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
app = create_app()
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
"""HTTP-level smoke tests + the SQL-inlining boundary (CliRunner's only injection
|
|
2
|
-
surface). Driven via create_app(SqliteRunner) over the in-memory fixture, so no
|
|
3
|
-
`opencode db` CLI spawn happens."""
|
|
4
|
-
|
|
5
|
-
import sqlite3
|
|
6
|
-
|
|
7
|
-
from fastapi.testclient import TestClient
|
|
8
|
-
|
|
9
|
-
import app as appmod
|
|
10
|
-
from db import SqliteRunner, _inline
|
|
11
|
-
from tests.conftest import SCHEMA, seed
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def make_client() -> TestClient:
|
|
15
|
-
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
16
|
-
conn.executescript(SCHEMA)
|
|
17
|
-
seed(conn)
|
|
18
|
-
return TestClient(appmod.create_app(SqliteRunner(conn))), conn
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_inline_quotes_and_escapes():
|
|
22
|
-
sql = _inline("WHERE id = ? AND cost > ?", ("a'b\";\nDROP", 5))
|
|
23
|
-
assert sql == "WHERE id = 'a''b\";\nDROP' AND cost > 5"
|
|
24
|
-
assert 'DROP' in sql # payload stays inside one quoted literal
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_unknown_project_404():
|
|
28
|
-
c, _ = make_client()
|
|
29
|
-
r = c.get("/projects/nope")
|
|
30
|
-
assert r.status_code == 404
|
|
31
|
-
assert r.json() == {"detail": "project nope not found"}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_overview_keys():
|
|
35
|
-
c, _ = make_client()
|
|
36
|
-
r = c.get("/overview")
|
|
37
|
-
assert r.status_code == 200
|
|
38
|
-
assert {"host", "opencodeVersion", "projectCount", "sessionCount",
|
|
39
|
-
"tokens", "cost", "updatedAt"} <= set(r.json())
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_models_keys():
|
|
43
|
-
c, _ = make_client()
|
|
44
|
-
r = c.get("/models")
|
|
45
|
-
assert r.status_code == 200
|
|
46
|
-
rows = r.json()
|
|
47
|
-
assert rows # fixture seeds assistant token-bearing messages
|
|
48
|
-
assert {"model", "provider", "mode", "messageCount", "tokens", "cost"} <= set(rows[0])
|
|
49
|
-
assert rows == sorted(rows, key=lambda m: (-m["cost"], m["model"] or ""))
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_foreign_origin_not_allowed_by_cors():
|
|
53
|
-
c, _ = make_client()
|
|
54
|
-
r = c.options("/projects", headers={
|
|
55
|
-
"Origin": "https://evil.example",
|
|
56
|
-
"Access-Control-Request-Method": "GET",
|
|
57
|
-
})
|
|
58
|
-
assert "access-control-allow-origin" not in r.headers
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def test_cors_origins_injection():
|
|
62
|
-
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
63
|
-
conn.executescript(SCHEMA)
|
|
64
|
-
seed(conn)
|
|
65
|
-
app = appmod.create_app(SqliteRunner(conn), cors_origins=["http://a.com"])
|
|
66
|
-
c = TestClient(app)
|
|
67
|
-
ok = c.options("/projects", headers={"Origin": "http://a.com", "Access-Control-Request-Method": "GET"})
|
|
68
|
-
assert ok.headers.get("access-control-allow-origin") == "http://a.com"
|
|
69
|
-
nope = c.options("/projects", headers={"Origin": "http://a.co", "Access-Control-Request-Method": "GET"})
|
|
70
|
-
assert "access-control-allow-origin" not in nope.headers
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|