Skip to content

Commit e686009

Browse files
committed
feat: add Next.js frontend for city surveillance dashboard
- Dark-theme dashboard with stat cards, severity pie chart, incident type bar chart - Multi-camera live grid (2/3 col toggle) with per-cell VLM analysis trigger - Incident table with filter, resolve/false-alarm actions - AI Agent chat page with quick-suggestion prompts and DB context display - Shift report generator with time window + district filter and TXT download - Camera manager (CRUD, toggle active, delete) with go2rtc registration - Real-time alert ticker bar via WebSocket with auto-reconnect - Full TypeScript, Tailwind dark theme, Recharts for charts - Dockerfile (multi-stage) + docker-compose frontend service Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXvYppU6bAHzS9R8HbTHdN
1 parent be0fe58 commit e686009

29 files changed

Lines changed: 1209 additions & 0 deletions

docker/docker-compose-backend.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ services:
8282
volumes:
8383
- snapshots:/tmp/snapshots
8484

85+
frontend:
86+
build:
87+
context: ../src/frontend
88+
dockerfile: Dockerfile
89+
args:
90+
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
91+
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:8000}
92+
ports:
93+
- "3000:3000"
94+
depends_on:
95+
- backend
96+
8597
volumes:
8698
pg_data:
8799
redis_data:

src/frontend/Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM node:20-alpine AS builder
2+
WORKDIR /app
3+
COPY package.json ./
4+
RUN npm install
5+
COPY . .
6+
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
7+
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8000
8+
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
9+
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
10+
RUN npm run build
11+
12+
FROM node:20-alpine AS runner
13+
WORKDIR /app
14+
ENV NODE_ENV=production
15+
COPY --from=builder /app/.next/standalone ./
16+
COPY --from=builder /app/.next/static ./.next/static
17+
EXPOSE 3000
18+
CMD ["node", "server.js"]

src/frontend/app/agent/page.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
'use client'
2+
import { useState, useRef, useEffect } from 'react'
3+
import { api } from '@/lib/api'
4+
import { Send, Bot, User, Loader2 } from 'lucide-react'
5+
import { fmtTime } from '@/lib/utils'
6+
7+
interface Message {
8+
role: 'user' | 'assistant'
9+
content: string
10+
time: string
11+
context?: number
12+
}
13+
14+
const SUGGESTIONS = [
15+
'How many incidents occurred in the last 24 hours?',
16+
'List all critical incidents today',
17+
'Which camera has the most incidents?',
18+
'Summarize traffic accidents from this week',
19+
]
20+
21+
export default function AgentPage() {
22+
const [messages, setMessages] = useState<Message[]>([])
23+
const [input, setInput] = useState('')
24+
const [loading, setLoading] = useState(false)
25+
const bottomRef = useRef<HTMLDivElement>(null)
26+
27+
useEffect(() => {
28+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
29+
}, [messages])
30+
31+
async function send(question: string) {
32+
if (!question.trim() || loading) return
33+
const userMsg: Message = { role: 'user', content: question, time: new Date().toISOString() }
34+
setMessages((prev) => [...prev, userMsg])
35+
setInput('')
36+
setLoading(true)
37+
38+
try {
39+
const res = await api.agent.query(question)
40+
setMessages((prev) => [
41+
...prev,
42+
{
43+
role: 'assistant',
44+
content: res.answer,
45+
time: new Date().toISOString(),
46+
context: res.incidents_in_context,
47+
},
48+
])
49+
} catch (e) {
50+
setMessages((prev) => [
51+
...prev,
52+
{ role: 'assistant', content: 'Failed to get a response. Is the backend running?', time: new Date().toISOString() },
53+
])
54+
} finally {
55+
setLoading(false)
56+
}
57+
}
58+
59+
return (
60+
<div className="flex flex-col h-full max-w-3xl mx-auto">
61+
<h1 className="text-xl font-semibold text-white mb-4">AI Agent</h1>
62+
63+
{/* Chat window */}
64+
<div className="flex-1 bg-gray-900 rounded-xl border border-gray-800 flex flex-col overflow-hidden">
65+
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
66+
{messages.length === 0 && (
67+
<div className="text-center pt-8">
68+
<Bot size={40} className="mx-auto text-brand-500 mb-3" />
69+
<p className="text-gray-400 text-sm mb-6">Ask anything about incidents, cameras, or traffic patterns.</p>
70+
<div className="grid grid-cols-2 gap-2">
71+
{SUGGESTIONS.map((s) => (
72+
<button
73+
key={s}
74+
onClick={() => send(s)}
75+
className="text-left text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-2 rounded-lg transition-colors"
76+
>
77+
{s}
78+
</button>
79+
))}
80+
</div>
81+
</div>
82+
)}
83+
84+
{messages.map((msg, i) => (
85+
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
86+
{msg.role === 'assistant' && (
87+
<div className="w-7 h-7 rounded-full bg-brand-600 flex items-center justify-center flex-shrink-0 mt-1">
88+
<Bot size={14} />
89+
</div>
90+
)}
91+
<div className={`max-w-lg ${msg.role === 'user' ? 'items-end' : 'items-start'} flex flex-col gap-1`}>
92+
<div className={`px-4 py-2.5 rounded-2xl text-sm whitespace-pre-wrap ${
93+
msg.role === 'user'
94+
? 'bg-brand-600 text-white rounded-br-sm'
95+
: 'bg-gray-800 text-gray-200 rounded-bl-sm'
96+
}`}>
97+
{msg.content}
98+
</div>
99+
<span className="text-xs text-gray-600">
100+
{fmtTime(msg.time)}
101+
{msg.context != null && ` · ${msg.context} incidents in context`}
102+
</span>
103+
</div>
104+
{msg.role === 'user' && (
105+
<div className="w-7 h-7 rounded-full bg-gray-700 flex items-center justify-center flex-shrink-0 mt-1">
106+
<User size={14} />
107+
</div>
108+
)}
109+
</div>
110+
))}
111+
112+
{loading && (
113+
<div className="flex gap-3">
114+
<div className="w-7 h-7 rounded-full bg-brand-600 flex items-center justify-center">
115+
<Bot size={14} />
116+
</div>
117+
<div className="bg-gray-800 rounded-2xl rounded-bl-sm px-4 py-3 flex items-center gap-2">
118+
<Loader2 size={14} className="animate-spin text-brand-400" />
119+
<span className="text-gray-400 text-sm">Thinking…</span>
120+
</div>
121+
</div>
122+
)}
123+
<div ref={bottomRef} />
124+
</div>
125+
126+
{/* Input bar */}
127+
<div className="border-t border-gray-800 p-3 flex gap-2">
128+
<input
129+
type="text"
130+
value={input}
131+
onChange={(e) => setInput(e.target.value)}
132+
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && send(input)}
133+
placeholder="Ask about incidents, cameras, or traffic…"
134+
className="flex-1 bg-gray-800 text-white text-sm rounded-lg px-4 py-2.5 outline-none placeholder-gray-600 focus:ring-1 focus:ring-brand-500"
135+
/>
136+
<button
137+
onClick={() => send(input)}
138+
disabled={loading || !input.trim()}
139+
className="bg-brand-600 hover:bg-brand-500 disabled:opacity-50 text-white px-4 py-2.5 rounded-lg transition-colors"
140+
>
141+
<Send size={15} />
142+
</button>
143+
</div>
144+
</div>
145+
</div>
146+
)
147+
}

src/frontend/app/cameras/page.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { api } from '@/lib/api'
2+
import { CameraManager } from '@/components/camera/CameraManager'
3+
4+
export const revalidate = 0
5+
6+
export default async function CamerasPage() {
7+
const cameras = await api.cameras.list().catch(() => [])
8+
return (
9+
<div className="space-y-4">
10+
<h1 className="text-xl font-semibold text-white">Cameras</h1>
11+
<CameraManager initialCameras={cameras} />
12+
</div>
13+
)
14+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { api } from '@/lib/api'
2+
import { StatCard } from '@/components/ui/StatCard'
3+
import { SeverityChart } from '@/components/incident/SeverityChart'
4+
import { IncidentTypeChart } from '@/components/incident/IncidentTypeChart'
5+
import { RecentIncidents } from '@/components/incident/RecentIncidents'
6+
import { CameraGrid } from '@/components/camera/CameraGrid'
7+
import { AlertTriangle, Camera, Activity, ShieldAlert } from 'lucide-react'
8+
9+
export const revalidate = 30
10+
11+
export default async function DashboardPage() {
12+
const [cameras, stats, recentIncidents] = await Promise.all([
13+
api.cameras.list().catch(() => []),
14+
api.incidents.stats().catch(() => ({ total: 0, by_type: {}, by_severity: {}, by_camera: {} })),
15+
api.incidents.list({ limit: '10', resolved: 'open' }).catch(() => []),
16+
])
17+
18+
const critical = stats.by_severity['critical'] ?? 0
19+
const activeCams = cameras.filter((c) => c.is_active).length
20+
21+
return (
22+
<div className="space-y-6">
23+
<h1 className="text-xl font-semibold text-white">Dashboard</h1>
24+
25+
{/* Stat cards */}
26+
<div className="grid grid-cols-4 gap-4">
27+
<StatCard label="Total Incidents" value={stats.total} icon={AlertTriangle} color="blue" />
28+
<StatCard label="Critical" value={critical} icon={ShieldAlert} color="red" />
29+
<StatCard label="Active Cameras" value={activeCams} icon={Camera} color="green" />
30+
<StatCard label="Open Cases" value={recentIncidents.length} icon={Activity} color="yellow" />
31+
</div>
32+
33+
{/* Charts row */}
34+
<div className="grid grid-cols-2 gap-4">
35+
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
36+
<p className="text-sm font-medium text-gray-400 mb-3">Incidents by Severity</p>
37+
<SeverityChart data={stats.by_severity} />
38+
</div>
39+
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
40+
<p className="text-sm font-medium text-gray-400 mb-3">Incidents by Type</p>
41+
<IncidentTypeChart data={stats.by_type} />
42+
</div>
43+
</div>
44+
45+
{/* Camera grid + recent incidents */}
46+
<div className="grid grid-cols-3 gap-4">
47+
<div className="col-span-2">
48+
<CameraGrid cameras={cameras.slice(0, 6)} />
49+
</div>
50+
<div className="bg-gray-900 rounded-xl p-4 border border-gray-800">
51+
<p className="text-sm font-medium text-gray-400 mb-3">Open Incidents</p>
52+
<RecentIncidents incidents={recentIncidents} />
53+
</div>
54+
</div>
55+
</div>
56+
)
57+
}

src/frontend/app/globals.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
@tailwind base;
2+
@tailwind components;
3+
@tailwind utilities;
4+
5+
@layer base {
6+
body {
7+
@apply bg-gray-950 text-gray-100 antialiased;
8+
}
9+
}
10+
11+
@layer utilities {
12+
.scrollbar-thin {
13+
scrollbar-width: thin;
14+
scrollbar-color: #374151 transparent;
15+
}
16+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { api } from '@/lib/api'
2+
import { IncidentTable } from '@/components/incident/IncidentTable'
3+
4+
export const revalidate = 0
5+
6+
export default async function IncidentsPage({
7+
searchParams,
8+
}: {
9+
searchParams: Record<string, string>
10+
}) {
11+
const incidents = await api.incidents
12+
.list({
13+
incident_type: searchParams.type,
14+
severity: searchParams.severity,
15+
resolved: searchParams.resolved ?? 'open',
16+
limit: '100',
17+
})
18+
.catch(() => [])
19+
20+
return (
21+
<div className="space-y-4">
22+
<h1 className="text-xl font-semibold text-white">Incidents</h1>
23+
<IncidentTable incidents={incidents} />
24+
</div>
25+
)
26+
}

src/frontend/app/layout.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Metadata } from 'next'
2+
import './globals.css'
3+
import { Sidebar } from '@/components/layout/Sidebar'
4+
import { AlertBar } from '@/components/layout/AlertBar'
5+
6+
export const metadata: Metadata = {
7+
title: 'NamuCam — City Surveillance',
8+
description: 'AI-powered city CCTV surveillance dashboard',
9+
}
10+
11+
export default function RootLayout({ children }: { children: React.ReactNode }) {
12+
return (
13+
<html lang="en">
14+
<body className="flex h-screen overflow-hidden">
15+
<Sidebar />
16+
<div className="flex flex-col flex-1 overflow-hidden">
17+
<AlertBar />
18+
<main className="flex-1 overflow-auto p-6">{children}</main>
19+
</div>
20+
</body>
21+
</html>
22+
)
23+
}

src/frontend/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import { redirect } from 'next/navigation'
2+
export default function Root() { redirect('/dashboard') }

0 commit comments

Comments
 (0)