Add analysis status panel to admin page
- GET /api/admin/stats returns total bills, docs fetched, briefs generated (full + amendment), and remaining count - Admin page shows stat cards + progress bar, auto-refreshes every 30s Authored-By: Jack Levy
This commit is contained in:
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.dependencies import get_current_admin
|
from app.core.dependencies import get_current_admin
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Follow
|
from app.models import Bill, BillBrief, BillDocument, Follow
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.schemas import UserResponse
|
from app.schemas.schemas import UserResponse
|
||||||
|
|
||||||
@@ -79,6 +79,35 @@ async def toggle_admin(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analysis Stats ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Return analysis pipeline progress counters."""
|
||||||
|
total_bills = (await db.execute(select(func.count()).select_from(Bill))).scalar()
|
||||||
|
docs_fetched = (await db.execute(
|
||||||
|
select(func.count()).select_from(BillDocument).where(BillDocument.raw_text.isnot(None))
|
||||||
|
)).scalar()
|
||||||
|
total_briefs = (await db.execute(select(func.count()).select_from(BillBrief))).scalar()
|
||||||
|
full_briefs = (await db.execute(
|
||||||
|
select(func.count()).select_from(BillBrief).where(BillBrief.brief_type == "full")
|
||||||
|
)).scalar()
|
||||||
|
amendment_briefs = (await db.execute(
|
||||||
|
select(func.count()).select_from(BillBrief).where(BillBrief.brief_type == "amendment")
|
||||||
|
)).scalar()
|
||||||
|
return {
|
||||||
|
"total_bills": total_bills,
|
||||||
|
"docs_fetched": docs_fetched,
|
||||||
|
"briefs_generated": total_briefs,
|
||||||
|
"full_briefs": full_briefs,
|
||||||
|
"amendment_briefs": amendment_briefs,
|
||||||
|
"remaining": total_bills - total_briefs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Celery Tasks ──────────────────────────────────────────────────────────────
|
# ── Celery Tasks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/trigger-poll")
|
@router.post("/trigger-poll")
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
|
FileText,
|
||||||
|
Brain,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { settingsAPI, adminAPI, type AdminUser } from "@/lib/api";
|
import { settingsAPI, adminAPI, type AdminUser } from "@/lib/api";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
@@ -33,6 +36,13 @@ export default function SettingsPage() {
|
|||||||
queryFn: () => settingsAPI.get(),
|
queryFn: () => settingsAPI.get(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ["admin-stats"],
|
||||||
|
queryFn: () => adminAPI.getStats(),
|
||||||
|
enabled: !!currentUser?.is_admin,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: users, isLoading: usersLoading } = useQuery({
|
const { data: users, isLoading: usersLoading } = useQuery({
|
||||||
queryKey: ["admin-users"],
|
queryKey: ["admin-users"],
|
||||||
queryFn: () => adminAPI.listUsers(),
|
queryFn: () => adminAPI.listUsers(),
|
||||||
@@ -85,13 +95,13 @@ export default function SettingsPage() {
|
|||||||
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
|
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
|
||||||
|
|
||||||
if (!currentUser?.is_admin) {
|
if (!currentUser?.is_admin) {
|
||||||
return (
|
return <div className="text-center py-20 text-muted-foreground">Admin access required.</div>;
|
||||||
<div className="text-center py-20 text-muted-foreground">
|
|
||||||
Admin access required.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pct = stats && stats.total_bills > 0
|
||||||
|
? Math.round((stats.briefs_generated / stats.total_bills) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-2xl">
|
<div className="space-y-8 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
@@ -101,6 +111,51 @@ export default function SettingsPage() {
|
|||||||
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
|
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis Status */}
|
||||||
|
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4" /> Analysis Status
|
||||||
|
<span className="text-xs text-muted-foreground font-normal ml-auto">refreshes every 30s</span>
|
||||||
|
</h2>
|
||||||
|
{stats ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
|
<FileText className="w-4 h-4 mx-auto mb-1 text-muted-foreground" />
|
||||||
|
<div className="text-xl font-bold">{stats.total_bills.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Total Bills</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
|
<FileText className="w-4 h-4 mx-auto mb-1 text-blue-500" />
|
||||||
|
<div className="text-xl font-bold">{stats.docs_fetched.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Docs Fetched</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
|
<Brain className="w-4 h-4 mx-auto mb-1 text-green-500" />
|
||||||
|
<div className="text-xl font-bold">{stats.briefs_generated.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Briefs Generated</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{stats.full_briefs} full · {stats.amendment_briefs} amendments</span>
|
||||||
|
<span>{pct}% analyzed · {stats.remaining.toLocaleString()} remaining</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading stats...</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* User Management */}
|
{/* User Management */}
|
||||||
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
|
||||||
<h2 className="font-semibold flex items-center gap-2">
|
<h2 className="font-semibold flex items-center gap-2">
|
||||||
@@ -137,19 +192,12 @@ export default function SettingsPage() {
|
|||||||
title={u.is_admin ? "Remove admin" : "Make admin"}
|
title={u.is_admin ? "Remove admin" : "Make admin"}
|
||||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
{u.is_admin ? (
|
{u.is_admin ? <ShieldOff className="w-4 h-4" /> : <ShieldCheck className="w-4 h-4" />}
|
||||||
<ShieldOff className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ShieldCheck className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
{confirmDelete === u.id ? (
|
{confirmDelete === u.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { deleteUser.mutate(u.id); setConfirmDelete(null); }}
|
||||||
deleteUser.mutate(u.id);
|
|
||||||
setConfirmDelete(null);
|
|
||||||
}}
|
|
||||||
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90"
|
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
@@ -204,7 +252,6 @@ export default function SettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
<div className="flex items-center gap-3 pt-2 border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={testLLM}
|
onClick={testLLM}
|
||||||
|
|||||||
@@ -123,8 +123,20 @@ export interface AdminUser {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnalysisStats {
|
||||||
|
total_bills: number;
|
||||||
|
docs_fetched: number;
|
||||||
|
briefs_generated: number;
|
||||||
|
full_briefs: number;
|
||||||
|
amendment_briefs: number;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
export const adminAPI = {
|
export const adminAPI = {
|
||||||
|
// Stats
|
||||||
|
getStats: () =>
|
||||||
|
apiClient.get<AnalysisStats>("/api/admin/stats").then((r) => r.data),
|
||||||
// Users
|
// Users
|
||||||
listUsers: () =>
|
listUsers: () =>
|
||||||
apiClient.get<AdminUser[]>("/api/admin/users").then((r) => r.data),
|
apiClient.get<AdminUser[]>("/api/admin/users").then((r) => r.data),
|
||||||
|
|||||||
Reference in New Issue
Block a user