Dashboard
Admin dashboard
Add a small React dashboard with shadcn/ui for events, tickets, and the knowledge base, serve it from FastAPI, and open it through an SSH tunnel.
By the end of this page you open http://localhost:8080 on your laptop, log in, and see every event and ticket on the server. You can edit the knowledge base there, and the agent uses the new text on the next email.
The dashboard is a set of static files that FastAPI serves, so the server needs no extra web server and no open port.
Add the API routes
Replace app/main.py. The highlighted lines are new.
import secrets
from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app import db
from app.config import settings
from app.poller import poll_once
app = FastAPI(title="AI customer support")
security = HTTPBasic(auto_error=False)
def require_login(credentials: HTTPBasicCredentials | None = Depends(security)) -> None:
valid = (
credentials is not None
and settings.dashboard_password != ""
and secrets.compare_digest(credentials.username.encode(), settings.dashboard_username.encode())
and secrets.compare_digest(credentials.password.encode(), settings.dashboard_password.encode())
)
if not valid:
raise HTTPException(status_code=401, detail="Wrong username or password")
class EntryUpdate(BaseModel):
title: str
content: str
api = APIRouter(prefix="/api", dependencies=[Depends(require_login)])
@api.get("/login")
def login() -> dict:
return {"ok": True}
@api.get("/events")
def events() -> list[dict]:
return db.list_events()
@api.get("/tickets")
def tickets() -> list[dict]:
return db.list_tickets()
@api.get("/tickets/{ticket_id}/messages")
def ticket_messages(ticket_id: int) -> list[dict]:
return db.list_messages(ticket_id)
@api.get("/knowledge-base")
def knowledge_base() -> list[dict]:
return db.list_knowledge_base()
@api.put("/knowledge-base/{entry_id}")
def update_entry(entry_id: int, update: EntryUpdate) -> dict:
entry = db.update_knowledge_base_entry(entry_id, update.title, update.content)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
return entry
@api.post("/poll")
def poll() -> dict:
return {"messages": poll_once()}
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
app.include_router(api)
# The built dashboard. Mounted last, so the API routes above take precedence.
dashboard = Path(__file__).resolve().parent.parent / "dashboard" / "dist"
if dashboard.exists():
app.mount("/", StaticFiles(directory=dashboard, html=True), name="dashboard")HTTPBasic(auto_error=False) checks the username and password on every request without the browser's own login pop-up. POST /api/poll runs one poll right away, so you do not have to wait for the next interval. The duplicate check in db.start_event keeps it from clashing with the poller.
Add the read and update helpers to the end of app/db.py:
def list_events(limit: int = 100) -> list[dict]:
with connect() as conn:
return conn.execute("select * from events order by id desc limit %s", (limit,)).fetchall()
def list_tickets(limit: int = 100) -> list[dict]:
with connect() as conn:
return conn.execute(
"select * from tickets order by updated_at desc limit %s", (limit,)
).fetchall()
def list_messages(ticket_id: int) -> list[dict]:
with connect() as conn:
return conn.execute(
"select * from messages where ticket_id = %s order by id", (ticket_id,)
).fetchall()
def list_knowledge_base() -> list[dict]:
with connect() as conn:
return conn.execute("select * from knowledge_base order by section, id").fetchall()
def update_knowledge_base_entry(entry_id: int, title: str, content: str) -> dict | None:
with connect() as conn:
return conn.execute(
"""
update knowledge_base set title = %s, content = %s, updated_at = now()
where id = %s returning *
""",
(title, content, entry_id),
).fetchone()Set DASHBOARD_PASSWORD in .env on your laptop to a long random password.
Create the React app
From the project root:
npx shadcn@latest init -t vite -n dashboard -b radix -p nova --no-monorepo
rm -rf dashboard/.gitThis creates a Vite, React, and TypeScript app in dashboard/ with Tailwind and shadcn/ui, using the neutral (black and white) color palette. The CLI also starts a Git repository inside dashboard/. Remove it, so the dashboard is part of your main repo.
Add the components the pages use:
cd dashboard
npx shadcn@latest add button card input label table tabs textarea badgeProxy the API in development
Replace dashboard/vite.config.ts. The highlighted server block sends /api calls to FastAPI on port 8000 while you develop.
import { resolve } from "node:path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve(import.meta.dirname, "./src"),
},
},
server: {
proxy: {
"/api": "http://localhost:8000",
},
},
})Add the API client and types
const KEY = "dashboard-login";
export function saveLogin(username: string, password: string) {
sessionStorage.setItem(KEY, btoa(`${username}:${password}`));
}
export function logout() {
sessionStorage.removeItem(KEY);
}
export function isLoggedIn() {
return sessionStorage.getItem(KEY) !== null;
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`/api${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${sessionStorage.getItem(KEY) ?? ""}`,
...init.headers,
},
});
if (response.status === 401) {
logout();
throw new Error("Wrong username or password");
}
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}The login lives in sessionStorage, so it is gone when you close the tab.
export type SupportEvent = {
id: number
gmail_message_id: string
status: string
attempts: number
error: string | null
created_at: string
}
export type Ticket = {
id: number
subject: string
customer_email: string
category: string | null
status: string
proposed_action: string | null
updated_at: string
}
export type Message = {
id: number
direction: "in" | "out"
from_address: string
body: string
created_at: string
}
export type Entry = {
id: number
section: string
title: string
content: string
}
export function formatDate(value: string) {
return new Date(value).toLocaleString()
}import { Badge } from "@/components/ui/badge"
// Black for what needs attention, outlined for the rest.
export function StatusBadge({ status }: { status: string }) {
const urgent = status === "escalated" || status === "failed"
return <Badge variant={urgent ? "default" : "outline"}>{status}</Badge>
}Add the login page
Create the folder dashboard/src/pages.
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { api, logout, saveLogin } from "@/lib/api"
export function LoginPage({ onLogin }: { onLogin: () => void }) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
saveLogin(username, password)
try {
await api("/login")
onLogin()
} catch {
logout()
setError("Wrong username or password")
}
}
return (
<div className="flex min-h-svh items-center justify-center p-6">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Harbor & Pine support</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm">{error}</p>}
<Button type="submit">Log in</Button>
</form>
</CardContent>
</Card>
</div>
)
}The form saves the credentials, then calls GET /api/login. A 401 means the username or password is wrong.
Add the events page
import { useEffect, useState } from "react"
import { StatusBadge } from "@/components/status-badge"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { api } from "@/lib/api"
import { formatDate, type SupportEvent } from "@/lib/types"
export function EventsPage() {
const [events, setEvents] = useState<SupportEvent[]>([])
const [checking, setChecking] = useState(false)
const load = () => api<SupportEvent[]>("/events").then(setEvents)
useEffect(() => {
load()
}, [])
async function checkInbox() {
setChecking(true)
try {
await api("/poll", { method: "POST" })
await load()
} finally {
setChecking(false)
}
}
return (
<div className="flex flex-col gap-4">
<div>
<Button onClick={checkInbox} disabled={checking}>
{checking ? "Checking..." : "Check inbox now"}
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Attempts</TableHead>
<TableHead>Error</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => (
<TableRow key={event.id}>
<TableCell>{event.id}</TableCell>
<TableCell>
<StatusBadge status={event.status} />
</TableCell>
<TableCell>{event.attempts}</TableCell>
<TableCell className="max-w-xs truncate">{event.error}</TableCell>
<TableCell>{formatDate(event.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Add the tickets page
import { useEffect, useState } from "react"
import { StatusBadge } from "@/components/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { api } from "@/lib/api"
import { formatDate, type Message, type Ticket } from "@/lib/types"
export function TicketsPage() {
const [tickets, setTickets] = useState<Ticket[]>([])
const [selected, setSelected] = useState<Ticket | null>(null)
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
api<Ticket[]>("/tickets").then(setTickets)
}, [])
async function open(ticket: Ticket) {
setSelected(ticket)
setMessages(await api<Message[]>(`/tickets/${ticket.id}/messages`))
}
return (
<div className="grid gap-6 lg:grid-cols-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>Subject</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow
key={ticket.id}
onClick={() => open(ticket)}
data-state={ticket.id === selected?.id ? "selected" : undefined}
className="cursor-pointer"
>
<TableCell className="max-w-48 truncate">{ticket.subject}</TableCell>
<TableCell>{ticket.customer_email}</TableCell>
<TableCell>{ticket.category}</TableCell>
<TableCell>
<StatusBadge status={ticket.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{selected ? (
<div className="flex flex-col gap-3">
<h2 className="font-medium">{selected.subject}</h2>
{selected.proposed_action && (
<p className="text-sm">Proposed action: {selected.proposed_action}</p>
)}
{messages.map((message) => (
<div
key={message.id}
className={
message.direction === "out"
? "ml-10 rounded-lg bg-primary p-3 text-primary-foreground"
: "mr-10 rounded-lg border p-3"
}
>
<p className="mb-1 text-xs opacity-70">
{message.from_address}, {formatDate(message.created_at)}
</p>
<p className="text-sm whitespace-pre-wrap">{message.body}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Select a ticket to read the conversation.</p>
)}
</div>
)
}Click a ticket to load its messages. Outgoing replies show in black on the right, customer emails outlined on the left.
Add the knowledge base page
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { api } from "@/lib/api"
import type { Entry } from "@/lib/types"
export function KnowledgeBasePage() {
const [entries, setEntries] = useState<Entry[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [saved, setSaved] = useState(false)
useEffect(() => {
api<Entry[]>("/knowledge-base").then(setEntries)
}, [])
const sections = [...new Set(entries.map((entry) => entry.section))]
function select(entry: Entry) {
setSelectedId(entry.id)
setTitle(entry.title)
setContent(entry.content)
setSaved(false)
}
async function save() {
const updated = await api<Entry>(`/knowledge-base/${selectedId}`, {
method: "PUT",
body: JSON.stringify({ title, content }),
})
setEntries(entries.map((entry) => (entry.id === updated.id ? updated : entry)))
setSaved(true)
}
return (
<div className="grid gap-6 lg:grid-cols-[260px_1fr]">
<nav className="flex flex-col gap-4">
{sections.map((section) => (
<div key={section}>
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase">{section}</p>
{entries
.filter((entry) => entry.section === section)
.map((entry) => (
<button
key={entry.id}
onClick={() => select(entry)}
className={`block w-full rounded px-2 py-1 text-left text-sm hover:bg-muted ${
entry.id === selectedId ? "bg-muted font-medium" : ""
}`}
>
{entry.title}
</button>
))}
</div>
))}
</nav>
{selectedId === null ? (
<p className="text-sm text-muted-foreground">Select an entry to edit it.</p>
) : (
<div className="flex flex-col gap-3">
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-96 font-mono text-sm"
/>
<div className="flex items-center gap-3">
<Button onClick={save}>Save</Button>
{saved && <span className="text-sm text-muted-foreground">Saved</span>}
</div>
</div>
)}
</div>
)
}Put it together
Replace dashboard/src/App.tsx. Leave main.tsx as the CLI created it.
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { isLoggedIn, logout } from "@/lib/api"
import { EventsPage } from "@/pages/events"
import { KnowledgeBasePage } from "@/pages/knowledge-base"
import { LoginPage } from "@/pages/login"
import { TicketsPage } from "@/pages/tickets"
export default function App() {
const [loggedIn, setLoggedIn] = useState(isLoggedIn())
if (!loggedIn) {
return <LoginPage onLogin={() => setLoggedIn(true)} />
}
function logOut() {
logout()
setLoggedIn(false)
}
return (
<div className="mx-auto max-w-6xl p-6">
<header className="mb-6 flex items-center justify-between">
<h1 className="text-lg font-semibold">Harbor & Pine support</h1>
<Button variant="outline" onClick={logOut}>
Log out
</Button>
</header>
<Tabs defaultValue="events">
<TabsList>
<TabsTrigger value="events">Events</TabsTrigger>
<TabsTrigger value="tickets">Tickets</TabsTrigger>
<TabsTrigger value="knowledge-base">Knowledge base</TabsTrigger>
</TabsList>
<TabsContent value="events" className="mt-4">
<EventsPage />
</TabsContent>
<TabsContent value="tickets" className="mt-4">
<TicketsPage />
</TabsContent>
<TabsContent value="knowledge-base" className="mt-4">
<KnowledgeBasePage />
</TabsContent>
</Tabs>
</div>
)
}There is no router. The app shows the login form until you are logged in, then three tabs. That keeps FastAPI's static file serving simple, because every page is index.html.
Check that it compiles:
npm run buildRun it locally
Use three terminals, one each for the API, the poller, and the dashboard.
uv run fastapi dev app/main.py
uv run python -m app.poller
cd dashboard && npm run devOpen the address Vite prints, usually http://localhost:5173, and log in with DASHBOARD_USERNAME and DASHBOARD_PASSWORD. Stop the poller on the server first (docker compose stop poller) if you test with real mail, or two pollers answer the same inbox.
Build it into the image
Replace the Dockerfile. The new first stage builds the dashboard with Node, and the last highlighted line copies only the built files, so the server image has no Node.
FROM node:22-slim AS dashboard
WORKDIR /dashboard
COPY dashboard/package.json dashboard/package-lock.json ./
RUN npm ci
COPY dashboard/ ./
RUN npm run build
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONUNBUFFERED=1 UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY app ./app
COPY --from=dashboard /dashboard/dist ./dashboard/dist
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]Deploy it
Commit and push from your laptop, then add DASHBOARD_PASSWORD to the server's .env and rebuild:
ssh support-vps
cd ~/ai-customer-support
nano .env
git pull
docker compose up -d --buildOpen it through an SSH tunnel
On your laptop:
ssh -L 8080:localhost:8080 support-vpsKeep that terminal open and go to http://localhost:8080. The traffic runs inside the SSH connection, so it is encrypted without HTTPS, and only someone with your SSH key can reach the page. The login is a second lock.
Check
- The dashboard loads at
http://localhost:8080while the tunnel is open, and not at all without it. - Events lists the emails from the deploy test. Check inbox now picks up a new email right away.
- Tickets shows each conversation, with
proposed_actionfilled in for escalated replacement cases. - Change an entry in Knowledge base, send a matching email, and the reply uses the new text.
With your own domain you would put a reverse proxy with HTTPS in front of the API instead of a tunnel. That is a different setup, and this project does not need it.