A small self-hosted app for logging incidents and how they were solved, with semantic (AI) search over the whole history.
- Backend: Rust (actix-web) + Postgres for storage, Weaviate for vector search, a local sentence-embedding model for indexing, and any OpenAI-compatible server for the question answering.
- Frontend: React 19 + Chakra UI v3 (Vite + TypeScript).
The backend is deliberately generic: it stores records (id, title, body,
plus a vector embedding). The frontend gives those records meaning by treating the
body as markdown and keeping the incident metadata in its first line:
**Severity:** high · **Status:** resolved
## Summary
...Because the metadata lives inside the markdown, it stays searchable by the semantic index and renders nicely, while the UI can still parse it into badges and filters.
| Concept | Values |
|---|---|
| Severity | critical, high, medium, low |
| Status | open, investigating, mitigated, resolved |
The app is single-port: one actix-web process serves both the JSON API (under
/api) and the compiled SPA (everything else). No reverse proxy or extra port is
needed in front of it — Caddy/Nginx are optional TLS terminators only.
| Port | What | Notes |
|---|---|---|
| 1337 | API + frontend (the only public port) | PORT env var, default 1337 |
| 5432 | Postgres (internal) | not published — reachable only on the docker network |
| 8081 | Weaviate (internal) | not published — reachable only on the docker network |
| 5173 | Vite dev server (development only) | proxies /api to http://localhost:1337 |
Routing inside the app:
/api/records*,/api/ai→ API handlers/assets/*,/vite.svg, … → static files fromFRONTEND_LOCATION- anything else →
index.html, so SPA deep links (e.g./<incident-id>) survive a refresh
https://notes.nilles.dev is forwarded to port 1337 on this machine.
| Method | Path | Description |
|---|---|---|
GET |
/api/records |
List records (body truncated) |
GET |
/api/records/{id} |
Fetch one record |
POST |
/api/records |
Create a record |
PUT |
/api/records/{id} |
Update a record |
DELETE |
/api/records/{id} |
Delete a record (and its vector) |
POST |
/api/ai |
{ "question": "..." } → { "answer": "..." } |
WS |
/api/ai/ws |
Same, streamed frame by frame over a WebSocket |
/api/ai/ws is a WebSocket. Upgrade, then send one JSON message per question:
{"question":"Have we seen a 500 on checkout before?"}Frames arrive as the model produces them:
{"type":"reasoning","delta":"The user asks about ..."}
{"type":"answer","delta":"Yes — "}
{"type":"done"}reasoning carries the model's chain of thought (controlled by
OPENAI_REASONING_EFFORT) and renders in a collapsed panel in the UI; answer
is the reply. Errors arrive as {"type":"error","message":"..."}. The socket
stays open for further questions until the client closes it, and a question that
arrives mid-answer is processed after the current one.
A WebSocket rather than a streamed POST/EventSource because HTTP responses get
buffered by clients and middleboxes — which is exactly what happened with the
earlier application/x-ndjson design — whereas every WebSocket frame lands as it
is sent. The frontend uses the native WebSocket (see streamAboutIncidents in
frontend/src/api/incidents.ts); the Vite dev server proxies it via ws: true.
| Variable | Required | Default | Purpose |
|---|---|---|---|
OPENAI_API_KEY |
yes | – | API key for your OpenAI-compatible server |
OPENAI_API_BASE |
no | https://api.openai.com/v1 |
Base URL of your OpenAI-compatible server |
OPENAI_MODEL |
no | gpt-4o-mini |
Model name served by that server |
OPENAI_REASONING_EFFORT |
no | medium |
How hard the model thinks: none/minimal/low/medium/high/xhigh |
DATABASE_URL |
yes | postgres://incidents:incidents@localhost:5432/incidents |
Postgres connection string |
WEAVIATE_HOSTNAME |
yes | – | Weaviate host |
WEAVIATE_PORT |
yes | – | Weaviate port |
FRONTEND_LOCATION |
no | static |
Where the built frontend assets live |
RUSTBERT_CACHE |
no | $XDG_CACHE_HOME/.rustbert |
Where the embedding model weights are cached |
The bundled .env.example is pre-filled for the local halogen instance
(https://ssh.nilles.dev/halogen/v1, model halogen-qwen3.8-flash-next); copy it
to .env and set OPENAI_API_KEY.
The Postgres schema (a single records table) is created automatically at boot with
CREATE TABLE IF NOT EXISTS, so there is no manual migration step.
cp .env.example .env # fill in your keys
docker compose up --buildThe app is served on http://localhost:1337 (and publicly on
https://notes.nilles.dev).
cd frontend
npm install
npm run dev # vite dev server, proxies /api to localhost:1337
npm run build # type-check + production build
npm run lint # eslint (flat config)- Rust toolchain and all crates were updated to their latest releases.
consoleis pinned inCargo.lockto0.15.xbecauseindicatif@0.16(arust-bertdependency) declares an overly broad range that resolves to a brokenconsolerelease. chatgpt_rswas replaced withopenai-api-rsso the assistant can point at any OpenAI-compatible endpoint.chakra-ui-markdown-rendererwas dropped: it is unmaintained and incompatible with Chakra v3. Markdown rendering now uses a small component map (frontend/src/components/markdown.tsx).- TypeScript is held at
6.x:@typescript-eslintdoes not support TypeScript 7 yet.