Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 7fb4a16

Browse files
committed
feat: turn the frontend into a dashboard + add status/history/root API endpoints
The frontend was a single-review page and the backend's base URL 404'd, which reads as "nothing is deployed" even on a healthy service. Both fixed. Backend: - GET / now returns a friendly service description (what it is, the endpoints, the source) instead of 404 -- the base URL is what people paste first. - GET /api/status: provider, model, config hash, and daily token budget, for the dashboard's status bar. Degrades to nulls rather than 500 if the budget store blips -- a status panel that can't render is worse than one showing "unknown". - GET /api/reviews: recent reviews, newest first, bounded 1..100 -- the history view, a read over the same cache table reviews already land in. Added ReviewCachePort.list_recent with SQLite + Postgres implementations. - ReviewService gained display-only provider/model_label fields (never touch review logic; the composition root fills them from Settings). Frontend, now a dashboard: - StatusBar: live backend-online / model / budget pills, polled every 15s. - Process-log console: each SSE event rendered as a timestamped line ("what is the agent doing right now"), autoscrolling -- the useReview hook now also accumulates this human-readable log alongside the structured state. - History panel: recent reviews from the API, each expandable to its findings, refreshed automatically when a review completes. - Two-column layout: run-a-review + live activity on the left, history on the right. Verified live in a real browser against the local backend: status bar showed ollama / llama3.1:8b / 0-100k budget, ran python/mypy#21647 (cache hit), watched the process log and 5 findings render with citations, confirmed history was populated from the real Postgres cache. Four new API tests (root, status, empty- then-populated history, limit cap); full suite green (the import-linter subprocess test is blocked by a local Windows AppControl policy but passes in CI and its contracts verify clean via the Python API).
1 parent 1cd4272 commit 7fb4a16

14 files changed

Lines changed: 564 additions & 137 deletions

File tree

app/domain/ports.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ async def get_latest(self, repo: RepoRef, pr_number: int) -> Review | None:
215215
"""
216216
...
217217

218+
async def list_recent(self, limit: int) -> Sequence[Review]:
219+
"""The most recently cached reviews, newest first, for a history view.
220+
221+
why: the cache already holds every completed review keyed by config; a dashboard that
222+
shows "what has this thing reviewed" is a read over that same table, not a new store.
223+
Distinct from ``get_latest`` (one PR) -- this is across all PRs.
224+
"""
225+
...
226+
218227

219228
@runtime_checkable
220229
class BudgetPort(Protocol):

app/infrastructure/persistence/postgres_review_cache.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ def _get_latest_sync(self, repo: RepoRef, pr_number: int) -> Review | None:
8282
row = cursor.fetchone()
8383
return _review_from_json(row["payload"]) if row is not None else None
8484

85+
async def list_recent(self, limit: int) -> list[Review]:
86+
return await asyncio.to_thread(self._list_recent_sync, limit)
87+
88+
def _list_recent_sync(self, limit: int) -> list[Review]:
89+
with self._connection.cursor() as cursor:
90+
cursor.execute(
91+
"SELECT payload FROM review_cache ORDER BY created_at DESC LIMIT %s", (limit,)
92+
)
93+
rows = cursor.fetchall()
94+
return [_review_from_json(row["payload"]) for row in rows]
95+
8596
async def put(self, cache_key: str, review: Review) -> None:
8697
await asyncio.to_thread(self._put_sync, cache_key, review)
8798

app/infrastructure/persistence/review_cache.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ async def get_latest(self, repo: RepoRef, pr_number: int) -> Review | None:
7474
return None
7575
return _review_from_json(json.loads(row["payload"]))
7676

77+
async def list_recent(self, limit: int) -> list[Review]:
78+
rows = self._connection.execute(
79+
"SELECT payload FROM review_cache ORDER BY created_at DESC, rowid DESC LIMIT ?",
80+
(limit,),
81+
).fetchall()
82+
return [_review_from_json(json.loads(row["payload"])) for row in rows]
83+
7784
async def put(self, cache_key: str, review: Review) -> None:
7885
# why: INSERT OR REPLACE rather than INSERT -- a config change that invalidates a key
7986
# (a new prompt version, say) produces a *different* cache_key naturally, so a

app/interface/api/app.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ def create_app(
6565
allow_headers=["Content-Type", "Idempotency-Key"],
6666
)
6767

68+
@app.get("/")
69+
async def root() -> dict[str, Any]:
70+
"""A friendly landing for anyone who opens the API's base URL directly.
71+
72+
why this exists: the base URL is what people paste into a browser first, and a bare
73+
404 there reads as "nothing is deployed" even when the service is perfectly healthy.
74+
This says what the service is and points at the human UI and the docs.
75+
"""
76+
return {
77+
"service": "Quorum",
78+
"what": "A supervisor agent that reviews pull requests with citation-backed findings.",
79+
"endpoints": {
80+
"health": "/healthz",
81+
"readiness": "/readyz",
82+
"status": "/api/status",
83+
"recent_reviews": "/api/reviews",
84+
"start_review": "POST /api/reviews {repo, pr_number}",
85+
"api_docs": "/docs",
86+
},
87+
"source": "https://github.com/sahil7359/Quorum",
88+
}
89+
6890
@app.get("/healthz")
6991
async def healthz() -> dict[str, str]:
7092
"""Liveness: the process can answer HTTP at all.
@@ -92,6 +114,39 @@ async def readyz() -> dict[str, str]:
92114
raise HTTPException(status_code=503, detail=f"budget store unreachable: {exc}") from exc
93115
return {"status": "ready"}
94116

117+
@app.get("/api/status")
118+
async def status() -> dict[str, Any]:
119+
"""A snapshot for the dashboard's status panel: what this instance is, and its budget.
120+
121+
Degrades rather than 500s if the budget store is briefly unreachable -- a status panel
122+
that can't render because one number is missing is worse than one showing the number as
123+
unknown.
124+
"""
125+
budget: dict[str, Any]
126+
try:
127+
state = await service.budget.state()
128+
budget = {
129+
"consumed": state.consumed,
130+
"limit": state.limit,
131+
"exhausted": state.exhausted,
132+
}
133+
except Exception:
134+
budget = {"consumed": None, "limit": None, "exhausted": None}
135+
return {
136+
"provider": service.provider,
137+
"model": service.model_label,
138+
"config_hash": service.config_hash,
139+
"budget": budget,
140+
}
141+
142+
@app.get("/api/reviews")
143+
async def list_reviews(limit: int = 20) -> list[dict[str, Any]]:
144+
"""Recent reviews, newest first -- the dashboard's history. Bounded so a caller cannot
145+
ask for the whole table."""
146+
capped = max(1, min(limit, 100))
147+
reviews = await service.cache.list_recent(capped)
148+
return [review_event(r) for r in reviews]
149+
95150
@app.post("/api/reviews")
96151
async def post_review(
97152
body: ReviewRequest,

app/interface/composition.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@
105105
config_hash=f"{settings.prompt_version}:{settings.chunker_version}",
106106
max_diff_lines=settings.max_diff_lines,
107107
retrieval_top_k=settings.retrieval_top_k,
108+
provider=settings.llm_provider,
109+
model_label=settings.specialist_model,
108110
)
109111

110112

app/interface/review_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ class ReviewService:
5858
config_hash: str
5959
max_diff_lines: int
6060
retrieval_top_k: int
61+
# Display-only, for the dashboard's status panel. Empty in tests; the composition root
62+
# fills them from Settings. They never touch review logic -- purely "what is this instance
63+
# configured as", surfaced so the UI doesn't have to guess.
64+
provider: str = ""
65+
model_label: str = ""
6166
# why optional: every test-built service uses a retriever that needs no ingestion step
6267
# (FakeRetriever, or a store pre-populated directly). Only the real composition root,
6368
# reviewing repos nobody has pre-ingested, needs this -- see ingestion_service.py.

frontend/components/EventLog.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
5+
import type { LogLine } from "@/hooks/useReview";
6+
7+
function clock(t: number): string {
8+
return new Date(t).toLocaleTimeString([], { hour12: false });
9+
}
10+
11+
export function EventLog({ log }: { log: LogLine[] }) {
12+
const endRef = useRef<HTMLDivElement>(null);
13+
14+
// Autoscroll to the newest line as the review streams -- a log console that doesn't follow
15+
// its own tail makes the reader chase it.
16+
useEffect(() => {
17+
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
18+
}, [log]);
19+
20+
return (
21+
<div className="rounded-lg border border-white/10 bg-black/30 p-3">
22+
<p className="mb-2 text-xs font-medium opacity-50">Process log</p>
23+
<div className="max-h-56 overflow-y-auto font-mono text-xs leading-relaxed">
24+
{log.length === 0 ? (
25+
<p className="opacity-30">waiting for events…</p>
26+
) : (
27+
log.map((line, i) => (
28+
<div key={i} className="flex gap-2">
29+
<span className="shrink-0 opacity-30">{clock(line.t)}</span>
30+
<span className="shrink-0 w-40 truncate text-sky-300/70">{line.event}</span>
31+
<span className="opacity-80">{line.detail}</span>
32+
</div>
33+
))
34+
)}
35+
<div ref={endRef} />
36+
</div>
37+
</div>
38+
);
39+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
import { FindingCard } from "@/components/FindingCard";
6+
import { SeverityBadge } from "@/components/SeverityBadge";
7+
import type { ReviewCompleted } from "@/lib/types";
8+
9+
function topSeverity(r: ReviewCompleted) {
10+
const order = { high: 3, medium: 2, low: 1, info: 0 } as const;
11+
return r.findings.reduce<ReviewCompleted["findings"][number] | null>((best, f) => {
12+
if (!best || order[f.severity] > order[best.severity]) return f;
13+
return best;
14+
}, null);
15+
}
16+
17+
function Row({ review }: { review: ReviewCompleted }) {
18+
const [open, setOpen] = useState(false);
19+
const top = topSeverity(review);
20+
return (
21+
<li className="rounded-lg border border-white/10">
22+
<button
23+
type="button"
24+
onClick={() => setOpen((o) => !o)}
25+
className="flex w-full items-center justify-between gap-3 p-3 text-left transition hover:bg-white/5"
26+
>
27+
<span className="min-w-0">
28+
<span className="text-sm font-medium">
29+
{review.repo}#{review.pr_number}
30+
</span>
31+
<span className="ml-2 text-xs opacity-50">
32+
{review.findings.length} finding{review.findings.length === 1 ? "" : "s"}
33+
{review.posted_to_github ? " · posted" : ""}
34+
</span>
35+
</span>
36+
{top ? <SeverityBadge severity={top.severity} /> : <span className="text-xs opacity-40">clean</span>}
37+
</button>
38+
{open && review.findings.length > 0 && (
39+
<ul className="flex flex-col gap-2 border-t border-white/10 p-3">
40+
{review.findings.map((f) => (
41+
<FindingCard key={f.finding_id} finding={f} />
42+
))}
43+
</ul>
44+
)}
45+
{open && review.findings.length === 0 && (
46+
<p className="border-t border-white/10 p-3 text-xs opacity-50">
47+
No findings survived citation checks.
48+
</p>
49+
)}
50+
</li>
51+
);
52+
}
53+
54+
export function HistoryPanel({ reviews }: { reviews: ReviewCompleted[] }) {
55+
if (reviews.length === 0) {
56+
return (
57+
<p className="text-xs opacity-40">
58+
No reviews yet. Run one above and it appears here (cached, so it&apos;s instant next time).
59+
</p>
60+
);
61+
}
62+
return (
63+
<ul className="flex flex-col gap-2">
64+
{reviews.map((r) => (
65+
<Row key={`${r.run_id}`} review={r} />
66+
))}
67+
</ul>
68+
);
69+
}

0 commit comments

Comments
 (0)