Datalumina

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.

app/main.py
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:

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/.git

This 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 badge

Proxy the API in development

Replace dashboard/vite.config.ts. The highlighted server block sends /api calls to FastAPI on port 8000 while you develop.

dashboard/vite.config.ts
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

dashboard/src/lib/api.ts
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.

dashboard/src/lib/types.ts
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()
}
dashboard/src/components/status-badge.tsx
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.

dashboard/src/pages/login.tsx
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 &amp; 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

dashboard/src/pages/events.tsx
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

dashboard/src/pages/tickets.tsx
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

dashboard/src/pages/knowledge-base.tsx
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.

dashboard/src/App.tsx
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 &amp; 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 build

Run 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 dev

Open 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.

Dockerfile
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 --build

Open it through an SSH tunnel

On your laptop:

ssh -L 8080:localhost:8080 support-vps

Keep 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:8080 while 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_action filled 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.

On this page