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>
15 KiB
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 permiddleware.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.tsruns per scheduled task. Incremental vialastTrackedModificationDateTimewhen supported; full upsert otherwise. Targetstickets,companies,contacts,resources,tasks,time_entries, etc.
Datto RMM
- Sync — devices, sites, alerts →
datto_rmm_*tables. - Overshell executor —
lib/services/rmm/executor.tsvalidates a registered script ID, resolves the target device, enforces a per-user rate limit (50 / 24h), inserts apendingrow inrmm_executions, and callsclient.runQuickJob(). The worker polls Datto for the result and parses output via the script'sparseOutput()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-keyheader). Receives a B2 object key, downloads + decompresses the gzipped JSON (capped at 100 MB), stores a slim summary inloglift_uploadsand 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.tspulls org types, configs, contacts, locations, flexible assets, etc. →itg_*tables. Note: flexible assets must be listed per type (API 422 otherwise — see commita0a6e7f). - Search (analyzer) —
lib/services/analyzer/itglue-search.tsreturns redacted documents only. Credentials/PII pass throughredact()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 toitglue_audit_logs, and links tickets viaitglue_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 viaresources.email = graph_users.email. Seelib/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:
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 EXISTSfor tables/indexes,ON CONFLICT DO NOTHINGfor seed data, audit columnscreated_at/updated_at/synced_at/is_deleted/deleted_at. prioritieshas nois_deletedcolumn — caught the hard way (9acf48e).- Columns are
snake_case; API responses arecamelCase. 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 fromDockerfile(turbopack,output: 'standalone'); runsnode server.js. Port 3100..env.localmounted read-only. Traefik labels forpulse.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
- Worker side-effect imports. Importing
sync-scheduler.ts,analyzer/worker.ts, orrmm/worker.tsfrom a hot path starts the loop. - No external queue. Multiple instances duplicate pollers. Analyzer is safe via row locking; sync scheduler is not — pin to one instance.
- IT Glue redaction is mandatory for any LLM-bound query. Use
itglue-search.ts, never the raw client. - Provider-scoped idempotency.
force=falseonly short-circuits if the same provider produced the existing analysis. - Cost ceiling at $2.00 before Stage 4. Above that, Opus is skipped and the analysis is flagged.
- Webhook handlers return 200 on failure (Autotask) to avoid deactivation. Errors are logged, not surfaced.
- Postgres init runs migrations once. Existing volumes won't re-run them.
- Duplicate migration numbers. Apply order is alphabetic.
.envis committed. Treat the values as potentially real production secrets; don't log or echo them.- RMM script registry is in code.
lib/services/rmm/scripts/— unregistered scripts can't execute. - LogLift zip-bomb guard caps inflated payloads at 100 MB.
- Stale analyzer jobs reset on worker boot. Don't rely on
in_flightstate 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) |