From 48555fe9ee51f3e6732f9d63e84dde1455551f93 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 15 May 2026 15:58:39 +0000 Subject: [PATCH 1/6] chore: sync dashboard UI from dashboard-ui@5a29729a1c26e8b81e8f2ef165ae6c1bbc827339 --- apiforgepy/dashboard.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apiforgepy/dashboard.py b/apiforgepy/dashboard.py index 9c6f08c..5f6e6a6 100644 --- a/apiforgepy/dashboard.py +++ b/apiforgepy/dashboard.py @@ -726,7 +726,7 @@ } // ─── Overview ───────────────────────────────────────────────────────────────── -function Overview({ timeRange, setRoute, setParams }) { +function Overview({ timeRange, setRoute, setParams, lastUpdated }) { const { ENDPOINTS, RELEASES, INSIGHTS, SUMMARY } = window.AF_DATA; const [globalTs, setGlobalTs] = useState(null); const hours = TIME_HOURS[timeRange] || 24; @@ -735,7 +735,7 @@ setGlobalTs(null); fetch(`/api/global-timeseries?hours=${hours}`) .then(r => r.json()).then(d => setGlobalTs(d)).catch(() => setGlobalTs([])); - }, [hours]); + }, [hours, lastUpdated]); const chartData = globalTs ? tsBucketsToChart(globalTs, hours) : null; const points = Math.max(chartData?.p90?.length || 0, 2); @@ -1057,7 +1057,7 @@ } // ─── Endpoint detail ────────────────────────────────────────────────────────── -function EndpointDetail({ id, timeRange, setRoute, setParams }) { +function EndpointDetail({ id, timeRange, setRoute, setParams, lastUpdated }) { const { ENDPOINTS, INSIGHTS } = window.AF_DATA; const ep = ENDPOINTS.find(e => e.id === id) || ENDPOINTS[0]; const [tab, setTab] = useState('performance'); @@ -1072,7 +1072,7 @@ setTs(null); fetch(`/api/timeseries?route=${encodeURIComponent(route)}&method=${encodeURIComponent(method)}&hours=${hours}`) .then(r => r.json()).then(d => setTs(d)).catch(() => setTs([])); - }, [id, hours]); + }, [id, hours, lastUpdated]); if (!ep) return
Endpoint not found.
; @@ -1635,9 +1635,9 @@ lastUpdated={lastUpdated} onRefresh={() => fetchData.current()}/>
- {route === 'overview' && } + {route === 'overview' && } {route === 'endpoints' && } - {route === 'endpoint' && } + {route === 'endpoint' && } {route === 'insights' && } {route === 'releases' && } {route === 'settings' && } From 88089413e8e8cee2fc2fa63cc34862b1667a76d0 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 18:06:30 +0200 Subject: [PATCH 2/6] fix: dashboard charts auto-refresh every 30s + bump version to 1.0.1 Pass lastUpdated prop to Overview and EndpointDetail so their useEffect re-fetches time series data on each polling tick. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4590b00..5ef0d42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apiforgepy" -version = "1.0.0" +version = "1.0.1" description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" readme = "README.md" license = { text = "MIT" } From 13a253e4c3651192fe5b0ab03e5f28a604195ff2 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 18:23:00 +0200 Subject: [PATCH 3/6] fix: release markers order in chart + bump version to 1.0.2 Reverse the two most recent releases before placing markers so the oldest appears on the left (45%) and the newest on the right (80%), matching the chart's left-to-right chronological axis. --- apiforgepy/dashboard.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apiforgepy/dashboard.py b/apiforgepy/dashboard.py index 5f6e6a6..f6e7aa3 100644 --- a/apiforgepy/dashboard.py +++ b/apiforgepy/dashboard.py @@ -748,7 +748,7 @@ 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) => ({ + const releaseMarkers = [...(RELEASES || [])].slice(0,2).reverse().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', diff --git a/pyproject.toml b/pyproject.toml index 5ef0d42..adcd7bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ 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" } From 4ec8199fbd7350f32767ccda30818f19430da876 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 18:29:34 +0200 Subject: [PATCH 4/6] fix: position release markers from real timestamps, show all in window Replace hardcoded 45%/80% positions and 2-marker cap with dynamic placement: each release within the current time range is snapped to its nearest time-series bucket, so all releases remain visible. --- apiforgepy/dashboard.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apiforgepy/dashboard.py b/apiforgepy/dashboard.py index f6e7aa3..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).reverse().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); From 824a45ed80c30923c3b678a1980205485870e032 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 18:44:21 +0200 Subject: [PATCH 5/6] fix: stale untracked routes in insights + add get_releases + routes count - upsert_known_routes: delete zero-traffic routes removed from the app so they no longer appear in insights after being removed from code - get_releases: new method counting known_routes instead of api_metrics so routes with no traffic are included in the release count --- apiforgepy/database.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apiforgepy/database.py b/apiforgepy/database.py index 9557eca..4506174 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,19 @@ 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(""" + SELECT release_tag, + MIN(bucket_ts) as release_ts, + (SELECT COUNT(*) FROM known_routes) as routes_affected + FROM api_metrics + WHERE release_tag IS NOT NULL AND release_tag != '' + GROUP BY release_tag + ORDER BY 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(""" From 2efe1c8e8c9533bcb24896f17018ea846f62d17b Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 15 May 2026 18:50:53 +0200 Subject: [PATCH 6/6] fix: routes_affected counts routes existing at each release's deploy time Use a CTE with first_seen <= release_ts + 60s so each release shows the route count at the time it was deployed, not the current total. The 60s buffer covers the bucket rounding (floor to the minute). --- apiforgepy/database.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apiforgepy/database.py b/apiforgepy/database.py index 4506174..6323fd7 100644 --- a/apiforgepy/database.py +++ b/apiforgepy/database.py @@ -239,13 +239,17 @@ def get_untracked_routes(self) -> list[dict]: def get_releases(self) -> list[dict]: rows = self._conn.execute(""" - SELECT release_tag, - MIN(bucket_ts) as release_ts, - (SELECT COUNT(*) FROM known_routes) as routes_affected - FROM api_metrics - WHERE release_tag IS NOT NULL AND release_tag != '' - GROUP BY release_tag - ORDER BY release_ts DESC + 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]