diff --git a/apiforgepy/dashboard.py b/apiforgepy/dashboard.py index 5f6e6a6..41b9f2a 100644 --- a/apiforgepy/dashboard.py +++ b/apiforgepy/dashboard.py @@ -430,6 +430,7 @@ function mapReleases(releases) { return (releases || []).map(r => ({ tag: r.release_tag, + ts: r.release_ts, summary: `${r.routes_affected || 0} route${r.routes_affected !== 1 ? 's' : ''} recorded`, age: formatAge(r.release_ts), by: 'local', @@ -748,11 +749,18 @@ const globalCalls = chartData?.calls || Array(fallbackPts).fill(0); const xLabelsFinal = xLabels.length > 0 ? xLabels : Array.from({length:globalP90.length}, (_,i) => `${i}`); - const releaseMarkers = (RELEASES || []).slice(0,2).map((r, i) => ({ - idx: Math.min(Math.floor(globalP90.length * (0.45 + i * 0.35)), globalP90.length - 1), - label: r.tag, - color: i === 0 ? '#b91c1c' : '#15803d', - })); + const MARKER_COLORS = ['#b91c1c','#15803d','#2563eb','#b45309','#7c3aed']; + const nowTs = Date.now() / 1000; + const releaseMarkers = globalTs && globalTs.length > 0 + ? [...(RELEASES || [])] + .filter(r => r.ts != null && r.ts >= nowTs - hours * 3600) + .reverse() + .map((r, i) => { + const idx = globalTs.reduce((best, b, j) => + Math.abs(b.bucket_ts - r.ts) < Math.abs(globalTs[best].bucket_ts - r.ts) ? j : best, 0); + return { idx, label: r.tag, color: MARKER_COLORS[i % MARKER_COLORS.length] }; + }) + : []; const topSlow = [...ENDPOINTS].filter(e => !e.untracked && e.base_p90 > 0).sort((a,b) => b.base_p90-a.base_p90).slice(0,5); const topCalled = [...ENDPOINTS].sort((a,b) => b.calls24h-a.calls24h).slice(0,5); diff --git a/apiforgepy/database.py b/apiforgepy/database.py index 9557eca..6323fd7 100644 --- a/apiforgepy/database.py +++ b/apiforgepy/database.py @@ -75,6 +75,17 @@ def upsert_known_routes(self, routes: list[dict]): INSERT INTO known_routes (route, method) VALUES (?, ?) ON CONFLICT (route, method) DO NOTHING """, [(r["route"], r["method"]) for r in routes]) + if routes: + keys = [f"{r['route']}|{r['method']}" for r in routes] + ph = ",".join("?" * len(keys)) + self._conn.execute(f""" + DELETE FROM known_routes + WHERE route || '|' || method NOT IN ({ph}) + AND NOT EXISTS ( + SELECT 1 FROM api_metrics m + WHERE m.route = known_routes.route AND m.method = known_routes.method + ) + """, keys) self._conn.commit() def get_summary(self) -> dict: @@ -226,6 +237,23 @@ def get_untracked_routes(self) -> list[dict]: """).fetchall() return [dict(r) for r in rows] + def get_releases(self) -> list[dict]: + rows = self._conn.execute(""" + WITH release_times AS ( + SELECT release_tag, MIN(bucket_ts) AS release_ts + FROM api_metrics + WHERE release_tag IS NOT NULL AND release_tag != '' + GROUP BY release_tag + ) + SELECT rt.release_tag, + rt.release_ts, + (SELECT COUNT(*) FROM known_routes WHERE first_seen <= rt.release_ts + 60) AS routes_affected + FROM release_times rt + ORDER BY rt.release_ts DESC + LIMIT 20 + """).fetchall() + return [dict(r) for r in rows] + def get_global_time_series(self, hours: int = 24) -> list[dict]: since = _now_sec() - hours * 3600 rows = self._conn.execute(""" diff --git a/pyproject.toml b/pyproject.toml index 5ef0d42..995f5b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "apiforgepy" -version = "1.0.1" +version = "1.0.2" + description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" readme = "README.md" license = { text = "MIT" }