wulf-pulse/ARCHITECTURE.md
lorentz 9bfb57553d feat(design): nav/visual overhaul — brand layer, /status route, KPI dashboard, TanStack DataTable
Major UI refresh on the nav-design-improvements branch.  Drops 2013-era
inline styles and consolidates patterns behind shared primitives.

Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
  the standards-guide blue (#0075AD) with utility classes for numerics
  (.num / .num-lg / .num-xl), metric labels, surface tints, and the
  wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
  Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
  "Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page

Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
  health table, worker pulse cards (analyzer / RMM / sync scheduler),
  token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
  to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
  integrations (e.g. SentinelOne) — no failure noise from broken-on-
  purpose entries.  Aliases supported (sentinelone → s1, etc.)

Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
  total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
  area chart, 30-day mean resolution time line chart, today's active
  engineers leaderboard

Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
  status, classification, source, company type, publish, active /
  yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)

Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
  PageHeader rule (consistent across flat links and submenu triggers);
  active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config

Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs

DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
  unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow

Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
  collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below

Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
  workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
  rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
  INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:33:13 -04:00

288 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Pulse — Architecture
Pulse is a single Next.js 16 app (`output: 'standalone'`) that backs Wulf
Consulting's PSA workflows. It pulls data from Autotask, Datto RMM, IT Glue, MS
Graph, Veeam, and ~10 other systems into Postgres, runs background workers for
sync / AI analysis / RMM execution, and serves dashboards + admin tooling on
port **3100**.
This file is the load-bearing reference for *how the system is wired*. Per-
feature deep dives live in `docs/`. UI/visual conventions live in `DESIGN.md`.
## 1. Runtime topology
One Node process, one Postgres, one Redis. Background work runs **in-process**
inside the Next server — there is no external job queue.
```
┌─────────────────────────────────────────────────────────┐
│ Next.js 16 (port 3100, output: 'standalone') │
│ │
│ HTTP routes ──► API handlers ──► Postgres / Redis │
│ │
│ Side-effect imports auto-start three workers: │
│ • SyncScheduler (node-cron) │
│ • AnalyzerWorker (poll analyzer_jobs every 2s) │
│ • RmmOvershellWorker (poll rmm_executions every 5s) │
└─────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
Postgres 16 Redis 7 External APIs
(state) (cache only) (Autotask, RMM, …)
```
**Workers are side-effect imports.** Touching one of these modules from a
server-side import path starts the loop:
| Worker | File | Trigger |
|---|---|---|
| Sync scheduler | `lib/services/sync-scheduler.ts` | Self-init at module bottom |
| Analyzer | `lib/services/analyzer/worker.ts` | Auto-starts when `NODE_ENV=production` or `ANALYZER_WORKER_AUTOSTART=1` |
| RMM Overshell | `lib/services/rmm/worker.ts` | Same gate as analyzer |
Routes that need a worker running deliberately import its module — e.g.
`app/api/analyzer/tickets/[ticketNumber]/analyze/route.ts` imports
`lib/services/analyzer/worker.ts` purely so the loop starts on first request.
**Don't eager-import these from hot paths or shared utilities.**
There is **no out-of-process queue**. Scaling out to multiple Next instances
means each instance runs duplicate pollers. The analyzer worker uses
`SELECT … FOR UPDATE SKIP LOCKED` so jobs run exactly once across instances,
but the sync scheduler does not — run it on a single dedicated instance, or
gate it behind an env flag on replicas.
## 2. Data flow
### Autotask (primary system of record)
- **Webhook** — `POST /api/webhooks/autotask`, public per `middleware.ts`,
HMAC-verified inside the handler. Returns 200 even on processing errors so
Autotask doesn't deactivate the subscription. Async handlers may enqueue
analyzer jobs.
- **Periodic sync** — `lib/services/entity-sync.ts` runs per scheduled task.
Incremental via `lastTrackedModificationDateTime` when supported; full upsert
otherwise. Targets `tickets`, `companies`, `contacts`, `resources`, `tasks`,
`time_entries`, etc.
### Datto RMM
- **Sync** — devices, sites, alerts → `datto_rmm_*` tables.
- **Overshell executor** — `lib/services/rmm/executor.ts` validates a
registered script ID, resolves the target device, enforces a per-user rate
limit (50 / 24h), inserts a `pending` row in `rmm_executions`, and calls
`client.runQuickJob()`. The worker polls Datto for the result and parses
output via the script's `parseOutput()` method. Scripts are **code-
registered** (`lib/services/rmm/scripts/`) — adding one is a TS change, not a
DB change.
- **LogLift** — `POST /api/rmm/loglift/upload` (public, `x-openclaw-key`
header). Receives a B2 object key, downloads + decompresses the gzipped JSON
(capped at 100 MB), stores a slim summary in `loglift_uploads` and the full
payload in B2 (`lib/services/b2/client.ts`). Resolves the device → Autotask
company → IT Glue configuration; if a unique IT Glue match is found, fires
an asset-first audit automatically.
### IT Glue
- **Sync** — `lib/services/itglue-sync-service.ts` pulls org types, configs,
contacts, locations, flexible assets, etc. → `itg_*` tables. Note: flexible
assets must be listed per type (API 422 otherwise — see commit `a0a6e7f`).
- **Search (analyzer)** — `lib/services/analyzer/itglue-search.ts` returns
redacted documents only. Credentials/PII pass through `redact()` before any
LLM sees them. Callers must not bypass redaction; the raw client is for non-
LLM use.
- **Audit + write-back** — `lib/services/analyzer/asset-audit/` runs LLM-driven
audits against IT Glue configurations or flexible assets, writes results to
`itglue_audit_logs`, and links tickets via `itglue_ticket_xrefs`. Reverts go
through `…/revert/[writeId]`.
### MS Graph (Engagement)
- App-only auth, **specific tenant ID** (not `common`). Reports API returns
CSV; parsed inline. Joins to Autotask hours via `resources.email =
graph_users.email`. See `lib/services/engagement-sync-service.ts`.
### Other integrations
Each has a factory + `is<Name>Configured()` helper in `lib/services/`. All
credentials come from env; clients throw if missing.
| System | Role | Files |
|---|---|---|
| Veeam VSPC | Backup status, RPO, ticket analysis | `veeam-*-service.ts` |
| Auvik | Network monitoring; tenant mappings | `auvik-client.ts` |
| Addigy | Apple endpoints; org mappings | `addigy-factory.ts` |
| Mimecast | Mail security | `mimecast-sync-service.ts` |
| SentinelOne | EDR | `sentinelone-sync-service.ts` |
| Duo | MFA | `duo-sync-service.ts` |
| Zoom | Meetings | `zoom-sync-service.ts` |
| QuickBooks | Billing reconciliation | `qbo-sync-service.ts` |
| Zabbix | WAN monitoring; webhook at `/api/zabbix/webhook` | — |
| Salesbldr | Sales pipeline | — |
## 3. Analyzer pipeline
`lib/services/analyzer/pipeline.ts` orchestrates seven stages. Provider is
chosen per request (`anthropic` default, `openrouter` opt-in); models are
mapped per stage in `lib/services/llm/models.ts`.
| Stage | Model (Anthropic) | Purpose |
|---|---|---|
| 0 — Preprocess | — | Filter workflow noise, tag entities, compute `content_hash` (idempotency key) |
| 1 — Triage | Haiku | Categorize, extract entities, initial priority |
| 2 — IT Glue retrieval | — | Redacted doc lookup, skipped if IT Glue not configured |
| 3 — Deep analysis | Sonnet | Summary, gaps, what-was-done, what-should-have-been-done |
| 4 — Deep reasoning | Opus (optional) | Apply corrections, propose IT Glue updates, re-rank |
| 5 — Persist | — | Write `analyzer_analyses` row + per-stage execution rows |
| 6 — Fingerprint | Haiku | Structured fingerprint for cross-ticket aggregation |
**Idempotency.** If `content_hash` already exists for the ticket and
`force=false`, the pipeline returns the existing analysis. The hash is
provider-scoped — Claude and DeepSeek analyses of the same ticket are separate
rows.
**Cost ceiling.** Stage 4 is skipped if estimated total cost exceeds **$2.00**;
the analysis is flagged for human review. All LLM/RMM activity is logged to
`analyzer_cost_audit`.
**Link-aware bundles.** `lib/services/analyzer/link-discovery.ts` resolves
related tickets two ways: (1) explicit — regex T-numbers in descriptions/notes,
"RELATED TICKETS:" blocks, the `problem_ticket_id` column; (2) suggested
(opt-in) — Haiku ranks recent same-company tickets by semantic similarity. When
a bundle is analyzed, members get `pending_analyses` rows; once all complete,
an aggregate report fires.
**Aggregate reports.** `stages/aggregate-reduce.ts` pairs SQL distributions
(category, client, resolution path, root cause) with a Sonnet pass that
identifies documentation gaps, process gaps, client patterns, recurrence
clusters. Persisted to `analyzer_aggregate_reports`.
**Asset audit (Phase 4).** `lib/services/analyzer/asset-audit/runner.ts` runs
post-analysis. Two modes: all-time evidence across every analysis linked to
the asset, or ticket-first (Phase 4.1) narrowed to a single analysis ID.
## 4. Background jobs
| Worker | Cadence | Scope | Concurrency model |
|---|---|---|---|
| `AnalyzerWorker` | 2s poll | Claims `analyzer_jobs.status='queued'` | `FOR UPDATE SKIP LOCKED`, exactly-once across instances |
| `RmmOvershellWorker` | 5s poll | Polls in-flight `rmm_executions`; advances state by querying Datto | Single in-process loop |
| `SyncScheduler` | node-cron | Per-schedule rows in DB (Autotask, IT Glue, Veeam, Engagement, Zoom, Duo, …) | **Not** safe for multi-instance — overlaps possible |
| `integration-health-alerts` | Cron-fired from sync scheduler | Detects stale syncs, publishes alerts | Single in-process |
Stale in-flight analyzer jobs are reset on worker boot (commit `378e68a`) so a
crashed pod doesn't leave jobs orphaned.
## 5. Auth & permissions
**Better Auth 1.4** with magic link + TOTP 2FA + Microsoft OAuth. Sessions
live in Postgres (no Redis session store). Account-linking is enabled for
Microsoft so admin-invited users join their MS account in one click.
**Roles.** `user`, `admin`, `super-admin`. Default admin bootstrapped from
`DEFAULT_ADMIN_EMAIL` via `lib/bootstrap.ts`.
**Resources** (`lib/permissions.ts`) — `tickets`, `configItems`, `admin`,
`users`, `roles`, `auditLog`, `settings`, `itglue`, `rmm`. The `itglue` and
`rmm` resources were added with the Overshell + IT Glue write-back work; user
role gets read-only on both.
**API auth pattern.** Every route handler calls one of:
```ts
const { session, error } = await requireAuth();
const { session, error } = await requireAdmin();
const { session, error } = await requireSuperAdmin();
const { session, error } = await requirePermission('itglue', 'write');
if (error) return error;
```
`middleware.ts` only checks for a session cookie — role/permission checks live
in the route handler.
**Public routes** (hardcoded in `middleware.ts`): `/api/auth/*`,
`/api/webhooks/*`, `/api/sync/*`, `/api/health`, `/api/zabbix/webhook`,
`/api/rmm/loglift`, `/api/mobile/*`, `/api/openclaw/*`, `/legal`,
`/api/kiosk`, `/api/qbo/*`. **Add to that list whenever you introduce a new
public endpoint.**
## 6. Database
89 numbered migrations (`migrations/NNN_*.sql`), applied in **alphabetical**
order on Postgres init only. Existing volumes do not re-run them — for
schema changes against an existing DB, use `scripts/apply-migrations` (verify
behavior first; varies by age of script).
Topical groupings:
| Range | Topic |
|---|---|
| 001014 | Core schema, auth tables, admin settings |
| 015026 | Ticket fields, queues, RMM site mappings, integration health |
| 027032 | Datto RMM, Veeam agents/alarms, priorities, ticket categories, RMM webhooks |
| 037044 | IT Glue (large), Veeam RPO, contract services, engagement |
| 045055 | Zoom, Teams, morning summary, ping suppression, ticket digest, Zabbix WAN, QBO, Mimecast |
| 056068 | UDFs, Autotask tags, Duo, project phases, recurring revenue, Veeam ticket analysis |
| 069074 | Analyzer (jobs, analyses, stage executions, aggregate reports, cost audit, link-aware bundles, provider) |
| 075076 | IT Glue audit + ticket xrefs |
| 077078 | RMM Overshell, LogLift uploads |
| 079080 | Endpoint data model, device-xref `company_id` |
**Watch out for:**
- Duplicate numbers exist (002, 004, 009). Apply order is filesystem-sort
alphabetical, not numeric. Don't introduce more.
- Conventions: `IF NOT EXISTS` for tables/indexes, `ON CONFLICT DO NOTHING`
for seed data, audit columns `created_at` / `updated_at` / `synced_at` /
`is_deleted` / `deleted_at`.
- `priorities` has no `is_deleted` column — caught the hard way (`9acf48e`).
- Columns are **`snake_case`**; API responses are **`camelCase`**. Handlers
transform manually. No ORM.
DB access is the singleton at `lib/services/postgres-client.ts`
`postgresClient.query()`, `.transaction()`, `.upsert()`, `.bulkUpsert()`.
## 7. Deployment
**Docker Compose** at the repo root.
- `postgres` — Postgres 16, port 5432. Migrations volume mounted at
`/docker-entrypoint-initdb.d/`. Init runs once per volume.
- `redis` — Redis 7, port 6380 (host) / 6379 (container). Cache only.
- `app` — built from `Dockerfile` (turbopack, `output: 'standalone'`); runs
`node server.js`. Port 3100. `.env.local` mounted read-only. Traefik labels
for `pulse.wulfconsulting.cloud` (HTTPS via Cloudflare cert).
`npm run build` uses turbopack (Next 16 default). `npm run dev` for local;
`npx tsc --noEmit --pretty` for type check; `npm test` (vitest) for the
analyzer / RMM / B2 / link-discovery unit tests. **No CI**; type-check is the
only safety net for code that doesn't have unit tests.
## 8. Invariants & gotchas
1. **Worker side-effect imports.** Importing `sync-scheduler.ts`,
`analyzer/worker.ts`, or `rmm/worker.ts` from a hot path starts the loop.
2. **No external queue.** Multiple instances duplicate pollers. Analyzer is
safe via row locking; sync scheduler is not — pin to one instance.
3. **IT Glue redaction is mandatory** for any LLM-bound query. Use
`itglue-search.ts`, never the raw client.
4. **Provider-scoped idempotency.** `force=false` only short-circuits if the
same provider produced the existing analysis.
5. **Cost ceiling at $2.00** before Stage 4. Above that, Opus is skipped and
the analysis is flagged.
6. **Webhook handlers return 200 on failure** (Autotask) to avoid
deactivation. Errors are logged, not surfaced.
7. **Postgres init runs migrations once.** Existing volumes won't re-run them.
8. **Duplicate migration numbers.** Apply order is alphabetic.
9. **`.env` is committed.** Treat the values as potentially real production
secrets; don't log or echo them.
10. **RMM script registry is in code.** `lib/services/rmm/scripts/`
unregistered scripts can't execute.
11. **LogLift zip-bomb guard** caps inflated payloads at 100 MB.
12. **Stale analyzer jobs reset on worker boot.** Don't rely on `in_flight`
state surviving restarts.
## 9. Where to look
| Concern | Start here |
|---|---|
| HTTP routes | `app/api/**/route.ts` |
| Pages | `app/**/page.tsx` |
| Worker boot | `lib/services/{sync-scheduler,analyzer/worker,rmm/worker}.ts` |
| Postgres access | `lib/services/postgres-client.ts` |
| Auth wiring | `lib/auth.ts`, `lib/auth-utils.ts`, `lib/permissions.ts`, `middleware.ts` |
| Analyzer pipeline | `lib/services/analyzer/pipeline.ts` + `stages/` |
| LLM dispatch | `lib/services/llm/{call,models,pricing}.ts` |
| RMM executor | `lib/services/rmm/{executor,worker,target-resolver}.ts` |
| IT Glue write-back | `lib/services/analyzer/asset-audit/` |
| Per-feature notes | `docs/` (one file per system) |