Skip to content
Slaven1996Public

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Hydro Telemetry

A dashboard app that combines classic CRUD screens with Generative UI: an AI assistant whose answers can include the app's own React components (live, paged tables) streamed into the page.

Layer Stack
Frontend (/frontend) React 19, Vite 8 (client-side rendering), TanStack Router (file-based) + Query + Table, shadcn/ui (Radix, Tailwind v4), Recharts, @ai-sdk/react + react-markdown, next-themes, axios, Socket.IO client
Backend (/backend) NestJS 12 (ESM), Prisma 7 + PostgreSQL 17, Passport JWT + argon2, Vercel AI SDK 7 (@ai-sdk/anthropic), WebSockets (@nestjs/websockets + Socket.IO), @nestjs/schedule, OpenAPI via @nestjs/swagger
Shared (/packages/shared) Zod schemas and types used by both sides: DTOs and the Generative UI tool contracts

Getting started

Two ways to run it. Both serve the web app on http://localhost:5173, so run one at a time.

Everything in Docker

The fastest way to see the app, and the one to use when handing the project to someone else. Only Docker is required — no Node, no pnpm, no configuration.

docker compose up --build         # or: pnpm docker:up

That brings up Postgres (which creates the schema from backend/prisma/init.sql), the API (which seeds the demo data) and the web app. When it settles, open http://localhost:5173 and sign in with any account from Demo accounts. The API documents itself at http://localhost:5173/docs.

pnpm docker:down                  # stop
pnpm docker:clean                 # stop and drop the database volume

Every setting in docker-compose.yml has a development default, including JWT_ACCESS_SECRET. That is what makes a fresh clone run unconfigured — generate a real secret before deploying anywhere (see Production notes). Override any value by exporting it, by putting it in a .env file in the repository root (compose reads that one by itself, so it is the easy place for ANTHROPIC_API_KEY), or by pointing compose at your own file:

docker compose --env-file backend/.env up

Only the variables listed under the backend service's environment: reach the API; the others keep their defaults. Every API setting, with its default, is in backend/.env.example.

The API port is deliberately not published: the browser reaches it same-origin at /api through nginx, which leaves port 3000 free for pnpm dev. Uncomment the ports: block under backend to expose it.

Frontend work in Docker (hot reload)

docker:up serves a production build, so every frontend edit means another tsc -b + vite build and a new image. For frontend work, use the dev mode instead:

pnpm docker:dev                   # same stack, but the web app is the Vite dev server

docker-compose.dev.yml layers over docker-compose.yml: the frontend container runs Vite (the dev target in frontend/Dockerfile) behind the same http://localhost:5173, and docker compose watch copies every edit under frontend/ and packages/shared/ into the running container, where Vite hot-reloads it. Nothing is rebuilt. Only a change to a package.json or pnpm-lock.yaml rebuilds the frontend image, and watch does that by itself.

  • Vite's output: docker compose logs -f frontend.
  • Ctrl+C stops watching, but the stack keeps running. Stop it with pnpm docker:down, or switch back to the production build with pnpm docker:up.
  • The API still runs from its image. After a backend change, run pnpm docker:dev again (the dependency layers are cached, so only the backend is recompiled).
  • frontend/src/routeTree.gen.ts is regenerated inside the container only. After adding a route file, run pnpm --filter frontend exec vite build locally once, so your copy (which is committed) and your editor know the new route. (pnpm --filter frontend build runs tsc -b first, which fails on the route until the tree knows it.)

Local development (hot reload)

Prerequisites: Node 24 (.nvmrc), pnpm 10 (corepack enable pnpm), Docker for Postgres.

cp backend/.env.example backend/.env    # before installing: `prisma generate` needs DATABASE_URL
cp frontend/.env.example frontend/.env
# In backend/.env set JWT_ACCESS_SECRET (openssl rand -base64 48)
# and ANTHROPIC_API_KEY (optional until you use the assistant)
pnpm install                      # installs all workspaces and generates the Prisma client

pnpm db:up                        # Postgres in Docker on localhost:5434; a new volume gets the schema
pnpm db:seed                      # load backend/prisma/seed/*.sql
pnpm dev                          # API on :3000, web app on http://localhost:5173

Postgres runs on host port 5434 so it doesn't clash with a locally installed Postgres on 5432 or 5433.

Without an AI key the app works normally, but POST /api/ai/chat answers 503 and the assistant shows how to fix it.

Demo accounts

Created by the seed. Every one of them signs in with Password123!.

Email Name Role
admin@example.com Admin ADMIN
ava.mitchell@example.com Ava Mitchell ADMIN
noah.bennett@example.com Noah Bennett ADMIN
liam.carter@example.com Liam Carter USER
sofia.reyes@example.com Sofia Reyes USER

These are local demo credentials. Change them before the app is reachable by anyone else.

The monitoring domain

The app monitors a small water utility (the seed data): a tree of assets (regions → sites → areas → equipment), the devices installed on them, and the tags (datapoints) those devices report. Readings land in measurements; alarm rules watch tags and raise alarms; people keep a logbook, work shifts, and build their own dashboards.

Page API What it is for
/dashboard /api/dashboards, /api/analytics/* Customizable grid of widgets; own dashboards plus shared ones (see Dashboards). The assistant can also put one together from a description. The KPI (open, unacknowledged, mean time to acknowledge, …), alarm trend (per hour, day or week by severity), most frequent alarms (by rule or asset) and device health widgets read /api/analytics/*
/alarms /api/alarms The alarm console: filter, acknowledge (one or many), clear by hand (admins), open an alarm's timeline
/alarm-rules /api/alarm-definitions Threshold rules on tags (admins edit); a click on a rule opens its details
/logbook /api/logbook Operator notes, maintenance, shift handovers — and system events
/shifts /api/shifts/* Shift calendar (month, week, day), staffing gaps, shift templates, who is on duty now
/assets, /assets/:id /api/assets The asset tree; an asset page is built from embedded, pre-filtered views
/devices, /tags /api/devices, /api/tags RTUs/PLCs/sensors and their datapoints with live values; a click on a row opens every detail of that device or tag
/graphs /api/telemetry/series, /stats Trend explorer: up to 8 tags, server-side buckets, one chart per unit
/audit /api/audit-logs Who changed what (admins)
/users /api/users People and their roles; admins add, promote and deactivate them (see Users)
/assistant /api/ai/* The AI assistant (see Generative UI)
/profile /api/profile Your own name and password (see Your own account)
/settings /api/ai/instructions The assistant's standing instructions, the head of its system prompt (admins; see Generative UI)

Admins configure (assets, devices, tags, rules, shift plans), manage users, clear alarms by hand and post readings to the ingest endpoint; everyone can acknowledge alarms, write in the logbook and build dashboards. The header's theme switch picks light, dark or the system's setting, remembered in the browser. The entities, their attributes, operations and associations are drawn as a UML class diagram in docs/class-diagram.drawio — open it in diagrams.net or the VS Code Draw.io Integration extension. The tables themselves are in backend/prisma/init.sql.

Where the code lives

Each module is a folder on both sides. backend/src/features/<module> holds its Nest module, controllers and services; frontend/src/features/<module> holds its data access (api/queries.ts, api/mutations.ts), its React components (components/, one component per file), hooks, helpers (lib/), external stores (store/, e.g. the chat and the notifications) and local types (types.ts), and the page that assembles them lives in frontend/src/routes/_authenticated. Pieces several features share (RelativeTime, AssetLink, the badges, Segmented) sit in frontend/src/components. What every module shares stays at the root of backend/src: common (list-query helpers, decorators, the exception filter), config, docs and prisma.

@gen-ui/shared runs from its compiled dist (so pnpm dev builds it first and then watches it). Editors don't need that build: frontend/tsconfig.app.json and backend/tsconfig.json list the package as a TypeScript project reference, so the editor reads its source, and a new export is visible in both apps the moment it is saved. tsc -b (the frontend build) builds the package itself when it is out of date.

One query, three uses

Every list endpoint takes the same kind of query — page/pageSize, sort=field or sort=-field, comma-separated multi-value filters, a time window as range=24h (relative, so links stay live) or from/to, search, and assetId meaning "this asset and everything below it". Each is one Zod schema in @gen-ui/shared, used by:

  1. the API, to validate @Query();
  2. the page, as its route's validateSearch — so /alarms?state=ACTIVE,CLEARED&severity=CRITICAL&range=7d and GET /api/alarms?state=ACTIVE,CLEARED&severity=CRITICAL&range=7d are the same query;
  3. the assistant's tools: each tool's input is a subset of its page's parameters (plus a title), parsed through the same schema (see Generative UI).

Tables and charts (AlarmsTable, TelemetryChart, LogbookTable, TagsTable, …) take that query as a prop and fetch their own data. A page feeds them from its URL; an embed (a dashboard widget, an asset page card, a chat answer) passes a fixed query and adds View full page — a plain link to the page with the same query. A dashboard widget's config is such a query (WIDGET_CONFIG_SCHEMAS), and features/dashboards/lib/widget-registry.tsx maps each widget type to its view and its full page. The assistant has its own registry (features/assistant/lib/tool-renderers.ts) that maps each tool to the same views.

Details on a click. A click on a row of the alarms, alarm rules, devices or tags table opens that record in a side sheet with every field (what the table truncates or leaves out), links to what belongs to it ("12 tags →", "3 open →") and the buttons the user's role allows (acknowledge and chart for everyone; clear, edit, add a rule or switch a rule off for admins, through the same dialogs as the row menu; deleting stays in the row menu). The table brings the sheet along, so it works wherever the table is: on its page, on an asset page, in a dashboard widget and in a chat answer, for every role. A page keeps the open record in its URL (/alarms?alarm=…, /alarm-rules?rule=…, /devices?device=…, /tags?tag=…), so a link opens it and Back closes it; an embed keeps it to itself (useDetailSelection, the counterpart of useViewQuery). The sheet's content is a detail component of its feature (AlarmDetail, DefinitionDetail, DeviceDetail, TagDetail) built from the pieces in frontend/src/components/detail/; the assistant shows the same component as a card (DetailCard).

Alarms, end to end

A reading arrives (POST /api/telemetry/measurements, admins only, or the simulator) → the tag's latest value moves → every enabled rule on the tag is evaluated, with the rule rows locked so concurrent readings can't double-raise → a firing value raises an ACTIVE alarm (a rule has at most one open alarm); a value back past the threshold by the deadband clears it (CLEARED, or CLOSED if already acknowledged). The deadband applies to ABOVE/BELOW; EQUAL/NOT_EQUAL clear as soon as they stop firing. Only a reading newer than the tag's latest value and not of BAD quality is evaluated: late or back-filled readings are stored but never raise or clear. Acknowledging moves ACTIVE → ACKNOWLEDGED / CLEARED → CLOSED. An admin can also clear by hand (POST /api/alarms/:id/clear) when the condition can't clear itself, e.g. a failed sensor: ACTIVE → CLEARED, ACKNOWLEDGED → CLOSED. Every transition is written to the logbook (the alarm's timeline is GET /api/logbook?alarmId=…), and every user action to the audit log, in the same transaction as the change.

The simulator (SIMULATOR_ENABLED, default on) writes a reading for every active tag of an ONLINE device every SIMULATOR_INTERVAL_SECONDS through that same ingest path, with an occasional excursion that trips the seeded rules — so charts move and alarms come and go in a demo. Turn it off when real data flows in. Measurements older than TELEMETRY_RETENTION_DAYS (180) are pruned daily at 04:00 (server time).

Real-time notifications

The bell in the header is each user's notification inbox, stored on the server (the notifications table), so it survives a reload and is the same in every browser they use. New notifications arrive the moment the server stores them, over a WebSocket — nothing polls. Every signed-in tab opens one Socket.IO connection (namespace /notifications, path /api/socket.io, WebSocket transport only), so it travels through the Vite proxy and nginx like the rest of /api, on the web app's own origin.

  • What they are about (type), and who gets them:

    Kind When Who gets it
    ALARM_RAISED a reading raises an alarm (the simulator or POST /api/telemetry/measurements) every active user
    ALARM_ACKNOWLEDGED someone acknowledges alarms; one notification per acknowledgement, however many alarms it covered everyone but that person
    ALARM_CLEARED an admin clears an alarm by hand (an alarm clearing by itself notifies nobody) everyone but that admin
    ALARM_NOTE someone writes a note on an alarm (a logbook entry with alarmId) everyone but its author
    LOGBOOK_ENTRY someone writes a warning or critical logbook entry; Info entries stay in the logbook everyone but its author
    SHIFT_SCHEDULED, SHIFT_CHANGED, SHIFT_REMOVED an admin schedules, copies, moves, hands over or removes shifts; a hand-over tells both people the person whose shifts they are, unless the admin changed their own or the shifts are over

    Each producer stores its notifications inside the same transaction as the change (NotificationsService.createForActiveUsers(items, tx, except) for everyone, createFor for given people; deactivated users never get any) and pushes them only after it commits (NotificationsService.push), so a socket never announces something a rollback undid. The text is written then: an alarm's title is its rule's name; a shift's says which and when, e.g. Mon 12 Oct, 22:00–06:00 as Operator. Scheduled by Marko. The alarm and asset are only links, so deleting either leaves the notification in place. severity (the alarm's), entry_severity (the entry's) and shift_date (to open the calendar there) are filled for their kinds only, which a CHECK constraint enforces. The builders are pure functions next to each producer: alarm-notifications.ts, logbook-notifications.ts, shift-notifications.ts (tested in shift-notifications.spec.ts).

  • Server: backend/src/features/notifications. NotificationsGateway (@WebSocketGateway) checks the access token in the socket handshake exactly as the REST guard does (signature, expiry, active account) and refuses the connection otherwise; an accepted socket joins its user's room, and sendTo(userId, notification) reaches every tab of that user. The inbox is REST: GET /api/notifications (newest first, paged, with the unread count), POST /api/notifications/read ({ ids }, or {} for all), DELETE /api/notifications/:id (dismiss one) and DELETE /api/notifications (clear). Each only ever touches the caller's own rows. The payload is AppNotificationSchema in packages/shared/src/notifications.ts.

  • Browser: frontend/src/features/notifications. The inbox is a TanStack Query entry (api/queries.ts, the newest 50); the socket (store/notifications-store.ts, opened after sign-in, closed on sign-out, a fresh token on every reconnect) adds each arrival to it, and every (re)connect refetches it for whatever came while it was down. The badge is the server's unread count. An arrival also refreshes the open views it is about (alarms, the logbook or the shift calendar), so a change someone else made shows at once. Closing the popover marks the ones it showed read (all of them, if more are unread than it can show). Each shows its kind and, for alarms and logbook entries, its severity. Its title opens what it is about (lib/notification-link.ts): the alarm with its sheet open (/alarms?alarm=…), the latest acknowledged alarms when several were acknowledged at once, the logbook searched for the entry's title, or the shift calendar at that day. Its asset links to the asset page, its X dismisses it, and Clear empties the inbox.

  • Demo alarms: MockAlarmNotifier adds made-up alarms on top of the real ones, so the bell has something to show in a quiet demo. Every 60 s, when Math.random() < 0.4 (40% of the ticks) and someone has the app open, it makes up an alarm on a random active analog tag and stores it in every active user's inbox like a real one, marked Demo alarm (no alarm behind it, so no link). MOCK_ALARM_NOTIFICATIONS=false turns it off.

Users

/users filters by name or email, role, status (active / deactivated) and last sign-in: All, Never signed in, or Exact date with a date picker. They are URL and API parameters like every other list filter: GET /api/users?status=DEACTIVATED&lastSignIn=never, or lastSignIn=2026-05-03 for a day. That day is read in timeZone, which the web app sets to the browser's zone, so the filter matches the date the Last sign-in column shows. Choosing Exact date opens an empty date field; nothing is filtered until a date is picked.

Shifts

/shifts is a calendar of who works when, in three views: Month (per day, one line per shift with the initials of who is on it), Week (rows per shift, the roster, or rows per person, with their hours) and Day (a midnight-to-midnight timeline with a now-line). The page's URL is the calendar's query, GET /api/shifts/calendar?view=week&date=2026-10-05&role=SUPERVISOR (ShiftCalendarQuerySchema); group=person only changes how the week is drawn (ShiftsPageQuerySchema). Above it, On duty now — the people alarm escalation would page — and who takes over next (GET /api/shifts/on-duty). The calendar (ShiftCalendar), the on-duty strip (OnDutyStrip) and the schedule, edit, copy and template forms are one set of components: the page, the assistant's answers and the On duty dashboard widget all render them, with props for what differs (controls={false} drops the calendar's toolbar; only="now" shows one half of the strip).

  • Plant time zone. Every date and time is in SHIFT_TIME_ZONE (default Europe/Budapest): a template's "06:00" is 06:00 there, a day runs from midnight to midnight there, and a shift belongs to the day it starts on there. A shift's length is wall-clock: Night, 22:00 + 8 h, ends at 06:00 even on the nights the clocks change (it then runs 7 or 9 hours), so it still meets the morning shift. Changing the zone later re-files existing shifts by the new zone.
  • Staffing targets. A template's coverage says whom it needs on which weekdays (Morning: an operator every day, a supervisor Monday–Friday). The calendar marks every day where a target isn't met, by role, and admins schedule into the gap from there. Deactivated people keep their shifts but no longer count, so their gaps show.
  • Series. Repeating a shift weekly creates a series. Changing or removing one of its shifts asks whether to apply to it, to it and the following ones, or to the whole series (?scope=one|following|series); wider scopes leave shifts that already started alone. Only one shift at a time can move to another day, time or template, and doing so takes it out of its series. Handing shifts to someone else works at every scope: one shift leaves its series, this and following become a series of their own, and the whole series stays one.
  • Copy a week. POST /api/shifts/assignments/copy-week repeats a week's shifts in the next 1–8 weeks at the same local times. All or nothing, like scheduling.
  • No double booking. Nobody can be on two overlapping shifts. The API checks first and names the clash (409); a Postgres exclusion constraint (shift_assignments_no_overlap) backs it against concurrent requests.

Dashboards

/dashboard shows one dashboard at a time: your own come first in the dropdown, then those others share. A dashboard is a 12-column grid of widgets, each a live view of a page with that page's filters as its config (WIDGET_CONFIG_SCHEMAS).

  • Editing works on a copy. Edit turns the header into the dashboard's name and a Share with everyone switch, and gives every widget move, resize, edit and remove buttons. All of it, adding widgets included, changes only a local copy of the whole dashboard. Save sends it in one request (PUT /api/dashboards/:id: widgets with an id are kept, the others added, the missing ones removed, all in one transaction), and Cancel drops it. Leaving the page, or closing the tab, with unsaved changes asks first.
  • A new dashboard is such a copy from the start. New dashboard opens an empty one in edit mode without calling the server; Save creates it with its widgets (POST /api/dashboards) and opens it, and Cancel goes back to the one you had open.
  • Who may change it. Only its owner edits, renames or shares a dashboard; a shared one is read-only for everyone else. Its owner deletes it, and an admin may also delete a shared one.
  • Widgets that need attention. One check (widgetIssues in @gen-ui/shared, plus whether the tags and asset a widget names still exist) runs on every widget. One that fails shows a red border and the reason ("Pick at least one tag") instead of its view, so no request goes out with a bad query; in edit mode a click opens its settings with the problem marked, and Save stays off ("2 widgets need attention"). The widget dialog itself never lets an incomplete widget in, so this only happens to a dashboard the assistant put together (below) or to a widget whose tag was deleted since.
  • From the assistant. "A dashboard for Riverside with the open critical alarms, a 7-day chart of the reservoir level and who's on duty" gets a preview in the answer, drawn with this page's own grid and widgets, and Open in new tab opens it here as an unsaved dashboard (/dashboard?conversationId=…&toolCallId=…). See Generative UI.

Asset and people filters

Alarms, alarm rules, logbook, devices and tags filter by asset with the asset dropdown (assetId). It covers the asset and everything below it, so picking a site brings all its equipment. The logbook's Author and the audit log's Who filter by people (authorId, actorId). The assistant fills these same ids, so its links open with the dropdowns already set (see Generative UI).

On those lists search never matches asset names: asset names are full of common words ("Pump", "Reservoir", "Tank"), so "pump rules" would otherwise return every rule at a pumping station. The assets list itself is the exception: its search matches an asset's name and code, and it filters by place with ancestorId (a branch of the tree) and parentId (direct children) instead of assetId.

API docs

Swagger UI is at http://localhost:5173/docs, with the raw document at /docs/openapi.json and /docs/openapi.yaml. It is generated from the very Zod schemas the endpoints validate with, so it cannot drift from the code.

The API serves it, but it is published on the web app's origin, so Try it out reaches /api same-origin and the refresh cookie behaves as it does in the app. To call a guarded route: run POST /api/auth/login, copy accessToken out of the response, press Authorize, paste it.

Postman: import postman/gen-ui.postman_collection.json. It covers every endpoint with the demo credentials already filled in. Signing in stores the access token in a collection variable that every other request sends, and a collection-level script signs in again whenever that variable is empty — so Run collection works top to bottom even though Sign out clears the token halfway. Two requests have real consequences and say so: Change my password changes the seeded account's password for good, and Ask the assistant spends provider tokens.

That file is written by hand, for readability. If you would rather have one that is always current, import http://localhost:5173/docs/openapi.json into Postman instead.

Adding an endpoint? The request side documents itself — @Body({ schema }) and @Query({ schema }) are read straight off the route. Write the prose in @ApiOperation, give the response shape with @ApiOkResponse({ standardSchema }), and list the failures with @ApiErrorResponse(status, why) from backend/src/docs/api-error.ts.

Database

The schema is one SQL file, backend/prisma/init.sql: every enum, table, index, foreign key and CHECK constraint. There are no migrations. The Postgres container runs the file once, when its data volume is empty (it is mounted into /docker-entrypoint-initdb.d), so a fresh docker compose up or pnpm db:up creates the whole database. Its healthcheck goes over TCP, which Postgres only opens once the file has run, so the API never starts against a half-built database.

backend/prisma/schema.prisma describes the same tables for the Prisma client. To change the schema, edit both, run pnpm --filter backend db:generate, then rebuild the local database with pnpm db:reset (it drops the database volume and starts Postgres again) and pnpm db:seed. pnpm docker:clean does the same for the whole stack.

Seed data lives in backend/prisma/seed/*.sql as ordinary SQL. backend/prisma/seed.ts is only a runner: it executes every .sql file in filename order, each in its own transaction. To seed a new entity, add a file after the existing ones — 005_orders.sql, 006_invoices.sql — and write INSERTs. Nothing in the runner changes.

Make each file re-runnable (ON CONFLICT ... DO NOTHING, or WHERE NOT EXISTS): pnpm db:seed is expected to be safe to run more than once, and it runs on every container start unless you set SEED_ON_START=false.

id and updated_at have to be set explicitly in SQL (gen_random_uuid(), NOW()) because Prisma generates those values in the client rather than in the database.

The domain seed comes in three files: 002_assets.sql (asset tree, devices, tags, alarm rules, shift templates — fixed, readable UUIDs such as 10000000-0000-4000-8000-000000000003 for the Riverside pumping station), 003_telemetry.sql (14 days of 15-minute readings; a re-run tops them up to the current time) and 004_operations.sql (alarm history consistent with the readings, logbook, a rotating shift plan from a week back to three weeks ahead, and every staffing target met on every day from 2026-09-01 to 2026-11-30, audit trail, two dashboards). Generated rows get deterministic ids from md5, so re-runs skip them. Shifts are planned in the plant time zone, SHIFT_TIME_ZONE (the seed runner passes it to the SQL), each person's week on a shift is a series, and the night shift nine days ahead is left empty so the calendar has a staffing gap to show (unless that day falls in the fully staffed months).

A few rules Prisma cannot express live in init.sql as CHECK and exclusion constraints (an asset is not its own parent, a shift ends after it starts, nobody is on two shifts at once, a shift template's coverage is a JSON array, widget sizes are on the grid, a dashboard's source conversation comes with its tool call).

Scripts (root)

Script What it does
pnpm docker:up / docker:logs Build and start the whole stack in Docker, detached / in the foreground
pnpm docker:dev The stack in Docker with the Vite dev server; frontend and shared edits hot-reload without a rebuild
pnpm docker:down / docker:clean Stop the stack / stop it and drop the database volume
pnpm dev Builds shared, then runs shared (watch), API and web app in parallel
pnpm --filter @gen-ui/shared build Rebuild the shared package's dist, which the running apps import
pnpm build / pnpm typecheck / pnpm lint / pnpm test Across all workspaces
pnpm db:up / db:down Start / stop only the Postgres container (a new volume gets init.sql)
pnpm db:reset Drop the database volume and recreate the schema from init.sql (then pnpm db:seed)
pnpm db:seed / db:studio Load the demo data / Prisma Studio

Authentication

  • Access token: a short-lived JWT (15 min by default) returned in the JSON body and kept only in memory (frontend/src/lib/auth/token-store.ts). It never touches Web Storage.
  • Refresh token: an opaque random value in an httpOnly, SameSite=Strict cookie scoped to /api/auth. Only its SHA-256 hash is stored.
  • Rotation and reuse detection: every refresh replaces the token. Replaying an old one outside a 10 s grace window revokes the whole login's token family (backend/src/features/sessions/refresh-token.service.ts).
  • Silent refresh: when the page loads, the app first tries POST /auth/refresh. The axios interceptor refreshes once and retries on a 401; concurrent requests share one refresh.
  • Multiple tabs: a Web Lock lets only one tab refresh at a time, and a BroadcastChannel shares the new token and logouts with the other tabs.
  • Roles: every route requires a valid token unless it's marked @Public(). @Roles(['ADMIN']) restricts access by role. There is no public sign-up; admins manage users at /users.

Your own account

/profile (also in the user menu) lets any signed-in user change their display name and password, backed by /api/profile (backend/src/features/profile).

Changing a password verifies the current one, then writes the new hash and revokes every refresh token for that account in one transaction. The tab that made the change is issued a fresh token pair, so it stays signed in while every other device is signed out.

Email is not editable: it is the sign-in identifier, and changing it safely needs a verification flow this app doesn't have. Admins can't change it either. Role and active status are never self-editable — the profile schemas are strict, so sending those fields is a 400 rather than a silent no-op.

Generative UI

Ask "all admins, and also users named John" and the answer contains two live users tables: the same UsersTable component as the Users page, with every column and, for admins, the same row actions (make admin or remove the admin role, deactivate, reactivate). Each has Open in new tab, which opens /users with that table's filters. The full flow, with diagrams, is in docs/ai-assistant-flow.md.

The assistant has one tool per domain view, one that draws a chart, two for shifts (the calendar and the on-duty card), four that show one record's details card, one that puts a dashboard together, and sixteen that open a form:

Tool Who may use it What Claude can filter by Renders Opens
listAlarms everyone words from the alarm (message, rule, tag), asset, state, severity, when it was raised AlarmsTable; a click opens the alarm's sheet (acknowledge, clear, chart, timeline) /alarms
listAlarmRules everyone rule or tag name, asset, severity, condition, enabled DefinitionsTable; a click opens the rule's details, admins get edit, delete, enable/disable /alarm-rules
listLogbookEntries everyone words from the entry, kind, severity, source (people or system), author, asset, when it happened LogbookTable; authors edit their entries, authors and admins delete them /logbook
showShiftCalendar everyone the period (week, month or day, around a plant date), week rows by shift or by person, people (deactivated ones too, from <former-people>), shifts (by id, from the prompt's shift template list), roles. For a month Claude reads only the month's own days, not the grid's padding, at most 60 shifts and every gap the Shifts page's ShiftCalendar for that period, without its toolbar (no navigation, view switches or filters); admins keep the shift and gap actions /shifts
showOnDuty everyone when: now or next (one per call), roles the page's on-duty strip (OnDutyStrip), only the half asked for, with the link in its corner /shifts
listDevices everyone name, serial number, model or manufacturer, asset, status, protocol DevicesTable; a click opens the device's details, admins get edit, delete /devices
listTags everyone name, code or unit, asset, type (analog, digital, counter) TagsTable with live values; a click opens the tag's details, chart for everyone, and add a rule, edit, delete for admins /tags
listAssets everyone name or code, level (region, site, area, equipment), a branch of the tree, direct children of one asset, in or out of service AssetsTable; a click opens the asset's page, admins get edit, delete /assets
listUsers everyone name or email, role, status, last sign-in UsersTable; admins get make admin, remove admin role, deactivate, reactivate /users
plotTags everyone up to 8 tags (by id, from the prompt's tag list), a time window, bucket size, aggregate, chart type the Graphs page's filter bar and chart (TelemetryExplorer), without the stats table; changing a filter redraws in place and moves Open in new tab along /graphs
showAlarm everyone one alarm (by id, from an alarm list earlier in the conversation; Claude lists the alarms first if there is none) the alarm's detail sheet as a card (AlarmDetail in DetailCard): acknowledge, clear, chart, timeline, note /alarms?alarm=…, with that sheet open
showAlarmRule everyone one rule (by id, from the prompt's rule list) the rule's details card (DefinitionDetail); admins get edit and enable/disable /alarm-rules?rule=…
showDevice everyone one device (by id, from the prompt's device list) the device's details card (DeviceDetail); admins get edit /devices?device=…
showTag everyone one tag (by id, from the prompt's tag list) the tag's details card (TagDetail) with its live value; chart for everyone, add a rule and edit for admins /tags?tag=…
createDashboard everyone a name, sharing, and up to 12 widgets in reading order, each with its type, title, size and the filters of its page (tags and assets by id, from the prompt's lists); anything left out is filled in on the page a preview with the Dashboard page's own grid and widgets, read-only, under its name and Not created yet or Created; once created, the saved dashboard as it is now /dashboard?conversationId=…&toolCallId=…, the draft unsaved in edit mode; once saved, /dashboard?board=…
listAuditLogs admins action (a family such as alarm_definition. or an exact one such as alarm.acknowledge), who acted, words from the summary, a time window AuditTable, read-only /audit
createUser admins pre-fills name, email, role (never a password) the Add user form, inside the answer /users, once saved
createAlarmRule admins pre-fills the tag (by id, from the prompt's tag list), name, condition, threshold, deadband, severity, description, enabled the New alarm rule form, inside the answer /alarm-rules, once saved
editAlarmRule admins the rule (by id, from the prompt's rule list) and only the values to change the Edit alarm rule form, loaded with the rule's current values /alarm-rules, once saved
createDevice admins pre-fills the asset (by id, from the asset tree), name, serial number, model, manufacturer, protocol, status, firmware, IP address, installed date the Register a device form, inside the answer /devices, once saved
editDevice admins the device (by id, from the prompt's device list) and only the values to change, including the asset it moves to the device's edit form, loaded with its current values /devices, once saved
createTag admins pre-fills the device (by id, from the prompt's device list), name, code, type, unit, log interval, range, description, active the New tag form, inside the answer /tags, once saved
editTag admins the tag (by id, from the prompt's tag list) and only the values to change (never its device) the tag's edit form, loaded with its current values /tags, once saved
createAsset admins pre-fills the parent (by id, from the asset tree), name, code, type, description, address, coordinates, in service the New asset form, inside the answer /assets, once saved
editAsset admins the asset (by id, from the asset tree) and only the values to change, including a new parent (it moves with everything below it) the asset's edit form, loaded with its current values /assets, once saved
createLogbookEntry everyone pre-fills the kind, severity, title and details (in the user's words), the asset (by id, from the asset tree), when it happened the Write in the logbook form, inside the answer /logbook, once saved
editLogbookEntry everyone, own entries only the entry (by id, from the prompt's list of the user's own latest entries) and only the values to change the entry's edit form, loaded with its current values /logbook, once saved
scheduleShift admins pre-fills the person and shift (by id), role, the plant date, own hours, a weekly repeat, notes. A repeat it couldn't save is refused; with a person and a shift, the result lists their shifts it would overlap (clashes) the Schedule a shift form, inside the answer /shifts, once saved
editShift admins the shift, named by person (deactivated ones too) + the plant day it starts (+ which shift, if they work two), the scope for a series, and only the values to change. The result lists the shifts the change would overlap the shift's edit form, loaded with its current values /shifts, once saved
removeShift admins the shift, named as for editShift, and the scope for a series the calendar's Remove shift confirmation, inside the answer /shifts, once removed
copyShiftWeek admins the week (a plant date), how many weeks after it, only some people or shifts. A week with nothing to copy is refused; the result says how many shifts the copy makes and lists the overlaps that would stop it the Copy week form, inside the answer /shifts, once saved
createShiftTemplate admins pre-fills the name, start, length, colour, active, staffing targets. A name another template has, or two rules for one role on a weekday, is refused the New shift template form, inside the answer /shifts, once saved
editShiftTemplate admins the template (by id, from the prompt's shift template list) and only the values to change, checked as for a new one the template's edit form, loaded with its current values /shifts, once saved

Questions to try:

  • Alarms: "open alarms at Riverside", "critical alarms raised today", "which alarms still need acknowledging".
  • Logbook: "maintenance work last week", "what did Ava write in the logbook", "device events at the reservoir".
  • Devices and tags: "devices that are offline or faulted", "what is the wet well level at PS-01 right now", "digital signals at Riverside".
  • Charts: "show the wet well level for the last 7 days", "compare the pump flows at Riverside since yesterday", "daily peaks of outlet pressure this month as bars", "nivo u mokrom bunaru danas".
  • Alarm rules: "rules that are switched off", "critical rules at Riverside".
  • Details: "tell me about the PS-01 RTU", "details of the inlet pressure at Riverside", "what does the wet well high level rule check", "show the latest critical alarm in detail" (lists the alarms, then shows the one). "Edit the inlet pressure tag" opens the edit form instead (admins).
  • Shifts: "who works next week?", "Noah's shifts in October", "where are we short-staffed this month", "who is on duty now?", "who takes over next?" (one card each; asking for both gives two).
  • Audit log (admins): "what did Ava change last week", "who acknowledged alarms yesterday", "deleted devices in the last 30 days".
  • Several at once: "open alarms at Riverside, and the devices there".
  • Dashboards (everyone): "a dashboard for Riverside with the open critical alarms, a 7-day chart of the reservoir level and who's on duty", then "make the chart wider"; "a morning overview with four KPIs and the latest logbook warnings, shared with everyone"; "add a chart" (the preview says it still needs a tag).
  • Forms (admins, components on): "I want to create a new rule" (opens the empty form, as on the page), "create a user named Milan Milanović", "new critical rule when the outlet pressure at PS-01 goes above 9.5 bar", "raise the threshold of wet wel hihg levl to 5", "izmeni pravilo za nizak nivo u mokrom bunaru", "register a new device at Hilcrest", "put the PS-02 RTU into maintenance", "add an outlet flow tag in m³/h to the PS-01 RTU", "log the inlet pressure every 30 seconds", "add Pump 3 under the pump hall at Riverside, code PS-01-P3", "take pump 2 at Riverside out of service", "move Tank 1 under Riverside", "schedule Sofia on mornings every weekday until the end of the month", "give Noah's Saturday night to Liam", "copy this week to the next 3 weeks", "add a 12-hour weekend day shift", "switch off the afternoon shift".
  • Logbook forms (everyone, components on): "log that I replaced the seal on pump 2 at Riverside", "write a shift handover: quiet night, reservoir at 4.1 m", and as Liam Carter "make my last handover a warning".

What is AI-driven, and what isn't. Claude decides only three things: whether to answer, refuse or look something up; which tool to call with which filters (the tool input); and the wording of the answer. Everything else is ordinary code: authorization, validation, the database query, which component draws a tool call, and the data in that component (fetched through the REST API, like its page). The numbered flow at the top of backend/src/features/ai/ai.service.ts marks each step [app] or [AI].

  1. The backend calls streamText with the tools the user's role may use and pipes an AI SDK UI message stream (SSE) back to the client (backend/src/features/ai).
  2. Tools call services, never controllers or repositories. A tool is an inbound adapter like a controller, so the chat gets the same rules and mapping as the REST API. Guards don't run for tool calls, so each tool declares its roles in TOOL_ROLES (the chat's @Roles).
  3. A tool's input is a page query: apart from its title, every field is a URL parameter of the tool's page (listAlarms → /alarms, listUsers → /users, and so on). The chat renders the tool call as that page's table, which fetches its own data, and links to the page with the same query. "Deactivated users who never signed in" becomes status: DEACTIVATED, lastSignIn: never; "open alarms at Riverside" becomes state: [ACTIVE, ACKNOWLEDGED, CLEARED], assetId: <Riverside's id>. Each request tells Claude the current date, time and UTC offset in the user's time zone, so "who signed in yesterday" becomes a date and "alarms raised yesterday" becomes a from/to window on the user's calendar day.
  4. features/assistant/lib/tool-renderers.ts maps each tool to its view (a body component in features/assistant/components/tool-bodies/), and components/tool-part-view.tsx draws it according to the part's state (input-streaming → skeleton, input complete → the table, output-error → an alert).

The system prompt is generic. It holds the app's pages, the rules for using tools, the language rule and the out-of-scope refusal, the answer format (components or Markdown), the rules for forms (or, when the user can't open one, where to get it), the pick lists below, and the user's name and role. The clocks come with the questions instead: Claude's copy of each one starts with a session block saying when it was sent, on the user's clock and on the plant's. It never names a tool. What a tool does and how to fill its fields lives in its description and field .describe() texts in packages/shared/src/assistant.ts, which Claude receives with the tool.

Admins edit the standing instructions. The head of the prompt (the page map and the rules for tools, pick lists, new dashboards, language, scope and style) is the text on the Settings page (/settings, admins only; GET/PUT /api/ai/instructions). It lives in one row of assistant_instructions, updated in place: only the latest text is kept, and each change is an audit record (assistant_instructions.update, without the text). Until an admin first saves, the text built into system-prompt.ts (DEFAULT_INSTRUCTIONS) is used, and Restore default puts it back. The next message anyone sends uses the new text; the server still adds a note on the session block, the answer format, forms, pick lists and the user after it.

The prompt is cached. Anthropic keeps a prompt's start (tools, then system, then messages) up to a marked block for five minutes and reads it back at a tenth of the input price without processing it again. The system prompt goes as three blocks: the instructions with the answer format and forms (marked; the same for every user of a role), the pick lists (marked; the same for a user until a record changes) and the user's name and role. The request also asks Anthropic to cache up to its last message, so each tool round, and the next question within five minutes, reads the conversation before it. That only works while nothing before the conversation changes, so the clocks, which change every minute, are not in the system prompt: the server saves the time each question is asked with it (metadata.createdAt), and Claude's copy of that question starts with its session block, the same on every later turn. A prompt start shorter than the model's minimum (4,096 tokens on Haiku 4.5) is never cached.

English and Serbian only. The assistant answers in the language of the question, English or Serbian (Latin or Cyrillic). A question in any other language gets a one-line refusal in both, with no lookup.

Assets and people come as ids, picked from the dropdowns' own lists. Every request adds two lists to the prompt: the asset tree (AssetsService.tree(), what the asset dropdown shows) and the active people (UsersService.options(), what Author and Who show), each entry with its id. Claude matches the user's words to an entry, typos included ("hilcrest" → Hillcrest Reservoir), and writes its id into the tool input. The chat table, Open in new tab and the page's dropdown then all show that same asset or person. If a name fits several entries equally (there are several pumps), Claude asks which one. If none fits, it says so. A tool rejects an id that isn't on the lists, so a miscopied id can't quietly return an empty table. The lists are never cut: every asset and every active person is listed, so they cost about 500 input tokens per message with the demo data and grow with the tree and the staff. Every device, tag and alarm rule is listed too (<devices>, <tags>, <alarm-rules>), because the details cards and the chart tool (offered to everyone, with components on or off) take them by id, and so is every shift template (<shift-templates>, with its plant-time hours), which the shift calendar filters by. Deactivated people keep their planned shifts (they show as gaps), so the shift calendar and the edit and remove forms also take them, from <former-people> (only when there are any): "Liam left, give his shifts next week to Sofia". Shift dates are the plant's (SHIFT_TIME_ZONE), so the session also gives the plant's date and time next to the user's. The form tools take the same lists ("edit the PS-02 RTU"), and with components on the prompt also lists each user's own 30 latest logbook entries (the only ones they may edit). Claude matches "wet wel hihg levl" to Wet well high level, or asks "high or low?" for "the wet well level rule". No code matches names.

Forms in the answer. The form tools open the app's own form inside the answer: the same header, fields, button and top-right X as the modal on its page (InlineDialog draws the dialog in place). Claude fills in what the user said; for an edit, the form loads the record as it is now and puts the requested changes over it, each changed field hinting what it was. The tools never write anything: the user saves through the same REST endpoint the modal uses. Afterwards the browser reports what happened (PUT /api/ai/conversations/:id/forms/:toolCallId, saved with the record's id — and every id, for a form that saves several shifts — or closed), and the server writes it into that tool call in the saved conversation, reading the record itself (for a removed shift, whether it is gone and how many of its series are left). The form then becomes a one-line result, also after a reload, and Claude knows on its next turn. That costs no model call. Details in docs/ai-assistant-flow.md.

Dashboards from a description. createDashboard never saves anything either. Its call is the draft: the answer previews it with the Dashboard page's DashboardGrid (mode="preview"), and the page opens it from the saved conversation (GET /api/ai/conversations/:id/dashboard-drafts/:toolCallId, only for the conversation's owner) as an unsaved dashboard in edit mode, where the user fills in what is missing and saves it with POST /api/dashboards and the draft as its source. The dashboard keeps that source (dashboards.source_conversation_id and source_tool_call_id, unique together), so one draft makes one dashboard (a second save is a 409, "already created, open it"), and the chat derives Created from the dashboard itself: deleting it makes the draft Not created yet again, a rename shows the new name, and both follow as soon as the chat's window has focus again. Claude reads the same status on later turns (the created field of the call's output, filled in per request, never saved). Clearing the chat drops the link but keeps the dashboard; a draft link from it then says the draft is no longer available. To refine ("make the chart wider"), Claude calls the tool again with the whole layout: each call is its own preview and draft. It is not a form tool: it works with components off too, as one line with the link.

Generate UI components (a switch above the message box, on by default): turned off, the assistant still looks data up but answers in Markdown, and each lookup shows as one line with the same link. Forms need it on: with it off, asking for one ("add a rule…") gets a pointer to this switch, or to the page, instead of questions about the values. A user (not an admin) has only the logbook forms; asking for another is told only admins can do that. Each answer keeps the mode it was given in.

Every user has any number of chats, saved in the conversations table, one row each, with the AI SDK's messages (parts included) as JSONB. The sidebar's Assistant group lists them (latest activity first, loading more as the list scrolls; GET /api/ai/conversations), with New chat above them and Rename and Delete in each chat's menu (PATCH/DELETE /api/ai/conversations/:id). /assistant is a new chat; /assistant?chat=<id> is a saved one (GET /api/ai/conversations/:id). A new chat's id comes from the browser, and its first message creates the row, so there are no empty chats; the URL then takes the id. Its title is that question until the first answer ends; meanwhile Claude Haiku writes a short title (one small extra call per new chat, conversation-title.ts), saved before the stream ends unless the user renamed the chat by then. The client sends only its new message; the server holds the history, saves it before the model runs and again when the stream ends (also after Stop; that save never brings back a chat deleted while it was answering). Switching chats doesn't stop an answer: it keeps arriving in the background (store/chat-store.ts keeps each open chat). A chat reopens with its tables, which show current data. Deleting a chat is for good; dashboards saved from its drafts stay. Each turn sends the whole conversation to Claude, so a long chat costs more per answer; past MAX_CONVERSATION_MESSAGES (200 messages) the server answers 400 and asks the user to start a new chat.

To add a domain:

  1. Add its tool schema to genUiToolSchemas in packages/shared/src/assistant.ts, with a description on every field.
  2. Implement the tool in backend/src/features/ai/ai.tools.ts, calling that domain's service, and add its TOOL_ROLES line. Export the service from its module and import that module in ai.module.ts.
  3. Add a body component in frontend/src/features/assistant/components/tool-bodies/ that wraps the domain's existing table (or view: the shift calendar, the on-duty strip), and its entry in lib/tool-renderers.ts. Role-based row actions belong inside that view, with their dialogs (each feature's hooks/use…Actions hook: users, alarm rules, devices, tags, assets, logbook, shifts; alarms are acknowledged and cleared in the alarm's detail sheet, which its table opens on a click, in the chat too), so the page and the chat show the same ones. What differs in the chat is a prop on the same component, never a copy of it. A filter a page sets with an asset or people dropdown takes the same ids in the tool; Claude picks them from the lists in its prompt.

TypeScript fails the build if the backend tools and the shared contract drift apart, or if a tool has no renderer. A tool that creates or edits something is a form tool instead; see "Adding a form tool" in docs/ai-assistant-flow.md.

The assistant has its own page at /assistant, linked from the sidebar. The assistant doesn't know which page is open; each request carries only the browser's time zone besides the settings.

Model and effort

Two pickers above the message box choose, per message, which Claude model answers and how hard it works. Both are remembered in the browser and sent with each request, and every answer is labelled with the model and effort that produced it.

Model Id Effort levels Thinking
Claude Opus 5 claude-opus-5 low · medium · high (default) · extra-high · max adaptive
Claude Sonnet 5 claude-sonnet-5 same adaptive
Claude Haiku 4.5 (default, cheapest) claude-haiku-4-5 none (the picker says "No effort levels") none

The list lives in ASSISTANT_MODELS (packages/shared/src/assistant.ts), and the server rejects any other id. DEFAULT_ASSISTANT_MODEL (Haiku) is what the picker starts on and what the server uses when a request names no model; a model picked earlier stays picked in that browser. Claude 3 Opus, 3.5 Sonnet and 3 Haiku are retired by Anthropic (they return 404) and never had effort levels, so each tier uses its current model. Lower effort is faster and cheaper; for simple lookups like these, low or medium is usually enough.

Don't add temperature/topP/budgetTokens: the Claude 5 models reject them with a 400. Settings, all with defaults:

  • AI_REFUSAL_FALLBACK (on by default) enables Anthropic's server-side refusal fallback on Claude Opus 5.
  • AI_MAX_OUTPUT_TOKENS (64000, at most 128000) caps answer length; Haiku 4.5 never gets more than 64000.
  • AI_MAX_STEPS (5) caps the model↔tool round trips per message.
  • AI_SEND_REASONING (on) streams a summary of the model's thinking, shown folded under the answer.

The only key the app needs is ANTHROPIC_API_KEY.

Operations

Health checks. GET /api/health/live has no dependencies and answers as long as the process can serve — use it for liveness, so a database blip doesn't get a healthy container restarted. GET /api/health/ready pings the database and answers 503 when it is unreachable — use it for readiness and load-balancer membership. GET /api/health is kept for existing callers and behaves like /ready.

Errors. Every error response carries statusCode, message, timestamp and path; handlers add their own fields on top (auth errors carry a machine-readable code). Unexpected failures are logged with a stack server-side and return a generic message to the client (backend/src/common/all-exceptions.filter.ts).

Refresh-token retention. Rows are kept after logout and rotation because they are what makes reuse detection work. A daily job (03:00, server time) deletes tokens more than REFRESH_TOKEN_PRUNE_AFTER_DAYS (default 30) past expiry, so the table doesn't grow without bound.

Notification retention. Every alarm event becomes one row per active user, so notifications grows with the staff. A daily job (02:00, server time, prune-notifications) deletes notifications older than NOTIFICATION_RETENTION_DAYS (default 30), read or not; the alarms, logbook entries and shifts they were about stay.

Production notes

  • Serve the SPA and /api from the same origin behind a reverse proxy, set COOKIE_SECURE=true and TRUST_PROXY, disable proxy buffering for /api/ai/chat, and pass WebSocket upgrades (Upgrade/Connection headers) for /api/socket.io. frontend/nginx.conf is a working example of all of this.
  • A notification reaches its user's tabs connected to the API process that stored it. Behind several API instances, add the Socket.IO Redis adapter so it reaches their tabs on every instance (the inbox itself is in the database, so a reload shows it anyway).
  • Set NODE_ENV=production. Configuration is validated at boot and fails fast: in production COOKIE_SECURE must be true and ANTHROPIC_API_KEY must be present.
  • Replace the development JWT_ACCESS_SECRET from docker-compose.yml with a real one (openssl rand -base64 48, at least 32 characters). The boot check enforces only the length, so it won't catch the development value.
  • Set SEED_ON_START=false. The container seeds on every start by default, also with NODE_ENV=production, and the seed recreates the demo accounts with Password123! if they were deleted. Once real data flows in, also turn off SIMULATOR_ENABLED and MOCK_ALARM_NOTIFICATIONS (both on by default).
  • For split origins, set CORS_ORIGIN (comma-separated) and VITE_API_BASE_URL. Vite reads VITE_API_BASE_URL at build time, and frontend/Dockerfile has no build argument for it yet. The cookie stays SameSite=Strict, so the frontend and API must be on the same site (e.g. app.example.com + api.example.com).
  • There is no rate limiting. This is a student project and 429s were getting in the way; if the app is ever exposed publicly, put a limiter in front of /api/auth/login (or restore @nestjs/throttler, removed in this branch's history).
  • /docs is public. It only describes routes that are guarded anyway, but if you would rather not publish the API's shape, drop the setupSwagger(app) call in backend/src/main.ts or put the path behind your proxy's own auth.
  • The users search filter is an unanchored ILIKE. It has no index; add a pg_trgm GIN index if that listing gets large.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages