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>
288 lines
15 KiB
Markdown
288 lines
15 KiB
Markdown
# 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 |
|
||
|---|---|
|
||
| 001–014 | Core schema, auth tables, admin settings |
|
||
| 015–026 | Ticket fields, queues, RMM site mappings, integration health |
|
||
| 027–032 | Datto RMM, Veeam agents/alarms, priorities, ticket categories, RMM webhooks |
|
||
| 037–044 | IT Glue (large), Veeam RPO, contract services, engagement |
|
||
| 045–055 | Zoom, Teams, morning summary, ping suppression, ticket digest, Zabbix WAN, QBO, Mimecast |
|
||
| 056–068 | UDFs, Autotask tags, Duo, project phases, recurring revenue, Veeam ticket analysis |
|
||
| 069–074 | Analyzer (jobs, analyses, stage executions, aggregate reports, cost audit, link-aware bundles, provider) |
|
||
| 075–076 | IT Glue audit + ticket xrefs |
|
||
| 077–078 | RMM Overshell, LogLift uploads |
|
||
| 079–080 | 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) |
|