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>
This commit is contained in:
lorentz 2026-05-03 09:33:13 -04:00
parent 1112a06afe
commit 9bfb57553d
75 changed files with 9352 additions and 1827 deletions

11
.mcp.json Normal file
View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

288
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,288 @@
# 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) |

View file

@ -5,7 +5,13 @@ data into Postgres and adds dashboards, workflows, and analytics around it.
Single Next.js 16 app — not a monorepo. Single Next.js 16 app — not a monorepo.
`README.md` covers the human-facing overview. **Trust this file** for the details `README.md` covers the human-facing overview. **Trust this file** for the details
that matter to coding decisions. that matter to coding decisions. For deeper context:
- **`ARCHITECTURE.md`** — runtime topology, data flow, workers, analyzer pipeline,
invariants. Read before touching workers, sync, or the analyzer.
- **`DESIGN.md`** — design tokens, navigation IA, component vocabulary, layout
rules, and the working backlog for nav/visual cleanup. Read before touching
pages or shared UI components.
## Stack ## Stack
- Next.js 16 + React 19 (App Router, `reactCompiler: true`, `output: 'standalone'`) - Next.js 16 + React 19 (App Router, `reactCompiler: true`, `output: 'standalone'`)
@ -73,7 +79,9 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
| Datto RMM | `DATTO_RMM_*` | | Datto RMM | `DATTO_RMM_*` |
| Veeam VSPC | `VEEAM_VSPC_*` | | Veeam VSPC | `VEEAM_VSPC_*` |
| Auvik / Addigy / IT Glue / Mimecast / S1 / Duo / Zoom / QBO / Zabbix / Salesbldr | `<NAME>_*` | | Auvik / Addigy / IT Glue / Mimecast / S1 / Duo / Zoom / QBO / Zabbix / Salesbldr | `<NAME>_*` |
| Anthropic | `ANTHROPIC_API_KEY` (used in `ai-triage-service.ts`, `llm-analyzer.ts`) | | Anthropic | `ANTHROPIC_API_KEY` (analyzer pipeline + `ai-triage-service.ts`) |
| OpenRouter | `OPENROUTER_API_KEY` (alternate analyzer provider, opt-in per request) |
| Backblaze B2 | `B2_*` (LogLift evidence storage) |
| Postgres / Redis | `POSTGRES_*` or `DATABASE_URL`, `REDIS_URL` | | Postgres / Redis | `POSTGRES_*` or `DATABASE_URL`, `REDIS_URL` |
## Sync & scheduling ## Sync & scheduling
@ -82,8 +90,11 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
- `lib/services/sync-scheduler.ts` — node-cron singleton. **Self-initializes on - `lib/services/sync-scheduler.ts` — node-cron singleton. **Self-initializes on
first server-side import** (side effect at the bottom of the file). Schedules first server-side import** (side effect at the bottom of the file). Schedules
live in DB, admin-editable at `/admin`. live in DB, admin-editable at `/admin`.
- Webhooks (`/api/webhooks/...`, `/api/zabbix/webhook`) are public per - Webhooks (`/api/webhooks/...`, `/api/zabbix/webhook`, `/api/rmm/loglift`) are
`middleware.ts`; they verify HMAC themselves. public per `middleware.ts`; they verify HMAC or a shared header themselves.
- Analyzer worker (`lib/services/analyzer/worker.ts`) and RMM Overshell worker
(`lib/services/rmm/worker.ts`) auto-start on import in production. Same
side-effect-import caveat as the sync scheduler.
## Auth ## Auth
- Better Auth with magic link + TOTP 2FA + Microsoft OAuth. Roles: `user`, `admin`, - Better Auth with magic link + TOTP 2FA + Microsoft OAuth. Roles: `user`, `admin`,
@ -97,9 +108,10 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
- Dev: `npm run dev` → http://localhost:3100 - Dev: `npm run dev` → http://localhost:3100
- Build: `npm run build` (turbopack via Next 16) - Build: `npm run build` (turbopack via Next 16)
- Type check: `npx tsc --noEmit --pretty` - Type check: `npx tsc --noEmit --pretty`
- Tests: `npm test` (vitest) — currently scoped to `lib/services/analyzer/**` only. - Tests: `npm test` (vitest) — covers `lib/services/analyzer/**`,
No CI yet; tests are local-only. Other parts of the codebase have no tests — `lib/services/rmm/**`, `lib/services/b2/**`, and `lib/services/analyzer/
if you touch them, type-check is the only safety net. link-discovery.test.ts`. Other parts of the codebase have no tests — if you
touch them, type-check is the only safety net. No CI yet; tests are local-only.
- Docker: `docker compose up` from repo root. Postgres applies `migrations/*.sql` - Docker: `docker compose up` from repo root. Postgres applies `migrations/*.sql`
on init only (existing volumes won't re-run them). on init only (existing volumes won't re-run them).
@ -110,15 +122,37 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
- New SQL: numbered migration; never edit a committed one. - New SQL: numbered migration; never edit a committed one.
- Long-form per-feature documentation belongs in `docs/`. Don't duplicate it here. - Long-form per-feature documentation belongs in `docs/`. Don't duplicate it here.
## Operator config
- `INTEGRATIONS_DISABLED` — comma- or space-separated list of integration
keys (or aliases) to suppress from `/status` and the top-bar status light.
Disabled entries render muted, don't count toward failure summaries, and
don't flag the rollup. Set in `.env` and restart. Aliases:
`sentinelone``s1`, `datto``datto_rmm`, `it-glue``itglue`,
`ms-graph``msgraph`. Live auth checks still run (so logs still
surface the underlying state) but the UI ignores the result.
## Watch out for ## Watch out for
- A `.env` file is committed to the repo. Treat secrets as potentially real; don't - A `.env` file is committed to the repo. Treat secrets as potentially real; don't
log/echo them, and flag this if it comes up. log/echo them, and flag this if it comes up.
- Duplicate migration numbers exist (002, 004, 009) — alphabetical apply order. - Duplicate migration numbers exist (002, 004, 009) — alphabetical apply order.
- Sync scheduler runs as a side effect of importing `sync-scheduler.ts` on the - Sync scheduler, analyzer worker, and RMM worker all auto-start as side effects
server. Be careful adding eager imports of that module. of being imported on the server. Don't eager-import them from hot paths or
shared utilities.
- Analyzer LLM provider is per-request (`anthropic` | `openrouter`). The
idempotency `content_hash` is provider-scoped — the same ticket can have one
Claude row and one OpenRouter row.
- Analyzer cost ceiling: Stage 4 (Opus) skipped above $2.00 estimated cost; the
analysis is flagged for human review.
- IT Glue results destined for an LLM **must** go through
`lib/services/analyzer/itglue-search.ts` (redacted). Don't pipe raw client
output into a prompt.
## Useful existing docs ## Useful existing docs
- `ARCHITECTURE.md` — runtime, data flow, workers, analyzer pipeline (read first)
- `DESIGN.md` — UI tokens, nav IA, component conventions, current cleanup backlog
- `AUTOTASK_API_GUIDE.md`, `ADDIGY_API_GUIDE.md` — credential setup - `AUTOTASK_API_GUIDE.md`, `ADDIGY_API_GUIDE.md` — credential setup
- `POSTGRES_SYNC_SETUP.md`, `DOCKER_README.md` - `POSTGRES_SYNC_SETUP.md`, `DOCKER_README.md`
- `PULSE_DATABASE_SKILL.md` — diagnostic queries - `PULSE_DATABASE_SKILL.md` — diagnostic queries
- `docs/` — sync behavior, webhook setup, workflow editor, per-integration guides - `docs/` — sync behavior, webhook setup, workflow editor, analyzer runbook,
RMM Overshell + LogLift specs, IT Glue audit spec, per-integration guides

396
DESIGN.md Normal file
View file

@ -0,0 +1,396 @@
# Pulse — Design System
This file documents how the UI is put together today: the tokens, the
component vocabulary, the navigation IA, and the patterns each page is
expected to follow. It also calls out the rough edges that the
`nav-design-improvements` branch exists to address — treat the "Open
improvements" section as the working backlog for visual/UX cleanup.
System overview lives in `ARCHITECTURE.md`. Per-feature notes live in `docs/`.
## 1. Design principles
- **Information density over decoration.** Pulse is an internal operations
console. Tables, dashboards, and admin tools win over hero spacing.
- **One source of truth per pattern.** If shadcn/ui has a primitive, use it —
don't re-implement (`<table>` raw, custom skeletons, ad-hoc dropdowns).
- **Tokens, not hex.** Colors come from CSS variables in `app/globals.css`,
not Tailwind's full color palette. The same goes for radius and spacing.
- **Roles shape the nav, not the layout.** Hide items the user can't access;
don't restructure the page shell per role.
- **Read-first, write-confirmed.** Destructive or external-effect actions
(RMM execute, IT Glue write) live behind explicit dialogs with confirmation,
not inline buttons.
## 2. Tokens
App tokens are defined in `app/globals.css` (shadcn defaults). The Wulf
brand layer in `app/styles/brand.css` is imported last and repoints
`--primary` / `--accent` / `--ring` / `--chart-2` to the standards-guide
blue (`#0075AD`) and provides Wulf gray ramps (`--wulf-gray-100/300/700`).
Tailwind 4 reads them via `@theme inline`; never hard-code colors or
radii in components.
### Colors (OKLch)
| Token | Light | Dark | Use |
|---|---|---|---|
| `--background` / `--foreground` | white / near-black | near-black / near-white | Page surface and primary text |
| `--card` / `--card-foreground` | white | `oklch(0.205 0 0)` | Card surfaces |
| `--primary` / `--primary-foreground` | logo blue `oklch(0.55 0.16 220)` | brighter blue `oklch(0.62 0.17 220)` | Primary actions, active nav, focus ring |
| `--secondary` / `--muted` | `oklch(0.97 0 0)` | `oklch(0.269 0 0)` | Subtle surfaces, table headers |
| `--muted-foreground` | `oklch(0.556 0 0)` | `oklch(0.708 0 0)` | Secondary text, descriptions |
| `--accent` | same as primary | same as primary | Hover/selection accents |
| `--destructive` | `oklch(0.577 0.245 27.325)` | `oklch(0.704 0.191 22.216)` | Errors, destructive buttons |
| `--border` / `--input` | `oklch(0.922 0 0)` | `oklch(1 0 0 / 10%)` | Borders, input outlines |
| `--ring` | logo blue | brighter blue | Focus ring |
| `--chart-1``--chart-5` | distinct hues | distinct hues | Recharts series |
| `--sidebar*` | mirrors page tokens | mirrors page tokens | Reserved for a future sidebar nav |
Status hues outside this set (success greens, warning ambers, info blues)
should still go through Tailwind's named palette at `-500` or `-600` and use
the muted background pair `bg-{hue}-500/15 text-{hue}-600` for badges so the
contrast stays acceptable in both modes.
### Radius
`--radius: 0.625rem`. Derived: `radius-sm` (`-4px`), `radius-md` (`-2px`),
`radius-lg` (=), `radius-xl` (`+4px`). Use `rounded-md` / `rounded-lg`
don't introduce custom radii.
### Type
Brand mandate (per `docs/StandardsGuide (1).pdf`, 2013): Helvetica /
Arial, Bold for headers and Light for the tagline. **As of 2026-05 the
app uses IBM Plex Sans instead** — same spirit (engineered sans, Light
weight available), reliably hosted, and pairs with Plex Mono for
numerics. Helvetica / Arial remain in the fallback chain.
- Sans: **IBM Plex Sans** via `next/font/google`, weights 300/400/500/600/700.
Wired via `--font-plex-sans` in `app/layout.tsx`.
- Mono: **IBM Plex Mono** via `next/font/google`, weights 400/500/600.
Used for numerics, IDs, timestamps — not for body text.
Type scale: stick to Tailwind defaults. Reserve `text-2xl font-bold
tracking-tight` for page titles (matches `PageHeader`); use `text-lg
font-semibold` for card titles and `text-sm text-muted-foreground` for
descriptions. Numerics go through the `.num` / `.num-lg` / `.num-xl`
utilities defined in `app/styles/brand.css`.
### Dark mode
CSS-class strategy (`.dark` on `<html>`). Toggled by `ThemeToggle` in the
top bar; persisted via `next-themes`. Component-level dark variants should be
unnecessary if you stick to tokens.
## 3. Layout
### Page shell
```
┌─ <AppNavigation /> (sticky h-16, backdrop blur, z-50) ─┐
├─ <PageHeader /> (optional, bordered, container-aligned)
└─ <main> (container mx-auto px-6 py-6)
├─ Cards / sections, separated by space-y-6
└─ …
```
`PageHeader` lives at the bottom of `components/navigation/app-navigation.tsx`.
It owns the title (`h1.text-2xl.font-bold.tracking-tight`), description
(`text-muted-foreground`), optional breadcrumbs (`/`-separated), and an
actions slot.
### Container & spacing rules
| Rule | Value |
|---|---|
| Horizontal container | `container mx-auto px-6` |
| Vertical page padding | `py-6` (default), `py-8` only on auth/landing |
| Section gap | `space-y-6` for stacked sections, `gap-6` for grid layouts |
| Card body padding | shadcn default — don't override unless wrapping a table (`p-0`) |
| Item-level gap | `gap-2` for inline rows, `gap-4` for form rows |
These are the targets. Today's pages don't all conform — see "Open
improvements" below.
## 4. Navigation IA
The top bar (`components/navigation/app-navigation.tsx`) is the only nav. It
is sticky, full-width, and structured left-to-right as:
1. **Brand** — logo + "Pulse" wordmark (`hidden sm:block` for the wordmark)
2. **Primary menu** (`<NavigationMenu>`)
3. **Right-side controls**`<ThemeToggle />`
### Items today
| Label | Route | Visibility |
|---|---|---|
| Dashboard | `/` | All |
| Configuration Items | `/configuration-items` | All |
| Backup Status ▾ | submenu | All |
| → Backup Status | `/backup-status` | |
| → RPO Comparison | `/veeam-comparison` | |
| → Ticket Analysis | `/veeam-analysis` | |
| Engagement ▾ | submenu | super-admin |
| → Overview | `/engagement` | |
| → Employee Profile | `/engagement/profile` | |
| Analyzer ▾ | submenu | All |
| → Browse Tickets | `/analyzer/tickets` | |
| → Aggregate Reports | `/analyzer/reports` | |
| → Needs Review | `/analyzer/queue` | |
| → IT Glue — Applications | `/analyzer/itglue/applications` | |
| → IT Glue — Configurations | `/analyzer/itglue/configurations` | |
| Admin | `/admin` | super-admin |
Role gating is hardcoded against `session.user.role`. Hidden items are not
rendered (no greyed-out variants).
`/admin` is the entry tile for everything admin-only — sync schedules, rules,
mappings, RMM Overshell, IT Glue write log, device-link conflicts. Sub-pages
don't appear in the top nav; they're reached from the admin landing grid.
### Mobile
`AppNavigation` returns `null` for any path under `/mobile` — kiosk and field
flows have their own shell. The desktop nav doesn't currently collapse to a
hamburger; under `sm` the brand wordmark hides and the menu items wrap.
Mobile-friendly behavior on small viewports is on the improvements list.
## 5. Components
### shadcn/ui primitives (in `components/ui/`)
| Available | Used heavily | Rare / underused |
|---|---|---|
| `accordion` | | yes (collapsible filters could use it) |
| `alert`, `alert-dialog` | yes (errors, confirmations) | |
| `badge` | yes (status, counts) | |
| `button` | yes | |
| `calendar`, `popover` | | engagement profile |
| `card`, `card-header/title/content` | yes (page sections) | |
| `checkbox`, `switch` | yes | |
| `collapsible` | yes (CI page) | |
| `dialog`, `alert-dialog` | yes (modals) | |
| `dropdown-menu` | yes (actions) | |
| `form` (react-hook-form bridge) | admin/auth only | |
| `input`, `label`, `textarea` | yes | |
| `multi-select` | analyzer filters | |
| `navigation-menu` | top bar only | |
| `progress` | | yes (could replace ad-hoc bars) |
| `scroll-area` | | yes |
| `select` | yes | |
| `separator` | yes | |
| `skeleton` | yes (loading states) | |
| `slider` | rare | |
| `sonner` (toast) | yes — `richColors`, top-right | |
| `table` | **inconsistent** — many pages use raw `<table>` | |
| `tabs` | yes (Engagement, CI, DetailModal) | |
### Feature components
| Path | Purpose |
|---|---|
| `components/navigation/app-navigation.tsx` | Top nav + `PageHeader` |
| `components/admin/DataTable.tsx` | Custom paginated/sortable table (not @tanstack) |
| `components/admin/DetailModal.tsx` | Ticket detail shell, tabs (status/priority maps now in `lib/status-registry.ts`) |
| `components/branding/wulf-mark.tsx` | `<WulfMark />` — W glyph or full wordmark |
| `components/branding/tagline-footer.tsx` | "Don't be afraid to cry · Wulf Consulting" footer line |
| `components/dashboard/kpi-card.tsx` | KpiCard with delta indicator and tonal left border |
| `components/navigation/page-header.tsx` | `<PageHeader />` — title, breadcrumbs, actions; supports `accent` and `watermark` |
| `components/navigation/status-indicator.tsx` | Top-bar StatusLight that links to `/status` |
| `components/ui/empty-state.tsx` | Shared zero-data placeholder |
| `components/ui/status-badge.tsx` | Small rounded pill driven by `lib/status-registry.ts` |
| `components/ui/status-light.tsx` | 8 px square indicator (5 states, 3 sizes) |
| `components/admin/SyncScheduler.tsx` | Schedule editor on `/admin` |
| `components/analyzer/analyze-button.tsx` | Trigger analysis from a ticket |
| `components/analyzer/share-modal.tsx` | Email share dialog |
| `components/analyzer/provider-toggle.tsx` | Anthropic/OpenRouter switch |
| `components/analyzer/related-tickets-panel.tsx` | Bundle members on analysis page |
| `components/analyzer/itglue-suggestions-panel.tsx` | Inline IT Glue cross-refs |
| `components/rmm/rmm-dispatch-dialog.tsx` | Pick + execute RMM script |
| `components/rmm/rmm-script-picker.tsx` | Script registry browser |
| `components/rmm/rmm-execution-stream.tsx` | Live tail of an execution |
| `components/configuration-items/config-item-modal.tsx` | CI detail shell |
Anything not in those two directories — and not a one-off page-local
subcomponent — should probably move there.
## 6. Icons
`lucide-react`, imported per-icon. Conventions:
- Inline with text: `h-4 w-4` (16 px), with `mr-2` if leading.
- Standalone tile/card icons: `h-6 w-6`.
- Admin landing tile icons: `h-8 w-8` plus a muted color (`text-muted-foreground`).
- Color: inherit from text; status icons (`text-orange-600`, `text-green-600`)
only when they carry semantic meaning, not for decoration.
Mixed `size-4` vs `h-4 w-4` exists today. Prefer `h-4 w-4` to match shadcn.
## 7. Tables, modals, forms
### Tables
There are two patterns in the codebase. Pick the leftmost that fits:
1. **`components/admin/DataTable.tsx`** — paginated, sortable, searchable.
Backed by `@tanstack/react-table` v8 in manual mode. Use this for any
list ≥ 25 rows or where users need to filter / sort. Pass
`getRowCanExpand` + `renderSubRow` for drill-down rows.
2. **shadcn `Table`** primitive in `components/ui/table.tsx` — for short
static lists wrapped in a `<Card>` with `<CardContent className="p-0">`.
Raw `<table>` markup is no longer in use anywhere under `app/`. If you find
yourself reaching for it, lift the layout into one of the two primitives.
### Modals & dialogs
- **`Dialog`** — most cases (forms, confirmations, RMM dispatch).
- **`AlertDialog`** — destructive confirmations only.
- **`DetailModal`** — tabbed read-only deep-dive on a ticket. Hardcoded
status/priority color map; if you need that map elsewhere, lift it out
rather than copy.
- A "panel" (e.g., `related-tickets-panel`) is a card laid out like a side
panel — it is not a modal and shouldn't trap focus.
### Forms
- **react-hook-form + Zod resolver** with the shadcn `Form` primitive — used
on auth and admin forms (invites, role edits). Use this for any new form
with ≥ 3 fields or any field that needs validation.
- **Controlled inputs** (`useState` + `onChange`) — acceptable for one-off
filter bars. Don't introduce a third pattern.
Error display today is inconsistent (toast vs inline). New forms should
display field errors inline via `<FormMessage>` and use toasts only for
submit-time outcomes.
## 8. Feedback
- **Toasts**`sonner` configured top-right with `richColors` (`app/layout.tsx`).
Use `toast.success` / `toast.error` / `toast.info`. Don't render error
banners inside the page when a toast fits.
- **Loading**`<Skeleton>` for content placeholders, `<Loader2 />` from
lucide with `animate-spin` for inline button spinners. Don't roll a custom
spinner div.
- **Empty states** — today: a centered `text-muted-foreground` line. Aim:
a small icon (lucide), a one-line headline, an optional CTA. There is no
shared `EmptyState` component yet — adding one is on the improvements list.
## 9. Charts
`recharts`, themed via the `--chart-1``--chart-5` CSS variables (Tailwind
exposes them as `text-chart-1` etc., so colors flip with light/dark
automatically). Common margins `{ top: 5, right: 8, bottom: 24, left: 0 }`.
Examples: `app/dashboard/page.tsx`, `app/engagement/page.tsx`.
## 10. Open improvements (this branch)
The `nav-design-improvements` branch tracks visual/UX cleanup. The list
below is the working backlog; expand as we go.
### Navigation
- [x] ~~Top-nav "Admin" item is a flat link~~ — now a dropdown with the
seven most-used admin pages (Sync, Workflow, RMM Overshell, IT Glue
Writes, Device Conflicts, Users & roles).
- [x] ~~No user menu / avatar / sign-out in the top bar~~`<UserMenu />`
now sits next to `ThemeToggle`. Shows initials, name/email, role
badge, links to `/settings` + `/settings/security`, and sign-out.
- [x] ~~No mobile-collapsed (hamburger) version~~`<MobileNav />` in a
Sheet. Reuses the desktop nav config so IA stays in sync. Desktop nav
hides under `md`.
- [x] ~~Submenu width is fixed~~ — now content-driven via `min-w-[320px]
max-w-[440px]`, single column with denser rows, so 7-item Admin and
5-item Analyzer menus fit without forced two-column awkwardness.
- [x] ~~Active-route highlighting on submenu items~~ — every top-bar item
(flat or trigger) marks active state with a 2 px Wulf-blue underline
that echoes the PageHeader rule. Active children inside dropdowns get
a tinted `bg-primary/10` background.
### Page shell
- [x] ~~Adopt `PageHeader` everywhere~~`/dashboard`, `/status`, all
`/admin/*` sub-pages, `/addigy-devices`, `/analyzer/ticket/[…]`,
`/analyzer/analysis/[…]` now use it. A handful of pages still need
it (kiosk, settings, sentinelone/* — low priority).
- [ ] Standardize container + padding (`container mx-auto px-6 py-6`). Pages
using `px-4`, `py-8`, `max-w-2xl` etc. should justify the deviation.
- [ ] Standardize section spacing (`space-y-6` between cards; pick `gap-6`
for grids).
- [x] ~~Add breadcrumbs to ticket / analysis detail routes~~. Still open
for `/configuration-items/[id]` and `/analyzer/itglue/{applications,
configurations}/[id]`.
### Components
- [x] ~~Build a shared `<EmptyState />`~~`components/ui/empty-state.tsx`.
- [x] ~~Build a shared `<StatusBadge />` that consumes the priority/status maps
from `DetailModal`~~ — registry in `lib/status-registry.ts`,
badge in `components/ui/status-badge.tsx`, DetailModal migrated.
- [x] ~~Extract `PageHeader` and add `accent` + `watermark` props~~
`components/navigation/page-header.tsx`. Adopted on `/dashboard`
and `/status`.
- [x] ~~Replace raw `<table>` markup on admin and Addigy pages with shadcn
`Table`~~ — Addigy, /status, all admin/sync/* pages, backup-status,
engagement/profile, veeam-comparison migrated. Only `/veeam-analysis`
remains (DataTable territory; filter + expand + paginate).
- [x] ~~Replace raw `<input type="checkbox">` on `/addigy-devices`~~
done; also caught one in `/admin/sync/mimecast`.
- [x] ~~/veeam-analysis raw `<table>`~~ — migrated to shadcn `Table`
primitive. The page keeps its own pagination + category filter + row
expansion (DataTable doesn't support row expansion yet, so a deeper
DataTable migration is folded into the long-term decision below).
- [x] ~~Decide on `DataTable` long-term~~ — **migrated to
`@tanstack/react-table` v8** in manual mode. The external API stays
stable (existing data-browser pages keep working without changes);
internally TanStack drives sort + expansion. New optional props for
consumers: `getRowCanExpand` and `renderSubRow` enable
expandable-row patterns (useful for `/veeam-analysis`-style
drill-downs).
### Status & dashboard split (2026-05-03)
- [x] Move integration health + sync health off `/dashboard` onto a
dedicated `/status` route. Top-bar `<StatusIndicator />` links there.
- [x] Rebuild `/dashboard` around KPI cards (today snapshot + needs
attention) and recent activity. Drop the integration / sync
cards.
- [x] ~~Add queue × priority heatmap to `/dashboard`~~`QueueHeatmap`
backed by `/api/dashboard/trends`.
- [x] ~~Add 30-day volume + mean resolution charts~~`VolumeTrend` and
`ResolutionTrend` components, recharts, brand-blue series.
- [x] ~~Add active engineers panel~~`ActiveEngineers` (today's hours
logged, ticket touch count).
- [x] ~~Worker pulse section on `/status`~~ — analyzer / RMM / sync
scheduler heartbeats via `/api/status/workers` and `WorkerPulse`.
### Tokens & theming
- [ ] Audit places that hard-code Tailwind palette colors (`text-orange-600`,
`bg-blue-500/15`) and either keep them as semantic status colors or
move them behind a token.
- [ ] Verify dark-mode contrast on status badges and chart legends; the 10%-
opacity borders in dark mode are subtle and may need lifting.
### Loading & empty
- [x] ~~Standardize Skeleton heights~~ — helpers in
`components/ui/skeleton-helpers.tsx`: `SkeletonRow`, `SkeletonRows`,
`SkeletonCard`, `SkeletonChart`, `SkeletonHeader`, `SkeletonTable`.
- [ ] Adopt the helpers across pages (still scattering `h-12` / `h-24` in
pages built before the helpers landed).
- [ ] Loading shells should match the post-load layout — skeletons inside
Cards, not a single full-width bar.
### Mobile
- [x] ~~CI filter bar overflows on small viewports~~ — company selector
now wraps and shrinks; the stat pill flows below.
- [x] ~~Analyzer multi-select dropdowns clip on narrow widths~~
Popover gets `max-w-[calc(100vw-1rem)]` and `collisionPadding={8}`.
- [ ] Tables horizontally scroll without a sticky first column; consider
responsive card-list fallbacks for narrow screens.
## 11. When in doubt
- Use a token, not a hex value.
- Use the shadcn primitive, not a custom one.
- Match the surrounding page's spacing scale rather than introducing a new
one.
- If the same thing exists in two shapes (e.g., raw vs DataTable), pick the
shape this doc documents and migrate the other.

View file

@ -2,6 +2,23 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AddigyDevice } from '@/lib/types/addigy'; import { AddigyDevice } from '@/lib/types/addigy';
import { PageHeader } from '@/components/navigation/page-header';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { EmptyState } from '@/components/ui/empty-state';
import { StatusBadge } from '@/components/ui/status-badge';
import { Check, X, Laptop, RefreshCw } from 'lucide-react';
export default function AddigyDevicesPage() { export default function AddigyDevicesPage() {
const [devices, setDevices] = useState<AddigyDevice[]>([]); const [devices, setDevices] = useState<AddigyDevice[]>([]);
@ -10,21 +27,19 @@ export default function AddigyDevicesPage() {
const [filterOnline, setFilterOnline] = useState(false); const [filterOnline, setFilterOnline] = useState(false);
useEffect(() => { useEffect(() => {
fetchDevices(); void fetchDevices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterOnline]); }, [filterOnline]);
const fetchDevices = async () => { async function fetchDevices() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const url = filterOnline const url = filterOnline
? '/api/addigy-devices?online=true' ? '/api/addigy-devices?online=true'
: '/api/addigy-devices'; : '/api/addigy-devices';
const res = await fetch(url, { cache: 'no-store' });
const response = await fetch(url); const result = await res.json();
const result = await response.json();
if (result.success) { if (result.success) {
setDevices(result.data); setDevices(result.data);
} else { } else {
@ -35,159 +50,137 @@ export default function AddigyDevicesPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }
return ( return (
<div className="container mx-auto p-6"> <>
<div className="flex justify-between items-center mb-6"> <PageHeader
<h1 className="text-3xl font-bold">Addigy Devices</h1> title="Addigy devices"
<div className="flex items-center gap-4"> description={
<label className="flex items-center gap-2"> loading
<input ? 'Loading…'
type="checkbox" : `${devices.length} device${devices.length === 1 ? '' : 's'}${filterOnline ? ' · online only' : ''}`
checked={filterOnline} }
onChange={(e) => setFilterOnline(e.target.checked)} breadcrumbs={[{ label: 'Addigy devices' }]}
className="w-4 h-4" actions={
/> <>
<span>Online Only</span> <label className="flex items-center gap-2 text-sm cursor-pointer">
</label> <Checkbox
<button checked={filterOnline}
onClick={fetchDevices} onCheckedChange={(v) => setFilterOnline(v === true)}
disabled={loading} aria-label="Filter to online devices only"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" />
> <span>Online only</span>
{loading ? 'Loading...' : 'Refresh'} </label>
</button> <Button onClick={fetchDevices} variant="outline" size="sm" disabled={loading}>
</div> <RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</div> Refresh
</Button>
</>
}
/>
{error && ( <div className="container mx-auto px-6 py-6 space-y-6">
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-4"> {error && (
<strong>Error:</strong> {error} <Alert variant="destructive">
</div> <AlertTitle>Failed to load</AlertTitle>
)} <AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? ( <Card>
<div className="text-center py-12"> <CardContent className="p-0">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> {loading ? (
<p className="mt-4 text-gray-600">Loading devices...</p> <div className="p-6 space-y-2">
</div> <Skeleton className="h-8 w-full" />
) : ( <Skeleton className="h-8 w-full" />
<> <Skeleton className="h-8 w-3/4" />
<div className="mb-4 text-gray-600"> </div>
Found {devices.length} device{devices.length !== 1 ? 's' : ''} ) : devices.length === 0 ? (
</div> <div className="p-6">
<EmptyState
<div className="bg-white shadow-md rounded-lg overflow-hidden"> icon={Laptop}
<div className="overflow-x-auto"> title="No devices found"
<table className="min-w-full divide-y divide-gray-200"> description={
<thead className="bg-gray-50"> filterOnline
<tr> ? 'No devices are currently online.'
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> : 'Addigy has not synced any devices yet.'
Device Name }
</th> size="sm"
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> />
Model </div>
</th> ) : (
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <Table>
OS Version <TableHeader>
</th> <TableRow>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <TableHead>Device</TableHead>
Current User <TableHead>Model</TableHead>
</th> <TableHead>OS</TableHead>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <TableHead>Current user</TableHead>
Status <TableHead>Status</TableHead>
</th> <TableHead className="text-right">Free disk</TableHead>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <TableHead>Security</TableHead>
Free Disk </TableRow>
</th> </TableHeader>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <TableBody>
Security {devices.map((device) => {
</th> const freePct = device['Free Disk Percentage'];
</tr> const freeTone =
</thead> freePct === undefined
<tbody className="bg-white divide-y divide-gray-200"> ? 'text-muted-foreground'
{devices.map((device) => ( : freePct < 20
<tr key={device.agentid} className="hover:bg-gray-50"> ? 'text-destructive'
<td className="px-6 py-4 whitespace-nowrap"> : freePct < 40
<div className="text-sm font-medium text-gray-900"> ? 'text-amber-600 dark:text-amber-400'
{device['Device Name']} : 'text-emerald-600 dark:text-emerald-400';
</div> return (
<div className="text-xs text-gray-500"> <TableRow key={device.agentid}>
{device['Serial Number'] || 'N/A'} <TableCell>
</div> <div className="font-medium">{device['Device Name']}</div>
</td> <div className="text-xs text-muted-foreground num">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> {device['Serial Number'] || '—'}
{device['Device Model Name'] || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['MAC OS X Version'] ||
device['iOS Version'] ||
'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{device['Current User'] || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
device.online
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{device.online ? 'Online' : 'Offline'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{device['Free Disk Percentage'] !== undefined ? (
<div className="flex items-center">
<span
className={`${
device['Free Disk Percentage'] < 20
? 'text-red-600'
: device['Free Disk Percentage'] < 40
? 'text-yellow-600'
: 'text-green-600'
}`}
>
{device['Free Disk Percentage']}%
</span>
</div> </div>
) : ( </TableCell>
'N/A' <TableCell>{device['Device Model Name'] || 'Unknown'}</TableCell>
)} <TableCell className="num">
</td> {device['MAC OS X Version'] || device['iOS Version'] || '—'}
<td className="px-6 py-4 whitespace-nowrap text-sm"> </TableCell>
<div className="flex flex-col gap-1"> <TableCell>{device['Current User'] || '—'}</TableCell>
<span <TableCell>
className={`text-xs ${ <StatusBadge tone={device.online ? 'ok' : 'inactive'}>
device['Firewall Enabled'] {device.online ? 'Online' : 'Offline'}
? 'text-green-600' </StatusBadge>
: 'text-red-600' </TableCell>
}`} <TableCell className={`text-right num ${freeTone}`}>
> {freePct !== undefined ? `${freePct}%` : '—'}
FW: {device['Firewall Enabled'] ? '✓' : '✗'} </TableCell>
</span> <TableCell>
<span <div className="flex items-center gap-3 text-xs">
className={`text-xs ${ <SecurityFlag label="FW" enabled={Boolean(device['Firewall Enabled'])} />
device['FileVault Enabled'] <SecurityFlag label="FV" enabled={Boolean(device['FileVault Enabled'])} />
? 'text-green-600' </div>
: 'text-red-600' </TableCell>
}`} </TableRow>
> );
FV: {device['FileVault Enabled'] ? '✓' : '✗'} })}
</span> </TableBody>
</div> </Table>
</td> )}
</tr> </CardContent>
))} </Card>
</tbody> </div>
</table> </>
</div> );
</div> }
</>
)} function SecurityFlag({ label, enabled }: { label: string; enabled: boolean }) {
</div> return (
<span
className={`inline-flex items-center gap-0.5 ${enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive'}`}
title={enabled ? `${label} enabled` : `${label} disabled`}
>
{enabled ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
<span className="num font-medium">{label}</span>
</span>
); );
} }

View file

@ -1,20 +1,23 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { AuditLogTable } from "@/components/admin/audit/audit-log-table"; import { AuditLogTable } from "@/components/admin/audit/audit-log-table";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { PageHeader } from '@/components/navigation/page-header';
export default function AuditLogPage() { export default function AuditLogPage() {
return ( return (
<div className="container mx-auto py-8 px-4"> <>
<div className="mb-8"> <PageHeader
<h1 className="text-3xl font-bold">Audit Log</h1> title="Audit Log"
<p className="text-muted-foreground mt-2"> description="View system activity and security events"
View system activity and security events breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Audit Log' }]}
</p> accent
/>
<div className="container mx-auto py-8 px-4">
<Suspense fallback={<AuditLogSkeleton />}>
<AuditLogTable />
</Suspense>
</div> </div>
<Suspense fallback={<AuditLogSkeleton />}> </>
<AuditLogTable />
</Suspense>
</div>
); );
} }

View file

@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock, MessageSquare } from 'lucide-react'; import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock, MessageSquare } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/navigation/page-header';
const entities = [ const entities = [
{ name: 'Companies', icon: Users, path: '/admin/data-browser/companies', description: 'View all companies' }, { name: 'Companies', icon: Users, path: '/admin/data-browser/companies', description: 'View all companies' },
@ -23,23 +24,24 @@ const entities = [
export default function DataBrowserPage() { export default function DataBrowserPage() {
return ( return (
<div className="container mx-auto p-6 space-y-6"> <>
<div className="flex items-center gap-4"> <PageHeader
<Link href="/"> title="Database Browser"
<Button variant="outline" size="sm" className="gap-2"> description="Inspect synced data from PostgreSQL"
<ArrowLeft className="w-4 h-4" /> breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Database Browser' }]}
<Home className="w-4 h-4" /> accent
<span className="hidden sm:inline">Back to Dashboard</span> actions={
</Button> <Link href="/">
</Link> <Button variant="outline" size="sm" className="gap-2">
<Database className="w-8 h-8" /> <ArrowLeft className="w-4 h-4" />
<div> <Home className="w-4 h-4" />
<h1 className="text-3xl font-bold">Database Browser</h1> <span className="hidden sm:inline">Back to Dashboard</span>
<p className="text-muted-foreground">Inspect synced data from PostgreSQL</p> </Button>
</div> </Link>
</div> }
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="container mx-auto p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{entities.map((entity) => { {entities.map((entity) => {
const Icon = entity.icon; const Icon = entity.icon;
return ( return (
@ -56,7 +58,8 @@ export default function DataBrowserPage() {
</Link> </Link>
); );
})} })}
</div>
</div> </div>
</div> </>
); );
} }

View file

@ -15,6 +15,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react'; import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface Candidate { interface Candidate {
ciId: string; ciId: string;
@ -105,7 +106,14 @@ export default function DeviceLinkConflictsPage() {
} }
return ( return (
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6"> <>
<PageHeader
title="Device-link conflicts"
description="Cases where the reconciler found two or more configuration_items matching one external device record. Pick the right CI to break the tie."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Device-Link Conflicts' }]}
accent
/>
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@ -248,6 +256,7 @@ export default function DeviceLinkConflictsPage() {
))} ))}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
); );
} }

View file

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PageHeader } from '@/components/navigation/page-header';
interface CompanyCategory { interface CompanyCategory {
value: number; value: number;
@ -287,18 +288,15 @@ export default function DisplaySettingsPage() {
} }
return ( return (
<div className="container mx-auto py-8 px-4 max-w-5xl"> <>
<div className="mb-8"> <PageHeader
<h1 className="text-3xl font-bold flex items-center gap-2"> title="Display Settings"
<SlidersHorizontal className="h-8 w-8" /> description="Configure which companies appear in the Kiosk and Mobile dashboards."
Display Settings breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Display Settings' }]}
</h1> accent
<p className="text-muted-foreground mt-2"> />
Configure which companies appear in the Kiosk and Mobile dashboards. <div className="container mx-auto py-8 px-4 max-w-5xl">
</p> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Section <Section
title="Kiosk" title="Kiosk"
description="Settings for the executive kiosk display." description="Settings for the executive kiosk display."
@ -317,7 +315,8 @@ export default function DisplaySettingsPage() {
companies={companies} companies={companies}
onSaved={handleSaved} onSaved={handleSaved}
/> />
</div>
</div> </div>
</div> </>
); );
} }

View file

@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { PageHeader } from '@/components/navigation/page-header';
interface WriteRow { interface WriteRow {
id: string; id: string;
@ -76,7 +77,14 @@ export default function ItglueWritesPage() {
}, [statusFilter]); }, [statusFilter]);
return ( return (
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6"> <>
<PageHeader
title="IT Glue Writes"
description="Every PATCH to IT Glue from Pulse, with before/after diffs and revert history."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'IT Glue Writes' }]}
accent
/>
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between gap-4 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
@ -163,6 +171,7 @@ export default function ItglueWritesPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
); );
} }

View file

@ -6,6 +6,7 @@ import {
Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle, Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle,
AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight, AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface WebhookConfig { interface WebhookConfig {
id: number; id: number;
@ -210,7 +211,23 @@ export default function MorningSummaryPage() {
} }
return ( return (
<div className="max-w-4xl mx-auto p-6 space-y-8"> <>
<PageHeader
title="Morning NOC Summary"
description="Scheduled 6:30 AM MonFri · Posts to Teams channels via webhook"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Morning NOC Summary' }]}
accent
actions={
<>
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
<Button size="sm" onClick={handleSendAll} disabled={sending}>
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
Send Now
</Button>
</>
}
/>
<div className="max-w-4xl mx-auto p-6 space-y-8">
{/* Toast */} {/* Toast */}
{toast && ( {toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}> <div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${toast.ok ? 'bg-green-500/10 border border-green-500/30 text-green-400' : 'bg-red-500/10 border border-red-500/30 text-red-400'}`}>
@ -219,21 +236,6 @@ export default function MorningSummaryPage() {
</div> </div>
)} )}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> Morning NOC Summary</h1>
<p className="text-sm text-muted-foreground mt-1">Scheduled 6:30 AM MonFri · Posts to Teams channels via webhook</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="h-4 w-4 mr-1" /> Refresh</Button>
<Button size="sm" onClick={handleSendAll} disabled={sending}>
{sending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Send className="h-4 w-4 mr-1" />}
Send Now
</Button>
</div>
</div>
{/* Last Run Stats */} {/* Last Run Stats */}
{latestSummary && ( {latestSummary && (
<div className="rounded-lg border bg-card p-4 space-y-3"> <div className="rounded-lg border bg-card p-4 space-y-3">
@ -462,6 +464,7 @@ export default function MorningSummaryPage() {
</div> </div>
</div> </div>
)} )}
</div> </div>
</>
); );
} }

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/navigation/page-header';
import { import {
RefreshCw, RefreshCw,
Network, Network,
@ -273,15 +274,15 @@ export default function AdminIndexPage() {
]; ];
return ( return (
<div className="container mx-auto px-6 py-6 max-w-7xl space-y-6"> <>
<div> <PageHeader
<h1 className="text-2xl font-bold tracking-tight">Admin</h1> title="Admin"
<p className="text-sm text-muted-foreground mt-1"> description="Sync, mappings, workflows, reporting, and tooling."
Sync, mappings, workflows, reporting, and tooling. breadcrumbs={[{ label: 'Admin' }]}
</p> accent
</div> />
<div className="container mx-auto px-6 py-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sections.map((section) => ( {sections.map((section) => (
<Card key={section.title}> <Card key={section.title}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -315,7 +316,8 @@ export default function AdminIndexPage() {
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div>
</div> </div>
</div> </>
); );
} }

View file

@ -7,6 +7,7 @@ import {
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2, CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
Link2, Link2Off, FileText, CreditCard, Building2, ArrowDownToLine, BarChart3, Link2, Link2Off, FileText, CreditCard, Building2, ArrowDownToLine, BarChart3,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface QboStatus { interface QboStatus {
tokenStatus: 'valid' | 'expired' | 'missing'; tokenStatus: 'valid' | 'expired' | 'missing';
@ -118,19 +119,20 @@ function QboPageInner() {
}[status?.tokenStatus ?? 'missing']; }[status?.tokenStatus ?? 'missing'];
return ( return (
<div className="p-6 max-w-4xl mx-auto space-y-6"> <>
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> title="QuickBooks Online"
<div> description="Sync invoices, payments, deposits, transactions and financial reports"
<h1 className="text-2xl font-bold">QuickBooks Online</h1> breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'QuickBooks Online' }]}
<p className="text-muted-foreground text-sm mt-1">Sync invoices, payments, deposits, transactions and financial reports</p> accent
</div> actions={
<Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}> <Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh Refresh
</Button> </Button>
</div> }
/>
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Banner */} {/* Banner */}
{banner && ( {banner && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${ <div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
@ -233,7 +235,8 @@ function QboPageInner() {
</div> </div>
)} )}
</div> </div>
</div> </div>
</>
); );
} }

View file

@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Loader2, RefreshCw, Terminal } from 'lucide-react'; import { Loader2, RefreshCw, Terminal } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PageHeader } from '@/components/navigation/page-header';
interface Settings { interface Settings {
overshellComponentUid: string | null; overshellComponentUid: string | null;
@ -94,7 +95,14 @@ export default function RmmOvershellAdminPage() {
} }
return ( return (
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6"> <>
<PageHeader
title="RMM Overshell"
description="Datto RMM PowerShell evidence pipeline — settings, executions, and worker activity."
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'RMM Overshell' }]}
accent
/>
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@ -267,6 +275,7 @@ export default function RmmOvershellAdminPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
); );
} }

View file

@ -1,15 +1,18 @@
import { RoleTable } from "@/components/admin/roles/role-table"; import { RoleTable } from "@/components/admin/roles/role-table";
import { PageHeader } from '@/components/navigation/page-header';
export default function RolesPage() { export default function RolesPage() {
return ( return (
<div className="container mx-auto py-8 px-4"> <>
<div className="mb-8"> <PageHeader
<h1 className="text-3xl font-bold">Role Management</h1> title="Role Management"
<p className="text-muted-foreground mt-2"> description="Manage roles and their permissions"
Manage roles and their permissions breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Roles' }]}
</p> accent
/>
<div className="container mx-auto py-8 px-4">
<RoleTable />
</div> </div>
<RoleTable /> </>
</div>
); );
} }

View file

@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PageHeader } from '@/components/navigation/page-header';
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<Record<string, string>>({}); const [settings, setSettings] = useState<Record<string, string>>({});
@ -62,14 +63,14 @@ export default function SettingsPage() {
} }
return ( return (
<div className="container mx-auto py-8 px-4"> <>
<div className="mb-8"> <PageHeader
<h1 className="text-3xl font-bold">Settings</h1> title="Settings"
<p className="text-muted-foreground mt-2"> description="Configure application settings"
Configure application settings breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Settings' }]}
</p> accent
</div> />
<div className="container mx-auto py-8 px-4">
<Tabs defaultValue="microsoft" className="space-y-6"> <Tabs defaultValue="microsoft" className="space-y-6">
<TabsList> <TabsList>
<TabsTrigger value="microsoft">Microsoft</TabsTrigger> <TabsTrigger value="microsoft">Microsoft</TabsTrigger>
@ -168,6 +169,7 @@ export default function SettingsPage() {
)} )}
</Button> </Button>
</div> </div>
</div> </div>
</>
); );
} }

View file

@ -4,6 +4,15 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import { import {
ArrowLeft, Activity, History, Monitor, Loader2, RefreshCw, ArrowLeft, Activity, History, Monitor, Loader2, RefreshCw,
ExternalLink, Server, Wifi, WifiOff, AlertTriangle, Bell, XCircle, CheckCircle2, Clock, ExternalLink, Server, Wifi, WifiOff, AlertTriangle, Bell, XCircle, CheckCircle2, Clock,
@ -108,39 +117,38 @@ function HistoryTab({ refreshKey }: { refreshKey: number }) {
return ( return (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th> <TableHead>Type</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead>Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th> <TableHead className="text-right">Records</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th> <TableHead>Started</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th> <TableHead className="text-right">Duration</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{rows.map((row: any, i: number) => { {rows.map((row: any, i: number) => {
const dur = row.completed_at && row.started_at const dur = row.completed_at && row.started_at
? Math.round((new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 1000) ? Math.round((new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 1000)
: null; : null;
const tone = row.status === 'completed' ? 'ok' : row.status === 'failed' ? 'error' : 'warn';
return ( return (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={i}>
<td className="px-4 py-2 capitalize">{row.sync_type}</td> <TableCell className="capitalize">{row.sync_type}</TableCell>
<td className="px-4 py-2"> <TableCell>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${ <StatusBadge tone={tone}>{row.status}</StatusBadge>
row.status === 'completed' ? 'bg-green-500/15 text-green-700' : </TableCell>
row.status === 'failed' ? 'bg-red-500/15 text-red-600' : <TableCell className="text-right num">{row.records_added ?? 0}</TableCell>
'bg-yellow-500/15 text-yellow-700' <TableCell className="text-muted-foreground num">{fmtDate(row.started_at)}</TableCell>
}`}>{row.status}</span> <TableCell className="text-right text-muted-foreground num">
</td> {dur != null ? `${dur}s` : '—'}
<td className="px-4 py-2 tabular-nums">{row.records_added ?? 0}</td> </TableCell>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td> </TableRow>
<td className="px-4 py-2 text-muted-foreground">{dur != null ? `${dur}s` : '—'}</td>
</tr>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
); );
} }

View file

@ -3,6 +3,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { RefreshCw, ArrowLeft, Loader2, CheckCircle2, AlertTriangle, Shield, Users, Smartphone, ScrollText, Layers, AppWindow, ChevronDown, ChevronUp, ShieldOff, ShieldAlert, ShieldX } from 'lucide-react'; import { RefreshCw, ArrowLeft, Loader2, CheckCircle2, AlertTriangle, Shield, Users, Smartphone, ScrollText, Layers, AppWindow, ChevronDown, ChevronUp, ShieldOff, ShieldAlert, ShieldX } from 'lucide-react';
interface DuoStatus { interface DuoStatus {
@ -226,37 +234,37 @@ export default function DuoSyncPage() {
<div> <div>
<h2 className="text-lg font-semibold mb-3">Child Accounts ({childAccounts.length})</h2> <h2 className="text-lg font-semibold mb-3">Child Accounts ({childAccounts.length})</h2>
<div className="rounded-lg border border-border overflow-hidden"> <div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium">Account Name</th> <TableHead>Account name</TableHead>
<th className="text-right px-4 py-2 font-medium">Users</th> <TableHead className="text-right">Users</TableHead>
<th className="text-right px-4 py-2 font-medium">Integrations</th> <TableHead className="text-right">Integrations</TableHead>
<th className="text-left px-4 py-2 font-medium">Matched Company</th> <TableHead>Matched company</TableHead>
<th className="text-left px-4 py-2 font-medium">Last Sync</th> <TableHead>Last sync</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-border"> <TableBody>
{childAccounts.map(a => ( {childAccounts.map(a => (
<tr key={a.account_id} className="hover:bg-muted/20"> <TableRow key={a.account_id}>
<td className="px-4 py-2 font-medium">{a.name}</td> <TableCell className="font-medium">{a.name}</TableCell>
<td className="px-4 py-2 text-right">{a.user_count}</td> <TableCell className="text-right num">{a.user_count}</TableCell>
<td className="px-4 py-2 text-right">{a.integration_count}</td> <TableCell className="text-right num">{a.integration_count}</TableCell>
<td className="px-4 py-2"> <TableCell>
{a.autotask_company_name ? ( {a.autotask_company_name ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-500" /> <CheckCircle2 className="w-3 h-3 text-emerald-500" />
{a.autotask_company_name} {a.autotask_company_name}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
)} )}
</td> </TableCell>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(a.synced_at)}</td> <TableCell className="text-muted-foreground num">{fmtDate(a.synced_at)}</TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </div>
</div> </div>
@ -294,40 +302,38 @@ function FlaggedUsersTable({ title, description, users, icon, borderColor, bgCol
</div> </div>
<p className="text-xs text-muted-foreground mt-1">{description}</p> <p className="text-xs text-muted-foreground mt-1">{description}</p>
</div> </div>
<div className="overflow-x-auto"> <Table>
<table className="w-full text-sm"> <TableHeader className={bgColor}>
<thead className={bgColor}> <TableRow>
<tr> <TableHead>User</TableHead>
<th className="text-left px-4 py-2 font-medium">User</th> <TableHead>Email</TableHead>
<th className="text-left px-4 py-2 font-medium">Email</th> <TableHead>Account</TableHead>
<th className="text-left px-4 py-2 font-medium">Account</th> <TableHead className="text-center">Enrolled</TableHead>
<th className="text-center px-4 py-2 font-medium">Enrolled</th> <TableHead>Last login</TableHead>
<th className="text-left px-4 py-2 font-medium">Last Login</th> <TableHead>Notes</TableHead>
<th className="text-left px-4 py-2 font-medium">Notes</th> </TableRow>
</tr> </TableHeader>
</thead> <TableBody>
<tbody className="divide-y divide-border"> {users.map(u => (
{users.map(u => ( <TableRow key={u.user_id}>
<tr key={u.user_id} className={`hover:${bgColor}`}> <TableCell>
<td className="px-4 py-2"> <div className="font-medium">{u.realname || u.username}</div>
<div className="font-medium">{u.realname || u.username}</div> {u.realname && <div className="text-xs text-muted-foreground">{u.username}</div>}
{u.realname && <div className="text-xs text-muted-foreground">{u.username}</div>} </TableCell>
</td> <TableCell className="text-muted-foreground">{u.email || '\u2014'}</TableCell>
<td className="px-4 py-2 text-muted-foreground">{u.email || '\u2014'}</td> <TableCell>{u.account_name}</TableCell>
<td className="px-4 py-2">{u.account_name}</td> <TableCell className="text-center">
<td className="px-4 py-2 text-center"> {u.is_enrolled
{u.is_enrolled ? <CheckCircle2 className="w-4 h-4 text-emerald-500 mx-auto" />
? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" /> : <span className="text-muted-foreground">No</span>
: <span className="text-muted-foreground">No</span> }
} </TableCell>
</td> <TableCell className="text-muted-foreground num">{u.last_login ? fmtDate(u.last_login) : 'Never'}</TableCell>
<td className="px-4 py-2 text-muted-foreground">{u.last_login ? fmtDate(u.last_login) : 'Never'}</td> <TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</TableCell>
<td className="px-4 py-2 text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</td> </TableRow>
</tr> ))}
))} </TableBody>
</tbody> </Table>
</table>
</div>
</div> </div>
); );
} }

View file

@ -4,6 +4,15 @@ import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import { import {
ArrowLeft, Activity, History, BookOpen, Loader2, RefreshCw, ArrowLeft, Activity, History, BookOpen, Loader2, RefreshCw,
ExternalLink, CheckCircle2, XCircle, Clock, AlertTriangle, ExternalLink, CheckCircle2, XCircle, Clock, AlertTriangle,
@ -41,17 +50,17 @@ function StatCard({
); );
} }
function StatusBadge({ status }: { status: string }) { function SyncStatusBadge({ status }: { status: string }) {
const cls = const tone =
status === 'completed' ? 'bg-green-500/15 text-green-700' : status === 'completed' ? 'ok' :
status === 'failed' ? 'bg-red-500/15 text-red-600' : status === 'failed' ? 'error' :
status === 'running' ? 'bg-blue-500/15 text-blue-700' : status === 'running' ? 'info' :
'bg-yellow-500/15 text-yellow-700'; 'warn';
return ( return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}> <StatusBadge tone={tone}>
{status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />} {status === 'running' && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
{status} {status}
</span> </StatusBadge>
); );
} }
@ -122,33 +131,33 @@ function StatusTab({
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3"> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Last Sync Breakdown Last Sync Breakdown
{latest.status && <span className="ml-2"><StatusBadge status={latest.status} /></span>} {latest.status && <span className="ml-2"><SyncStatusBadge status={latest.status} /></span>}
</p> </p>
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Entity</th> <TableHead>Entity</TableHead>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th> <TableHead className="text-right">Records</TableHead>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th> <TableHead className="text-right">Duration</TableHead>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead className="text-right">Status</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{latest.entities.map((e: any, i: number) => ( {latest.entities.map((e: any, i: number) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={i}>
<td className="px-4 py-2 font-mono text-xs">{e.entity}</td> <TableCell className="num text-xs">{e.entity}</TableCell>
<td className="px-4 py-2 text-right tabular-nums">{e.recordsUpserted.toLocaleString()}</td> <TableCell className="text-right num">{e.recordsUpserted.toLocaleString()}</TableCell>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(e.duration)}</td> <TableCell className="text-right text-muted-foreground num">{fmtDuration(e.duration)}</TableCell>
<td className="px-4 py-2 text-right"> <TableCell className="text-right">
{e.success {e.success
? <CheckCircle2 className="w-4 h-4 text-green-500 inline" /> ? <CheckCircle2 className="w-4 h-4 text-emerald-500 inline" />
: <span title={e.error}><XCircle className="w-4 h-4 text-red-500 inline" /></span>} : <span title={e.error}><XCircle className="w-4 h-4 text-destructive inline" /></span>}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </div>
)} )}
@ -167,28 +176,28 @@ function HistoryTab({ history }: { history: any[] }) {
return ( return (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead>Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th> <TableHead>Triggered by</TableHead>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th> <TableHead className="text-right">Records</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th> <TableHead>Started</TableHead>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th> <TableHead className="text-right">Duration</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{history.map((row: any, i: number) => ( {history.map((row: any, i: number) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={i}>
<td className="px-4 py-2"><StatusBadge status={row.status} /></td> <TableCell><SyncStatusBadge status={row.status} /></TableCell>
<td className="px-4 py-2 text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</td> <TableCell className="text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</TableCell>
<td className="px-4 py-2 text-right tabular-nums">{(row.total_upserted ?? 0).toLocaleString()}</td> <TableCell className="text-right num">{(row.total_upserted ?? 0).toLocaleString()}</TableCell>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td> <TableCell className="text-muted-foreground num">{fmtDate(row.started_at)}</TableCell>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(row.duration_ms)}</td> <TableCell className="text-right text-muted-foreground num">{fmtDuration(row.duration_ms)}</TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
); );
} }

View file

@ -11,6 +11,16 @@ import {
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole, Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
PauseCircle, Building2, Check, Info, TrendingUp, ExternalLink, Eye, Trash2, PauseCircle, Building2, Check, Info, TrendingUp, ExternalLink, Eye, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import { Checkbox } from '@/components/ui/checkbox';
import SyncScheduler from '@/components/admin/SyncScheduler'; import SyncScheduler from '@/components/admin/SyncScheduler';
function fmtDate(d: string | null | undefined) { function fmtDate(d: string | null | undefined) {
@ -39,32 +49,24 @@ function StatCard({ label, value, sub, icon: Icon, cls }: {
); );
} }
function StatusBadge({ status }: { status: string }) { function MessageStatusBadge({ status }: { status: string }) {
const cls = const tone =
status === 'delivered' ? 'bg-green-500/15 text-green-700' : status === 'delivered' ? 'ok' :
status === 'rejected' ? 'bg-red-500/15 text-red-600' : status === 'rejected' ? 'error' :
status === 'held' ? 'bg-yellow-500/15 text-yellow-700' : status === 'held' ? 'warn' :
status === 'bounced' ? 'bg-orange-500/15 text-orange-700' : status === 'bounced' ? 'warn' :
status === 'spam' ? 'bg-purple-500/15 text-purple-700' : status === 'spam' ? 'accent' :
'bg-muted text-muted-foreground'; 'inactive';
return ( return <StatusBadge tone={tone}>{status || '—'}</StatusBadge>;
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{status || '—'}
</span>
);
} }
function ThreatBadge({ level }: { level: string }) { function ThreatLevelBadge({ level }: { level: string }) {
const cls = const tone =
level === 'high' ? 'bg-red-500/15 text-red-600' : level === 'high' ? 'error' :
level === 'medium' ? 'bg-orange-500/15 text-orange-700' : level === 'medium' ? 'warn' :
level === 'low' ? 'bg-yellow-500/15 text-yellow-700' : level === 'low' ? 'pending' :
'bg-muted text-muted-foreground'; 'inactive';
return ( return <StatusBadge tone={tone}>{level || 'info'}</StatusBadge>;
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{level || 'info'}
</span>
);
} }
// ── Status Tab ──────────────────────────────────────────────────────────────── // ── Status Tab ────────────────────────────────────────────────────────────────
@ -211,30 +213,30 @@ function MessagesTab() {
</div> </div>
) : ( ) : (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">From</th> <TableHead>From</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">To</th> <TableHead>To</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Subject</th> <TableHead>Subject</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Direction</th> <TableHead>Direction</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead>Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Sent</th> <TableHead>Sent</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{rows.map((r: any) => ( {rows.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={r.id}>
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.sender_address}>{r.sender_address ?? '—'}</td> <TableCell className="text-xs truncate max-w-[180px]" title={r.sender_address}>{r.sender_address ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.recipient_address}>{r.recipient_address ?? '—'}</td> <TableCell className="text-xs truncate max-w-[180px]" title={r.recipient_address}>{r.recipient_address ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs truncate max-w-[200px]" title={r.subject}>{r.subject ?? '—'}</td> <TableCell className="text-xs truncate max-w-[200px]" title={r.subject}>{r.subject ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs capitalize text-muted-foreground">{r.direction ?? '—'}</td> <TableCell className="text-xs capitalize text-muted-foreground">{r.direction ?? '—'}</TableCell>
<td className="px-4 py-2"><StatusBadge status={r.status} /></td> <TableCell><MessageStatusBadge status={r.status} /></TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.sent_datetime)}</td> <TableCell className="text-xs text-muted-foreground whitespace-nowrap num">{fmtDate(r.sent_datetime)}</TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
</div> </div>
@ -260,32 +262,32 @@ function ThreatsTab() {
return ( return (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th> <TableHead>Type</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Level</th> <TableHead>Level</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Actor</th> <TableHead>Actor</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Verdict</th> <TableHead>Verdict</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">URL / File</th> <TableHead>URL / File</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">When</th> <TableHead>When</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{rows.map((r: any) => ( {rows.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={r.id}>
<td className="px-4 py-2 text-xs capitalize">{r.event_type ?? '—'}</td> <TableCell className="text-xs capitalize">{r.event_type ?? '—'}</TableCell>
<td className="px-4 py-2"><ThreatBadge level={r.threat_level} /></td> <TableCell><ThreatLevelBadge level={r.threat_level} /></TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{r.actor_email ?? '—'}</td> <TableCell className="text-xs text-muted-foreground">{r.actor_email ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground capitalize">{r.verdict ?? '—'}</td> <TableCell className="text-xs text-muted-foreground capitalize">{r.verdict ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground truncate max-w-[200px]" title={r.url ?? r.file_name ?? ''}> <TableCell className="text-xs text-muted-foreground truncate max-w-[200px]" title={r.url ?? r.file_name ?? ''}>
{r.url ?? r.file_name ?? '—'} {r.url ?? r.file_name ?? '—'}
</td> </TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.event_datetime)}</td> <TableCell className="text-xs text-muted-foreground whitespace-nowrap num">{fmtDate(r.event_datetime)}</TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
); );
} }
@ -420,41 +422,44 @@ function HistoryTab() {
return ( return (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th> <TableHead>Type</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead>Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Messages</th> <TableHead>Messages</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Threats</th> <TableHead>Threats</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th> <TableHead>Started</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th> <TableHead>Duration</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{rows.map((r: any, i: number) => { {rows.map((r: any, i: number) => {
const dur = r.completed_at && r.started_at const dur = r.completed_at && r.started_at
? new Date(r.completed_at).getTime() - new Date(r.started_at).getTime() ? new Date(r.completed_at).getTime() - new Date(r.started_at).getTime()
: null; : null;
const durStr = dur == null ? '—' : dur < 60000 ? `${Math.round(dur / 1000)}s` : `${Math.floor(dur / 60000)}m ${Math.round((dur % 60000) / 1000)}s`; const durStr = dur == null ? '—' : dur < 60000 ? `${Math.round(dur / 1000)}s` : `${Math.floor(dur / 60000)}m ${Math.round((dur % 60000) / 1000)}s`;
const statusCls = r.status === 'completed' ? 'bg-green-500/15 text-green-700' : r.status === 'failed' ? 'bg-red-500/15 text-red-600' : 'bg-muted text-muted-foreground'; const statusTone =
r.status === 'completed' ? 'ok' :
r.status === 'failed' ? 'error' :
'inactive';
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata ?? {}); const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata ?? {});
return ( return (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={i}>
<td className="px-4 py-2 capitalize text-xs">{r.sync_type ?? '—'}</td> <TableCell className="capitalize text-xs">{r.sync_type ?? '—'}</TableCell>
<td className="px-4 py-2"> <TableCell>
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusCls}`}>{r.status}</span> <StatusBadge tone={statusTone}>{r.status}</StatusBadge>
</td> </TableCell>
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.messagesUpserted ?? r.records_added)}</td> <TableCell className="num text-xs">{fmtNum(meta.messagesUpserted ?? r.records_added)}</TableCell>
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.threatsUpserted)}</td> <TableCell className="num text-xs">{fmtNum(meta.threatsUpserted)}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(r.started_at)}</td> <TableCell className="text-xs text-muted-foreground num">{fmtDate(r.started_at)}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{durStr}</td> <TableCell className="text-xs text-muted-foreground num">{durStr}</TableCell>
</tr> </TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
); );
} }
@ -901,74 +906,74 @@ function HeldMailTab() {
: ''} : ''}
</span> </span>
</div> </div>
<div className="overflow-x-auto"> <Table className="table-fixed">
<table className="w-full text-sm table-fixed"> <TableHeader className="bg-muted/30">
<thead className="bg-muted/30"> <TableRow>
<tr> <TableHead style={{width:'120px'}} className="text-xs">Date</TableHead>
<th style={{width:'120px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th> <TableHead style={{width:'160px'}} className="text-xs">To</TableHead>
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">To</th> <TableHead style={{width:'180px'}} className="text-xs">From</TableHead>
<th style={{width:'180px'}} className="text-left px-3 py-2 font-medium text-xs">From</th> <TableHead className="text-xs">Subject</TableHead>
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th> <TableHead style={{width:'160px'}} className="text-xs">Policy</TableHead>
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">Policy</th> <TableHead style={{width:'160px'}}></TableHead>
<th style={{width:'160px'}} className="px-3 py-2"></th> </TableRow>
</tr> </TableHeader>
</thead> <TableBody>
<tbody className="divide-y divide-border"> {filtered.map((m: any) => (
{filtered.map((m: any) => ( <TableRow key={m.id}>
<tr key={m.id} className="hover:bg-muted/20"> <TableCell className="text-muted-foreground whitespace-nowrap text-xs num">
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs"> {new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} </TableCell>
</td> <TableCell className="text-xs" style={{overflow:'hidden'}}>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}> <div className="truncate">{m.to}</div>
<div className="truncate">{m.to}</div> </TableCell>
</td> <TableCell style={{overflow:'hidden'}}>
<td className="px-3 py-2" style={{overflow:'hidden'}}> <div className="font-medium text-xs truncate">{m.fromDisplay || m.from}</div>
<div className="font-medium text-xs truncate">{m.fromDisplay || m.from}</div> {m.fromDisplay && <div className="text-xs text-muted-foreground truncate">{m.from}</div>}
{m.fromDisplay && <div className="text-xs text-muted-foreground truncate">{m.from}</div>} </TableCell>
</td> <TableCell className="text-xs" style={{overflow:'hidden'}}>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}> <div className="truncate">{m.subject || '(no subject)'}</div>
<div className="truncate">{m.subject || '(no subject)'}</div> </TableCell>
</td> <TableCell style={{overflow:'hidden'}}>
<td className="px-3 py-2" style={{overflow:'hidden'}}> <StatusBadge
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ tone={
m.policyInfo?.includes('DMARC') || m.policyInfo?.includes('Impersonation') m.policyInfo?.includes('DMARC') || m.policyInfo?.includes('Impersonation')
? 'bg-red-500/10 text-red-600' ? 'error'
: 'bg-muted text-muted-foreground' : 'inactive'
}`}> }
{m.policyInfo || m.reason || '—'} >
</span> {m.policyInfo || m.reason || '—'}
</td> </StatusBadge>
<td className="px-3 py-2"> </TableCell>
<div className="flex items-center gap-1 justify-end"> <TableCell>
<Button <div className="flex items-center gap-1 justify-end">
size="sm" <Button
variant="ghost" size="sm"
className="h-7 text-xs px-2 whitespace-nowrap" variant="ghost"
onClick={() => setAnalysisMessage(m)} className="h-7 text-xs px-2 whitespace-nowrap"
> onClick={() => setAnalysisMessage(m)}
<Info className="w-3 h-3 mr-1" /> >
Analyze <Info className="w-3 h-3 mr-1" />
</Button> Analyze
<Button </Button>
size="sm" <Button
variant="outline" size="sm"
className="h-7 text-xs px-2 whitespace-nowrap text-green-700 border-green-300 hover:bg-green-50 dark:hover:bg-green-950/20" variant="outline"
disabled={releasing[m.id]} className="h-7 text-xs px-2 whitespace-nowrap text-green-700 border-green-300 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={() => release(m)} disabled={releasing[m.id]}
> onClick={() => release(m)}
{releasing[m.id] ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null} >
Release {releasing[m.id] ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
</Button> Release
</div> </Button>
{releaseErrors[m.id] && ( </div>
<div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div> {releaseErrors[m.id] && (
)} <div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div>
</td> )}
</tr> </TableCell>
))} </TableRow>
</tbody> ))}
</table> </TableBody>
</div> </Table>
</div> </div>
)} )}
@ -1385,13 +1390,15 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages
<div className="max-h-40 overflow-y-auto divide-y"> <div className="max-h-40 overflow-y-auto divide-y">
{remedMatches.map(m => ( {remedMatches.map(m => (
<label key={m.id} className="flex items-start gap-2 px-3 py-1.5 hover:bg-muted/20 cursor-pointer"> <label key={m.id} className="flex items-start gap-2 px-3 py-1.5 hover:bg-muted/20 cursor-pointer">
<input type="checkbox" className="mt-0.5 flex-shrink-0" <Checkbox
className="mt-0.5 flex-shrink-0"
checked={remedSelected.has(m.id)} checked={remedSelected.has(m.id)}
onChange={e => { onCheckedChange={(v) => {
const s = new Set(remedSelected); const s = new Set(remedSelected);
e.target.checked ? s.add(m.id) : s.delete(m.id); if (v === true) s.add(m.id); else s.delete(m.id);
setRemedSelected(s); setRemedSelected(s);
}} /> }}
/>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs truncate">{m.subject || '(no subject)'}</div> <div className="text-xs truncate">{m.subject || '(no subject)'}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@ -1808,68 +1815,70 @@ function DeliveredMailTab() {
{loaded && !loading && filtered.length > 0 && ( {loaded && !loading && filtered.length > 0 && (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto"> <Table className="table-fixed">
<table className="w-full text-sm table-fixed"> <TableHeader className="bg-muted/30">
<thead className="bg-muted/30"> <TableRow>
<tr> <TableHead style={{width:'110px'}} className="text-xs">Date</TableHead>
<th style={{width:'110px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th> <TableHead style={{width:'150px'}} className="text-xs">To</TableHead>
<th style={{width:'150px'}} className="text-left px-3 py-2 font-medium text-xs">To</th> <TableHead style={{width:'170px'}} className="text-xs">From</TableHead>
<th style={{width:'170px'}} className="text-left px-3 py-2 font-medium text-xs">From</th> <TableHead className="text-xs">Subject</TableHead>
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th> <TableHead style={{width:'90px'}} className="text-xs">Status</TableHead>
<th style={{width:'90px'}} className="text-left px-3 py-2 font-medium text-xs">Status</th> <TableHead style={{width:'80px'}} className="text-xs">Spam</TableHead>
<th style={{width:'80px'}} className="text-left px-3 py-2 font-medium text-xs">Spam</th> <TableHead style={{width:'90px'}}></TableHead>
<th style={{width:'90px'}} className="px-3 py-2"></th> </TableRow>
</tr> </TableHeader>
</thead> <TableBody>
<tbody className="divide-y divide-border"> {filtered.map((m: any) => (
{filtered.map((m: any) => ( <TableRow key={m.id} className={
<tr key={m.id} className={`hover:bg-muted/20 ${ m.spamScore >= 10 || detectSubjectThreat(m.subject) !== null
m.spamScore >= 10 || detectSubjectThreat(m.subject) !== null ? 'bg-red-500/5'
? 'bg-red-500/5' : m.spamScore >= 5
: m.spamScore >= 5 ? 'bg-amber-500/5'
? 'bg-amber-500/5' : ''
: '' }>
}`}> <TableCell className="text-muted-foreground whitespace-nowrap text-xs num">
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs"> {new Date(m.received).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{new Date(m.received).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} </TableCell>
</td> <TableCell className="text-xs" style={{overflow:'hidden'}}>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}> <div className="truncate">{m.to}</div>
<div className="truncate">{m.to}</div> </TableCell>
</td> <TableCell style={{overflow:'hidden'}}>
<td className="px-3 py-2" style={{overflow:'hidden'}}> <div className="text-xs truncate font-medium">{m.from}</div>
<div className="text-xs truncate font-medium">{m.from}</div> {m.fromEnv && m.fromEnv !== m.from && (
{m.fromEnv && m.fromEnv !== m.from && ( <div className="text-xs text-muted-foreground truncate">{m.fromEnv}</div>
<div className="text-xs text-muted-foreground truncate">{m.fromEnv}</div> )}
)} </TableCell>
</td> <TableCell className="text-xs" style={{overflow:'hidden'}}>
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}> <div className="truncate">{m.subject || '(no subject)'}</div>
<div className="truncate">{m.subject || '(no subject)'}</div> </TableCell>
</td> <TableCell>
<td className="px-3 py-2"> <StatusBadge
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${ tone={
m.status === 'accepted' ? 'bg-green-500/10 text-green-700' m.status === 'accepted' ? 'ok' :
: m.status === 'held' ? 'bg-amber-500/10 text-amber-700' m.status === 'held' ? 'warn' :
: m.status === 'rejected' || m.status === 'bounced' ? 'bg-red-500/10 text-red-600' m.status === 'rejected' || m.status === 'bounced' ? 'error' :
: 'bg-muted text-muted-foreground' 'inactive'
}`}>{m.status}</span> }
</td> >
<td className="px-3 py-2"> {m.status}
<span className={`text-xs font-semibold ${m.spamScore >= 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}> </StatusBadge>
{m.spamScore} </TableCell>
</span> <TableCell>
</td> <span className={`text-xs font-semibold num ${m.spamScore >= 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}>
<td className="px-3 py-2"> {m.spamScore}
<Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap" </span>
onClick={() => setAnalysisMessage(m)}> </TableCell>
<Eye className="w-3 h-3 mr-1" /> <TableCell>
View <Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap"
</Button> onClick={() => setAnalysisMessage(m)}>
</td> <Eye className="w-3 h-3 mr-1" />
</tr> View
))} </Button>
</tbody> </TableCell>
</table> </TableRow>
</div> ))}
</TableBody>
</Table>
</div> </div>
)} )}

View file

@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { RefreshCw, CheckCircle2, XCircle, AlertTriangle, Clock, Loader2, ChevronRight } from 'lucide-react'; import { RefreshCw, CheckCircle2, XCircle, AlertTriangle, Clock, Loader2, ChevronRight } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface IntegrationCard { interface IntegrationCard {
id: string; id: string;
@ -222,18 +223,20 @@ export default function SyncOverviewPage() {
}; };
return ( return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6"> <>
<div className="flex items-center justify-between"> <PageHeader
<div> title="Integrations & Sync"
<h1 className="text-2xl md:text-3xl font-bold">Integrations & Sync</h1> description="Manage data sync across all connected platforms"
<p className="text-sm text-muted-foreground mt-0.5">Manage data sync across all connected platforms</p> breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Integrations & Sync' }]}
</div> accent
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2"> actions={
<RefreshCw className="w-4 h-4" /> <Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
Refresh <RefreshCw className="w-4 h-4" />
</Button> Refresh
</div> </Button>
}
/>
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
@ -423,6 +426,7 @@ export default function SyncOverviewPage() {
})} })}
</div> </div>
)} )}
</div> </div>
</>
); );
} }

View file

@ -9,6 +9,15 @@ import {
CheckCircle2, XCircle, AlertTriangle, Clock, Server, HardDrive, CheckCircle2, XCircle, AlertTriangle, Clock, Server, HardDrive,
Bot, Bell, ChevronDown, ChevronRight, Target, Play, Bot, Bell, ChevronDown, ChevronRight, Target, Play,
} from 'lucide-react'; } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import SyncScheduler from '@/components/admin/SyncScheduler'; import SyncScheduler from '@/components/admin/SyncScheduler';
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@ -35,13 +44,13 @@ function StatCard({ label, value, sub, icon: Icon, cls }: {
); );
} }
function StatusBadge({ status }: { status: string }) { function SyncStatusBadge({ status }: { status: string }) {
const cls = const tone =
status === 'completed' ? 'bg-green-500/15 text-green-700' : status === 'completed' ? 'ok' :
status === 'failed' ? 'bg-red-500/15 text-red-600' : status === 'failed' ? 'error' :
status === 'started' ? 'bg-blue-500/15 text-blue-700' : status === 'started' ? 'info' :
'bg-yellow-500/15 text-yellow-700'; 'warn';
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>{status}</span>; return <StatusBadge tone={tone}>{status}</StatusBadge>;
} }
// ── Status Tab ──────────────────────────────────────────────────────────────── // ── Status Tab ────────────────────────────────────────────────────────────────
@ -135,11 +144,11 @@ function HistoryRow({ row }: { row: any }) {
return ( return (
<> <>
<tr <TableRow
className={`border-b hover:bg-muted/30 ${entities.length > 0 ? 'cursor-pointer' : ''}`} className={entities.length > 0 ? 'cursor-pointer' : ''}
onClick={() => entities.length > 0 && setExpanded(e => !e)} onClick={() => entities.length > 0 && setExpanded(e => !e)}
> >
<td className="px-4 py-2.5"> <TableCell>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{entities.length > 0 {entities.length > 0
? (expanded ? (expanded
@ -148,16 +157,16 @@ function HistoryRow({ row }: { row: any }) {
: <span className="w-3.5" />} : <span className="w-3.5" />}
<span className="capitalize">{row.sync_type}</span> <span className="capitalize">{row.sync_type}</span>
</div> </div>
</td> </TableCell>
<td className="px-4 py-2.5"><StatusBadge status={row.status} /></td> <TableCell><SyncStatusBadge status={row.status} /></TableCell>
<td className="px-4 py-2.5 tabular-nums font-medium">{(row.records_added ?? 0).toLocaleString()}</td> <TableCell className="num font-medium">{(row.records_added ?? 0).toLocaleString()}</TableCell>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{fmtDate(row.started_at)}</td> <TableCell className="text-muted-foreground text-xs num">{fmtDate(row.started_at)}</TableCell>
<td className="px-4 py-2.5 text-muted-foreground">{dur != null ? fmtDur(dur) : '—'}</td> <TableCell className="text-muted-foreground num">{dur != null ? fmtDur(dur) : '—'}</TableCell>
<td className="px-4 py-2.5 text-muted-foreground capitalize">{row.triggered_by ?? '—'}</td> <TableCell className="text-muted-foreground capitalize">{row.triggered_by ?? '—'}</TableCell>
</tr> </TableRow>
{expanded && entities.length > 0 && ( {expanded && entities.length > 0 && (
<tr className="border-b bg-muted/20"> <TableRow className="bg-muted/20">
<td colSpan={6} className="px-8 py-3"> <TableCell colSpan={6} className="px-8 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">Entity Breakdown</p> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">Entity Breakdown</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{entities.map((e) => ( {entities.map((e) => (
@ -176,8 +185,8 @@ function HistoryRow({ row }: { row: any }) {
{row.error_message} {row.error_message}
</div> </div>
)} )}
</td> </TableCell>
</tr> </TableRow>
)} )}
</> </>
); );
@ -201,21 +210,21 @@ function VeeamHistoryTab({ refreshKey }: { refreshKey: number }) {
return ( return (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th> <TableHead>Type</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead>Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th> <TableHead>Records</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th> <TableHead>Started</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th> <TableHead>Duration</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th> <TableHead>Triggered By</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{rows.map((row, i) => <HistoryRow key={i} row={row} />)} {rows.map((row, i) => <HistoryRow key={i} row={row} />)}
</tbody> </TableBody>
</table> </Table>
</div> </div>
); );
} }
@ -253,45 +262,49 @@ function AgentsTab({ refreshKey }: { refreshKey: number }) {
</div> </div>
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Name</th> <TableHead>Name</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th> <TableHead>Organization</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Platform</th> <TableHead>Platform</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead>Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Agent Status</th> <TableHead>Agent Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Version</th> <TableHead>Version</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Mode</th> <TableHead>Mode</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Jobs</th> <TableHead>Jobs</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{agents.map((a: any) => ( {agents.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={a.instance_uid}>
<td className="px-4 py-2 font-medium">{a.name}</td> <TableCell className="font-medium">{a.name}</TableCell>
<td className="px-4 py-2 text-muted-foreground text-xs">{a.organization_name ?? '—'}</td> <TableCell className="text-muted-foreground text-xs">{a.organization_name ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs">{a.agent_platform ?? '—'}</td> <TableCell className="text-xs">{a.agent_platform ?? '—'}</TableCell>
<td className="px-4 py-2"> <TableCell>
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${ <StatusBadge tone={a.status === 'Active' ? 'ok' : 'inactive'}>
a.status === 'Active' ? 'bg-green-500/15 text-green-700' : 'bg-muted text-muted-foreground' {a.status ?? '—'}
}`}>{a.status ?? '—'}</span> </StatusBadge>
</td> </TableCell>
<td className="px-4 py-2"> <TableCell>
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${ <StatusBadge
a.management_agent_status === 'Inaccessible' ? 'bg-red-500/15 text-red-600' : tone={
a.management_agent_status === 'Accessible' ? 'bg-green-500/15 text-green-700' : a.management_agent_status === 'Inaccessible' ? 'error' :
'bg-muted text-muted-foreground' a.management_agent_status === 'Accessible' ? 'ok' :
}`}>{a.management_agent_status ?? '—'}</span> 'inactive'
</td> }
<td className="px-4 py-2 text-xs"> >
{a.management_agent_status ?? '—'}
</StatusBadge>
</TableCell>
<TableCell className="text-xs">
<span className={a.version_status === 'Outdated' ? 'text-yellow-700 font-medium' : 'text-muted-foreground'}> <span className={a.version_status === 'Outdated' ? 'text-yellow-700 font-medium' : 'text-muted-foreground'}>
{a.version ?? '—'} {a.version ?? '—'}
{a.version_status === 'Outdated' && ' ⚠'} {a.version_status === 'Outdated' && ' ⚠'}
</span> </span>
</td> </TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.operation_mode ?? '—'}</td> <TableCell className="text-xs text-muted-foreground">{a.operation_mode ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs tabular-nums"> <TableCell className="text-xs num">
<span className="text-green-700">{a.success_jobs_count ?? 0}</span> <span className="text-green-700">{a.success_jobs_count ?? 0}</span>
{(a.running_jobs_count ?? 0) > 0 && <span className="text-blue-600 ml-1">{a.running_jobs_count}</span>} {(a.running_jobs_count ?? 0) > 0 && <span className="text-blue-600 ml-1">{a.running_jobs_count}</span>}
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0) > 0 && ( {(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0) > 0 && (
@ -299,11 +312,11 @@ function AgentsTab({ refreshKey }: { refreshKey: number }) {
{(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0)} {(a.total_jobs_count ?? 0) - (a.success_jobs_count ?? 0) - (a.running_jobs_count ?? 0)}
</span> </span>
)} )}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </div>
); );
@ -342,41 +355,45 @@ function AlarmsTab({ refreshKey }: { refreshKey: number }) {
</div> </div>
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Object</th> <TableHead>Object</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th> <TableHead>Type</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th> <TableHead>Organization</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th> <TableHead>Status</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Repeats</th> <TableHead>Repeats</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Activation</th> <TableHead>Last Activation</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Message</th> <TableHead>Message</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{alarms.map((a: any) => ( {alarms.map((a: any) => (
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={a.instance_uid}>
<td className="px-4 py-2 font-medium">{a.object_computer_name || a.object_name || '—'}</td> <TableCell className="font-medium">{a.object_computer_name || a.object_name || '—'}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.object_type ?? '—'}</td> <TableCell className="text-xs text-muted-foreground">{a.object_type ?? '—'}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{a.organization_name ?? '—'}</td> <TableCell className="text-xs text-muted-foreground">{a.organization_name ?? '—'}</TableCell>
<td className="px-4 py-2"> <TableCell>
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${ <StatusBadge
a.last_activation_status === 'Active' ? 'bg-red-500/15 text-red-600' : tone={
a.last_activation_status === 'Warning' ? 'bg-yellow-500/15 text-yellow-700' : a.last_activation_status === 'Active' ? 'error' :
a.last_activation_status === 'Resolved' ? 'bg-green-500/15 text-green-700' : a.last_activation_status === 'Warning' ? 'warn' :
'bg-muted text-muted-foreground' a.last_activation_status === 'Resolved' ? 'ok' :
}`}>{a.last_activation_status ?? '—'}</span> 'inactive'
</td> }
<td className="px-4 py-2 tabular-nums text-xs">{a.repeat_count ?? 0}</td> >
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(a.last_activation_time)}</td> {a.last_activation_status ?? '—'}
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={a.last_activation_message ?? ''}> </StatusBadge>
</TableCell>
<TableCell className="num text-xs">{a.repeat_count ?? 0}</TableCell>
<TableCell className="text-xs text-muted-foreground num">{fmtDate(a.last_activation_time)}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-xs truncate" title={a.last_activation_message ?? ''}>
{a.last_activation_message?.trim() || '—'} {a.last_activation_message?.trim() || '—'}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </div>
); );
@ -463,47 +480,48 @@ function RpoTab({ refreshKey }: { refreshKey: number }) {
<XCircle className="w-4 h-4 text-red-600" /> <XCircle className="w-4 h-4 text-red-600" />
<p className="text-xs font-semibold uppercase tracking-wider text-red-700">RPO Breached ({breachedJobs.length})</p> <p className="text-xs font-semibold uppercase tracking-wider text-red-700">RPO Breached ({breachedJobs.length})</p>
</div> </div>
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th> <TableHead>Job</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th> <TableHead>Organization</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th> <TableHead>Last Backup</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Overdue</th> <TableHead>Overdue</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Failure Reason</th> <TableHead>Failure Reason</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Ticket</th> <TableHead>Ticket</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{breachedJobs.map((j: any) => { {breachedJobs.map((j: any) => {
const hrs = j.hours_since_backup; const hrs = j.hours_since_backup;
const display = hrs === null ? 'Never' : hrs >= 48 ? `${Math.round(hrs / 24)}d` : `${Math.round(hrs)}h`; const display = hrs === null ? 'Never' : hrs >= 48 ? `${Math.round(hrs / 24)}d` : `${Math.round(hrs)}h`;
const ticketPriCls = j.open_ticket?.priority_level === 'critical' ? 'bg-red-500/15 text-red-700' const ticketTone =
: j.open_ticket?.priority_level === 'high' ? 'bg-orange-500/15 text-orange-700' j.open_ticket?.priority_level === 'critical' ? 'error' :
: 'bg-yellow-500/15 text-yellow-700'; j.open_ticket?.priority_level === 'high' ? 'warn' :
'pending';
return ( return (
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={j.job_instance_uid}>
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td> <TableCell className="font-medium text-xs">{j.job_name}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td> <TableCell className="text-xs text-muted-foreground">{j.org_name}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td> <TableCell className="text-xs text-muted-foreground num">{fmtDate(j.last_end_time)}</TableCell>
<td className="px-4 py-2 tabular-nums text-xs font-semibold text-red-600">{display}</td> <TableCell className="num text-xs font-semibold text-red-600">{display}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={j.failure_category ?? ''}> <TableCell className="text-xs text-muted-foreground max-w-xs truncate" title={j.failure_category ?? ''}>
{j.failure_category ?? '—'} {j.failure_category ?? '—'}
</td> </TableCell>
<td className="px-4 py-2 text-xs"> <TableCell className="text-xs">
{j.open_ticket ? ( {j.open_ticket ? (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${ticketPriCls}`}> <StatusBadge tone={ticketTone}>
{j.open_ticket.at_ticket_number} · {j.open_ticket.priority_level} {j.open_ticket.at_ticket_number} · {j.open_ticket.priority_level}
</span> </StatusBadge>
) : ( ) : (
<span className="text-muted-foreground">No ticket yet</span> <span className="text-muted-foreground">No ticket yet</span>
)} )}
</td> </TableCell>
</tr> </TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
@ -512,30 +530,30 @@ function RpoTab({ refreshKey }: { refreshKey: number }) {
<summary className="px-4 py-2.5 bg-green-500/5 border-b cursor-pointer flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-green-700"> <summary className="px-4 py-2.5 bg-green-500/5 border-b cursor-pointer flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-green-700">
<CheckCircle2 className="w-4 h-4" />Within RPO ({healthyJobs.length}) <CheckCircle2 className="w-4 h-4" />Within RPO ({healthyJobs.length})
</summary> </summary>
<table className="w-full text-sm"> <Table>
<thead className="bg-muted/50 border-b"> <TableHeader className="bg-muted/50">
<tr> <TableRow>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th> <TableHead>Job</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th> <TableHead>Organization</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th> <TableHead>Last Backup</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Hours Ago</th> <TableHead>Hours Ago</TableHead>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">RPO</th> <TableHead>RPO</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{healthyJobs.map((j: any) => ( {healthyJobs.map((j: any) => (
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={j.job_instance_uid}>
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td> <TableCell className="font-medium text-xs">{j.job_name}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td> <TableCell className="text-xs text-muted-foreground">{j.org_name}</TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td> <TableCell className="text-xs text-muted-foreground num">{fmtDate(j.last_end_time)}</TableCell>
<td className="px-4 py-2 tabular-nums text-xs text-green-700"> <TableCell className="num text-xs text-green-700">
{j.hours_since_backup !== null ? `${j.hours_since_backup}h` : '—'} {j.hours_since_backup !== null ? `${j.hours_since_backup}h` : '—'}
</td> </TableCell>
<td className="px-4 py-2 text-xs text-muted-foreground">{j.rpo_hours}h</td> <TableCell className="text-xs text-muted-foreground num">{j.rpo_hours}h</TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</details> </details>
)} )}
</div> </div>

View file

@ -7,6 +7,7 @@ import {
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain, Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink, Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '@/components/navigation/page-header';
interface DigestConfig { interface DigestConfig {
daily_enabled: boolean; daily_enabled: boolean;
@ -172,7 +173,19 @@ export default function TicketDigestPage() {
); );
return ( return (
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8"> <>
<PageHeader
title="Ticket Digest Reports"
description="LLM-analyzed ticket reports delivered to Teams — daily, weekly, and monthly"
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Ticket Digest Reports' }]}
accent
actions={
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
}
/>
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
{/* Toast */} {/* Toast */}
{toast && ( {toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}> <div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}>
@ -180,21 +193,6 @@ export default function TicketDigestPage() {
</div> </div>
)} )}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BarChart3 className="h-6 w-6" /> Ticket Digest Reports
</h1>
<p className="text-muted-foreground text-sm mt-1">
LLM-analyzed ticket reports delivered to Teams daily, weekly, and monthly
</p>
</div>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
</div>
{/* Generate Reports */} {/* Generate Reports */}
<div className="border rounded-lg p-5 space-y-4"> <div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2"> <h2 className="font-semibold text-lg flex items-center gap-2">
@ -451,6 +449,7 @@ export default function TicketDigestPage() {
</div> </div>
)} )}
</div> </div>
</div> </div>
</>
); );
} }

View file

@ -1,20 +1,23 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { UserTable } from "@/components/admin/users/user-table"; import { UserTable } from "@/components/admin/users/user-table";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { PageHeader } from '@/components/navigation/page-header';
export default function UsersPage() { export default function UsersPage() {
return ( return (
<div className="container mx-auto py-8 px-4"> <>
<div className="mb-8"> <PageHeader
<h1 className="text-3xl font-bold">User Management</h1> title="User Management"
<p className="text-muted-foreground mt-2"> description="Manage users, roles, and permissions"
Manage users, roles, and permissions breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Users' }]}
</p> accent
/>
<div className="container mx-auto py-8 px-4">
<Suspense fallback={<UserTableSkeleton />}>
<UserTable />
</Suspense>
</div> </div>
<Suspense fallback={<UserTableSkeleton />}> </>
<UserTable />
</Suspense>
</div>
); );
} }

View file

@ -18,6 +18,7 @@ import {
PauseCircle, PauseCircle,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PageHeader } from '@/components/navigation/page-header';
interface TicketWorkflow { interface TicketWorkflow {
id: number; id: number;
@ -109,25 +110,22 @@ export default function WorkflowListPage() {
}; };
return ( return (
<div className="container mx-auto p-6 space-y-6"> <>
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> title="Ticket Workflows"
<div className="flex items-center gap-3"> description="Automated ticket triage and classification"
<Workflow className="w-6 h-6" /> breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Ticket Workflows' }]}
<div> accent
<h1 className="text-2xl font-bold">Ticket Workflows</h1> actions={
<p className="text-sm text-muted-foreground">Automated ticket triage and classification</p> <Link href="/admin/workflow/create">
</div> <Button>
</div> <Plus className="w-4 h-4 mr-2" />
Create Workflow
<Link href="/admin/workflow/create"> </Button>
<Button> </Link>
<Plus className="w-4 h-4 mr-2" /> }
Create Workflow />
</Button> <div className="container mx-auto p-6 space-y-6">
</Link>
</div>
{/* Master Control */} {/* Master Control */}
<Card className={globalEnabled ? 'border-green-500/50' : 'border-gray-300'}> <Card className={globalEnabled ? 'border-green-500/50' : 'border-gray-300'}>
<CardHeader> <CardHeader>
@ -284,6 +282,7 @@ export default function WorkflowListPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
); );
} }

View file

@ -50,6 +50,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { HostManager } from '@/components/zabbix/host-manager'; import { HostManager } from '@/components/zabbix/host-manager';
import { PageHeader } from '@/components/navigation/page-header';
type SyncMode = 'all' | 'client' | 'site'; type SyncMode = 'all' | 'client' | 'site';
@ -433,19 +434,14 @@ export default function ZabbixWanPage() {
}; };
return ( return (
<div className="container mx-auto py-8 max-w-6xl space-y-6"> <>
{/* Header */} <PageHeader
<div className="flex items-center gap-3"> title="Zabbix WAN Monitor Setup"
<div> description="Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing"
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2"> breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Zabbix WAN' }]}
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup accent
</h1> />
<p className="text-sm text-muted-foreground mt-0.5"> <div className="container mx-auto py-8 max-w-6xl space-y-6">
Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing
</p>
</div>
</div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 border-b"> <div className="flex gap-1 border-b">
{([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => ( {([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => (
@ -1422,6 +1418,7 @@ export default function ZabbixWanPage() {
)} )}
</div> </div>
)} )}
</div> </div>
</>
); );
} }

View file

@ -6,6 +6,7 @@ import { AnalysisView } from '@/components/analyzer/analysis-view';
import { ItglueSuggestionsPanel } from '@/components/analyzer/itglue-suggestions-panel'; import { ItglueSuggestionsPanel } from '@/components/analyzer/itglue-suggestions-panel';
import type { PersistedAnalysis } from '@/lib/types/analyzer'; import type { PersistedAnalysis } from '@/lib/types/analyzer';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { PageHeader } from '@/components/navigation/page-header';
export default function AnalysisDetailPage({ export default function AnalysisDetailPage({
params, params,
@ -36,27 +37,42 @@ export default function AnalysisDetailPage({
}; };
}, [id]); }, [id]);
const ticketNumber = analysis?.ticketNumber;
return ( return (
<div className="container mx-auto px-6 py-6 max-w-5xl"> <>
{error && ( <PageHeader
<Alert variant="destructive"> title={analysis?.ticketNumber ? `Analysis · ${analysis.ticketNumber}` : 'Analysis'}
<AlertTitle>Couldn&rsquo;t load this analysis</AlertTitle> description={analysis?.id && `id ${analysis.id.slice(0, 8)}`}
<AlertDescription>{error}</AlertDescription> breadcrumbs={[
</Alert> { label: 'Analyzer', href: '/analyzer/tickets' },
)} ...(ticketNumber
{!error && !analysis && ( ? [{ label: ticketNumber, href: `/analyzer/ticket/${encodeURIComponent(ticketNumber)}` }]
<div className="space-y-4"> : []),
<Skeleton className="h-32 w-full" /> { label: 'Analysis' },
<Skeleton className="h-24 w-full" /> ]}
<Skeleton className="h-24 w-full" /> accent
</div> />
)} <div className="container mx-auto px-6 py-6 max-w-5xl">
{analysis && ( {error && (
<div className="space-y-6"> <Alert variant="destructive">
<AnalysisView analysis={analysis} /> <AlertTitle>Couldn&rsquo;t load this analysis</AlertTitle>
<ItglueSuggestionsPanel analysisId={analysis.id} /> <AlertDescription>{error}</AlertDescription>
</div> </Alert>
)} )}
</div> {!error && !analysis && (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{analysis && (
<div className="space-y-6">
<AnalysisView analysis={analysis} />
<ItglueSuggestionsPanel analysisId={analysis.id} />
</div>
)}
</div>
</>
); );
} }

View file

@ -12,6 +12,7 @@ import {
ProviderToggle, ProviderToggle,
type AnalyzerProvider, type AnalyzerProvider,
} from '@/components/analyzer/provider-toggle'; } from '@/components/analyzer/provider-toggle';
import { PageHeader } from '@/components/navigation/page-header';
import { Sparkles, Zap } from 'lucide-react'; import { Sparkles, Zap } from 'lucide-react';
import type { PersistedAnalysis } from '@/lib/types/analyzer'; import type { PersistedAnalysis } from '@/lib/types/analyzer';
@ -50,29 +51,24 @@ export default function TicketAnalyzerPage({
const latest = analyses?.[0]; const latest = analyses?.[0];
return ( return (
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6"> <>
<Card> <PageHeader
<CardHeader> title={ticketNumber}
<div className="flex items-start justify-between gap-4 flex-wrap"> description="Run the analyzer pipeline against this ticket. Each provider keeps its own history; same-hash runs are instant."
<div className="space-y-1"> breadcrumbs={[
<p className="text-sm text-muted-foreground">Ticket</p> { label: 'Analyzer', href: '/analyzer/tickets' },
<CardTitle className="font-mono">{ticketNumber}</CardTitle> { label: 'Tickets', href: '/analyzer/tickets' },
</div> { label: ticketNumber },
<div className="flex items-center gap-2 flex-wrap"> ]}
<ProviderToggle value={provider} onChange={setProvider} size="sm" /> accent
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} /> actions={
</div> <>
</div> <ProviderToggle value={provider} onChange={setProvider} size="sm" />
</CardHeader> <AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
<CardContent> </>
<p className="text-sm text-muted-foreground"> }
Click <strong>Analyze</strong> to run the AI pipeline using the />
selected provider. Each provider keeps its own analysis history, <div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
so you can compare Claude and DeepSeek output side-by-side. A run
with the same content hash on the same provider returns instantly.
</p>
</CardContent>
</Card>
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
@ -162,6 +158,7 @@ export default function TicketAnalyzerPage({
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</>
); );
} }

View file

@ -2,10 +2,11 @@
* GET /api/dashboard/overview * GET /api/dashboard/overview
* Single round-trip backing the new dashboard. All queries run in parallel. * Single round-trip backing the new dashboard. All queries run in parallel.
* *
* today KPI snapshot: opened, resolved, open total, SLA breaches
* attention counts that should pull a human's eyes * attention counts that should pull a human's eyes
* observations recent device_observations (loglift et al.) * observations recent device_observations (loglift et al.)
* audits recent endpoint_audits * audits recent endpoint_audits
* syncHealth per-schedule last_run / last_status from sync_schedules * syncHealth per-schedule last_run / last_status (consumed by /status)
* stats small footer: companies, CIs, xref linkage * stats small footer: companies, CIs, xref linkage
*/ */
@ -20,6 +21,9 @@ export async function GET() {
type Counts = { count: string }; type Counts = { count: string };
const [ const [
todayRes,
yesterdayOpenedRes,
last7AvgResolvedRes,
linkConflictsRes, linkConflictsRes,
itglueUnlinkedRes, itglueUnlinkedRes,
s1UnmappedRes, s1UnmappedRes,
@ -31,6 +35,44 @@ export async function GET() {
ciRes, ciRes,
xrefRes, xrefRes,
] = await Promise.all([ ] = await Promise.all([
/* today snapshot — single row, all four KPIs */
postgresClient.query<{
opened_today: string;
resolved_today: string;
open_total: string;
sla_breaches: string;
}>(`
SELECT
COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND due_date_time IS NOT NULL
AND due_date_time < NOW()
)::text AS sla_breaches
FROM tickets
WHERE is_deleted = false OR is_deleted IS NULL
`),
/* yesterday's opened count for the today-vs-yesterday delta */
postgresClient.query<{ count: string }>(`
SELECT COUNT(*)::text AS count
FROM tickets
WHERE create_date::date = CURRENT_DATE - INTERVAL '1 day'
AND (is_deleted = false OR is_deleted IS NULL)
`),
/* 7-day average resolved (excluding today) for the resolved delta */
postgresClient.query<{ avg_resolved: string }>(`
SELECT COALESCE(AVG(daily_count), 0)::text AS avg_resolved
FROM (
SELECT completed_date::date AS d, COUNT(*) AS daily_count
FROM tickets
WHERE completed_date >= CURRENT_DATE - INTERVAL '7 days'
AND completed_date < CURRENT_DATE
AND (is_deleted = false OR is_deleted IS NULL)
GROUP BY completed_date::date
) sub
`),
postgresClient.query<Counts>( postgresClient.query<Counts>(
`SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL` `SELECT COUNT(*)::text AS count FROM device_link_review WHERE resolved_at IS NULL`
), ),
@ -118,7 +160,19 @@ export async function GET() {
), ),
]); ]);
const today = todayRes.rows[0];
const yesterdayOpened = parseInt(yesterdayOpenedRes.rows[0]?.count ?? '0', 10);
const last7Avg = parseFloat(last7AvgResolvedRes.rows[0]?.avg_resolved ?? '0');
return NextResponse.json({ return NextResponse.json({
today: {
openedToday: parseInt(today?.opened_today ?? '0', 10),
resolvedToday: parseInt(today?.resolved_today ?? '0', 10),
openTotal: parseInt(today?.open_total ?? '0', 10),
slaBreaches: parseInt(today?.sla_breaches ?? '0', 10),
yesterdayOpened,
last7DayAvgResolved: Math.round(last7Avg * 10) / 10,
},
attention: { attention: {
linkConflicts: parseInt(linkConflictsRes.rows[0]?.count ?? '0', 10), linkConflicts: parseInt(linkConflictsRes.rows[0]?.count ?? '0', 10),
itglueUnlinked: parseInt(itglueUnlinkedRes.rows[0]?.count ?? '0', 10), itglueUnlinked: parseInt(itglueUnlinkedRes.rows[0]?.count ?? '0', 10),

View file

@ -0,0 +1,143 @@
/**
* GET /api/dashboard/trends
* Operational trend data backing /dashboard's chart row + queue posture.
*
* volumeByDay last 30 days, ticket creation count per day
* resolutionByDay last 30 days, mean resolution hours per day completed
* queueHeatmap open tickets grouped by (queue, priority)
* activeEngineers top engineers today by hours logged
*
* All queries run in parallel. ~50 ms total against a warm DB.
*/
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
const TREND_DAYS = 30;
const TOP_QUEUES = 10;
const TOP_ENGINEERS = 8;
export async function GET() {
const { error } = await requireAuth();
if (error) return error;
const [volumeRes, resolutionRes, heatmapRes, engineersRes] = await Promise.all([
postgresClient.query<{ d: string; count: string }>(
`WITH days AS (
SELECT generate_series(
CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
CURRENT_DATE,
INTERVAL '1 day'
)::date AS d
)
SELECT d::text AS d,
COALESCE(COUNT(t.id), 0)::text AS count
FROM days
LEFT JOIN tickets t
ON t.create_date::date = days.d
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY d
ORDER BY d`,
),
postgresClient.query<{ d: string; avg_hours: string | null }>(
`WITH days AS (
SELECT generate_series(
CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
CURRENT_DATE,
INTERVAL '1 day'
)::date AS d
)
SELECT d::text AS d,
AVG(EXTRACT(EPOCH FROM (t.completed_date - t.create_date)) / 3600.0)::text AS avg_hours
FROM days
LEFT JOIN tickets t
ON t.completed_date::date = days.d
AND t.create_date IS NOT NULL
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY d
ORDER BY d`,
),
postgresClient.query<{
queue_id: number | null;
queue_label: string | null;
priority: number | null;
count: string;
}>(
`SELECT t.queue_id,
q.label AS queue_label,
t.priority,
COUNT(*)::text AS count
FROM tickets t
LEFT JOIN queues q ON q.value = t.queue_id
WHERE t.completed_date IS NULL
AND (t.is_deleted = false OR t.is_deleted IS NULL)
GROUP BY t.queue_id, q.label, t.priority
ORDER BY COUNT(*) DESC`,
),
postgresClient.query<{
resource_id: string;
resource_name: string;
hours: string;
tickets_touched: string;
}>(
`SELECT te.resource_id::text,
COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''),
r.email,
'Resource ' || te.resource_id) AS resource_name,
SUM(te.hours_worked)::text AS hours,
COUNT(DISTINCT te.ticket_id)::text AS tickets_touched
FROM time_entries te
LEFT JOIN resources r ON r.id = te.resource_id
WHERE te.entry_date::date = CURRENT_DATE
AND te.hours_worked > 0
GROUP BY te.resource_id, r.first_name, r.last_name, r.email
ORDER BY SUM(te.hours_worked) DESC
LIMIT ${TOP_ENGINEERS}`,
),
]);
// Heatmap: top N queues by open volume × priority columns
const heatmapRows = heatmapRes.rows;
const queueTotals = new Map<number, { id: number; label: string; total: number }>();
for (const row of heatmapRows) {
if (row.queue_id == null) continue;
const t = queueTotals.get(row.queue_id) ?? {
id: row.queue_id,
label: row.queue_label ?? `Queue ${row.queue_id}`,
total: 0,
};
t.total += parseInt(row.count, 10);
queueTotals.set(row.queue_id, t);
}
const topQueues = [...queueTotals.values()]
.sort((a, b) => b.total - a.total)
.slice(0, TOP_QUEUES);
const heatmap = topQueues.map((q) => {
const cells: Record<number, number> = {};
for (const row of heatmapRows) {
if (row.queue_id !== q.id || row.priority == null) continue;
cells[row.priority] = (cells[row.priority] ?? 0) + parseInt(row.count, 10);
}
return { queueId: q.id, queueLabel: q.label, total: q.total, byPriority: cells };
});
return NextResponse.json({
volumeByDay: volumeRes.rows.map((r) => ({
date: r.d,
count: parseInt(r.count, 10),
})),
resolutionByDay: resolutionRes.rows.map((r) => ({
date: r.d,
avgHours: r.avg_hours == null ? null : Math.round(parseFloat(r.avg_hours) * 10) / 10,
})),
queueHeatmap: heatmap,
activeEngineers: engineersRes.rows.map((r) => ({
resourceId: r.resource_id,
name: r.resource_name,
hours: Math.round(parseFloat(r.hours) * 10) / 10,
ticketsTouched: parseInt(r.tickets_touched, 10),
})),
});
}

View file

@ -0,0 +1,102 @@
/**
* GET /api/status/workers
* Heartbeat snapshot for the three in-process workers:
* analyzer analyzer_jobs
* rmm rmm_executions
* sync sync_schedules / sync_history (proxy for the scheduler)
*
* For each: last activity timestamp, in-flight count, last-1h success/
* failure totals. Cheap just SELECT COUNT(*) FILTER queries. */
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
export interface WorkerSnapshot {
name: string;
lastActivity: string | null;
inFlight: number;
oneHour: { success: number; failure: number };
}
export async function GET() {
const { error } = await requireAuth();
if (error) return error;
const [analyzerRes, rmmRes, syncRes] = await Promise.all([
postgresClient.query<{
last_activity: string | null;
in_flight: string;
ok_1h: string;
fail_1h: string;
}>(
`SELECT
GREATEST(MAX(queued_at), MAX(started_at), MAX(finished_at))::text AS last_activity,
COUNT(*) FILTER (WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review'))::text AS in_flight,
COUNT(*) FILTER (WHERE status = 'complete' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM analyzer_jobs`,
),
postgresClient.query<{
last_activity: string | null;
in_flight: string;
ok_1h: string;
fail_1h: string;
}>(
`SELECT
GREATEST(MAX(queued_at), MAX(started_at), MAX(completed_at))::text AS last_activity,
COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight,
COUNT(*) FILTER (WHERE status = 'complete' AND completed_at >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM rmm_executions`,
),
postgresClient.query<{
last_run: string | null;
ok_1h: string;
fail_1h: string;
}>(
`SELECT
MAX(last_run)::text AS last_run,
COUNT(*) FILTER (WHERE last_status = 'success' AND last_run >= NOW() - INTERVAL '1 hour')::text AS ok_1h,
COUNT(*) FILTER (WHERE last_status = 'failed' AND last_run >= NOW() - INTERVAL '1 hour')::text AS fail_1h
FROM sync_schedules
WHERE is_enabled = true`,
),
]);
const a = analyzerRes.rows[0];
const r = rmmRes.rows[0];
const s = syncRes.rows[0];
const workers: WorkerSnapshot[] = [
{
name: 'Analyzer',
lastActivity: a?.last_activity ?? null,
inFlight: parseInt(a?.in_flight ?? '0', 10),
oneHour: {
success: parseInt(a?.ok_1h ?? '0', 10),
failure: parseInt(a?.fail_1h ?? '0', 10),
},
},
{
name: 'RMM Overshell',
lastActivity: r?.last_activity ?? null,
inFlight: parseInt(r?.in_flight ?? '0', 10),
oneHour: {
success: parseInt(r?.ok_1h ?? '0', 10),
failure: parseInt(r?.fail_1h ?? '0', 10),
},
},
{
name: 'Sync scheduler',
lastActivity: s?.last_run ?? null,
inFlight: 0,
oneHour: {
success: parseInt(s?.ok_1h ?? '0', 10),
failure: parseInt(s?.fail_1h ?? '0', 10),
},
},
];
return NextResponse.json({ workers });
}

View file

@ -12,6 +12,15 @@ import { ContractCoverageTable } from '@/components/backup/contract-coverage-tab
import { RefreshCw, CheckCircle2, AlertTriangle, XCircle, Clock, WifiOff } from 'lucide-react'; import { RefreshCw, CheckCircle2, AlertTriangle, XCircle, Clock, WifiOff } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { StatusBadge } from '@/components/ui/status-badge';
import { RpoJobSummary } from '@/lib/services/veeam-rpo-service'; import { RpoJobSummary } from '@/lib/services/veeam-rpo-service';
interface BackupStatusData { interface BackupStatusData {
@ -284,28 +293,28 @@ export default function BackupStatusPage() {
{/* Job Table */} {/* Job Table */}
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <Table>
<thead> <TableHeader className="bg-muted/50">
<tr className="border-b bg-muted/50"> <TableRow>
<th className="px-4 py-3 text-left font-medium">Job</th> <TableHead>Job</TableHead>
<th className="px-4 py-3 text-left font-medium">Organization</th> <TableHead>Organization</TableHead>
<th className="px-4 py-3 text-left font-medium">Last Backup</th> <TableHead>Last Backup</TableHead>
<th className="px-4 py-3 text-left font-medium">RMM Device</th> <TableHead>RMM Device</TableHead>
<th className="px-4 py-3 text-left font-medium">Status</th> <TableHead>Status</TableHead>
<th className="px-4 py-3 text-left font-medium">Ticket</th> <TableHead>Ticket</TableHead>
<th className="px-4 py-3 text-left font-medium">Failure Reason</th> <TableHead>Failure Reason</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{rpo.jobs.map((job) => ( {rpo.jobs.map((job) => (
<tr key={job.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={job.job_instance_uid}>
<td className="px-4 py-3 font-medium">{job.job_name}</td> <TableCell className="font-medium">{job.job_name}</TableCell>
<td className="px-4 py-3 text-muted-foreground">{job.org_name}</td> <TableCell className="text-muted-foreground">{job.org_name}</TableCell>
<td className="px-4 py-3 text-muted-foreground">{timeAgoHours(job.hours_since_backup)}</td> <TableCell className="text-muted-foreground num">{timeAgoHours(job.hours_since_backup)}</TableCell>
<td className="px-4 py-3 text-xs"> <TableCell className="text-xs">
{job.rmm_hostname ? ( {job.rmm_hostname ? (
<div> <div>
<span className="font-mono">{job.rmm_hostname}</span> <span className="num">{job.rmm_hostname}</span>
{job.is_offline_suppressed && ( {job.is_offline_suppressed && (
<div className="flex items-center gap-1 mt-0.5 text-muted-foreground"> <div className="flex items-center gap-1 mt-0.5 text-muted-foreground">
<WifiOff className="h-3 w-3" /> <WifiOff className="h-3 w-3" />
@ -316,21 +325,21 @@ export default function BackupStatusPage() {
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
)} )}
</td> </TableCell>
<td className="px-4 py-3"> <TableCell>
{job.is_offline_suppressed ? ( {job.is_offline_suppressed ? (
<Badge variant="secondary" className="flex items-center gap-1 w-fit"> <StatusBadge tone="neutral" className="gap-1">
<WifiOff className="h-3 w-3" />Offline <WifiOff className="h-3 w-3" />Offline
</Badge> </StatusBadge>
) : job.is_breached ? ( ) : job.is_breached ? (
<Badge variant="destructive">Breached</Badge> <StatusBadge tone="error">Breached</StatusBadge>
) : ( ) : (
<Badge variant="outline" className="text-green-600 border-green-600">Healthy</Badge> <StatusBadge tone="ok">Healthy</StatusBadge>
)} )}
</td> </TableCell>
<td className="px-4 py-3"> <TableCell>
{job.open_ticket ? ( {job.open_ticket ? (
<span className={`text-xs font-mono ${ <span className={`text-xs num ${
job.open_ticket.priority_level === 'critical' ? 'text-destructive' : job.open_ticket.priority_level === 'critical' ? 'text-destructive' :
job.open_ticket.priority_level === 'high' ? 'text-orange-500' : 'text-muted-foreground' job.open_ticket.priority_level === 'high' ? 'text-orange-500' : 'text-muted-foreground'
}`}> }`}>
@ -339,19 +348,19 @@ export default function BackupStatusPage() {
) : ( ) : (
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground"></span>
)} )}
</td> </TableCell>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate"> <TableCell className="text-xs text-muted-foreground max-w-xs truncate">
{job.failure_category ?? '—'} {job.failure_category ?? '—'}
</td> </TableCell>
</tr> </TableRow>
))} ))}
{rpo.jobs.length === 0 && ( {rpo.jobs.length === 0 && (
<tr> <TableRow>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</td> <TableCell colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</TableCell>
</tr> </TableRow>
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</> </>
)} )}
@ -363,43 +372,43 @@ export default function BackupStatusPage() {
No Autotask ticket is created while the device is offline. No Autotask ticket is created while the device is offline.
</p> </p>
<div className="rounded-md border"> <div className="rounded-md border">
<table className="w-full text-sm"> <Table>
<thead> <TableHeader className="bg-muted/50">
<tr className="border-b bg-muted/50"> <TableRow>
<th className="px-4 py-3 text-left font-medium">Device</th> <TableHead>Device</TableHead>
<th className="px-4 py-3 text-left font-medium">Job</th> <TableHead>Job</TableHead>
<th className="px-4 py-3 text-left font-medium">Organization</th> <TableHead>Organization</TableHead>
<th className="px-4 py-3 text-left font-medium">Type</th> <TableHead>Type</TableHead>
<th className="px-4 py-3 text-left font-medium">Last Seen</th> <TableHead>Last Seen</TableHead>
<th className="px-4 py-3 text-left font-medium">Offline</th> <TableHead>Offline</TableHead>
<th className="px-4 py-3 text-left font-medium">Checked</th> <TableHead>Checked</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{offlineLog.map((row) => ( {offlineLog.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30"> <TableRow key={row.id}>
<td className="px-4 py-3 font-mono text-xs">{row.rmm_hostname}</td> <TableCell className="num text-xs">{row.rmm_hostname}</TableCell>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[180px] truncate">{row.job_name}</td> <TableCell className="text-xs text-muted-foreground max-w-[180px] truncate">{row.job_name}</TableCell>
<td className="px-4 py-3 text-xs text-muted-foreground">{row.org_name}</td> <TableCell className="text-xs text-muted-foreground">{row.org_name}</TableCell>
<td className="px-4 py-3"> <TableCell>
<Badge variant="outline" className="text-xs">{row.device_type_category}</Badge> <Badge variant="outline" className="text-xs">{row.device_type_category}</Badge>
</td> </TableCell>
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.rmm_last_seen)}</td> <TableCell className="text-xs text-muted-foreground num">{timeAgo(row.rmm_last_seen)}</TableCell>
<td className="px-4 py-3 text-xs"> <TableCell className="text-xs num">
{row.hours_offline >= 48 {row.hours_offline >= 48
? `${Math.round(row.hours_offline / 24)}d` ? `${Math.round(row.hours_offline / 24)}d`
: `${Math.round(row.hours_offline)}h`} : `${Math.round(row.hours_offline)}h`}
</td> </TableCell>
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.checked_at)}</td> <TableCell className="text-xs text-muted-foreground num">{timeAgo(row.checked_at)}</TableCell>
</tr> </TableRow>
))} ))}
{offlineLog.length === 0 && ( {offlineLog.length === 0 && (
<tr> <TableRow>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No offline suppressions logged yet</td> <TableCell colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No offline suppressions logged yet</TableCell>
</tr> </TableRow>
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</TabsContent> </TabsContent>

View file

@ -585,9 +585,9 @@ function ConfigurationItemsContent() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
{/* Company Selector Row */} {/* Company Selector Row */}
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" /> <Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="w-[500px]"> <div className="w-full sm:flex-1 sm:min-w-[280px] sm:max-w-[500px]">
<CompanySelectorEnhanced <CompanySelectorEnhanced
value={selectedCompany} value={selectedCompany}
onValueChange={handleCompanyChange} onValueChange={handleCompanyChange}
@ -595,10 +595,10 @@ function ConfigurationItemsContent() {
/> />
</div> </div>
{selectedCompany && ( {selectedCompany && (
<div className="flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 rounded-lg flex-shrink-0"> <div className="flex items-center gap-2 px-3 py-1.5 bg-primary/10 rounded-md flex-shrink-0">
<Server className="h-4 w-4 text-purple-600" /> <Server className="h-4 w-4 text-primary" />
<span className="text-sm font-medium whitespace-nowrap"> <span className="text-xs font-medium num">
PSA: {stats?.totalAutotask || 0} | RMM: {stats?.totalRmm || 0} | NMS: {stats?.totalAuvik || 0} | ARMM: {stats?.totalAddigy || 0} PSA {stats?.totalAutotask || 0} · RMM {stats?.totalRmm || 0} · NMS {stats?.totalAuvik || 0} · ARMM {stats?.totalAddigy || 0}
</span> </span>
</div> </div>
)} )}

View file

@ -1,59 +1,48 @@
/* /dashboard Operations home.
*
* KPI-first. Health and sync status moved to /status (linked from the
* top-bar StatusLight). This page surfaces:
* Today snapshot opened, resolved, open total, SLA breaches
* Needs attention admin housekeeping that pulls a human's eyes
* Recent observations + recent audits
*
* Trends (volume by day, queue heatmap) will land here next once the
* supporting endpoints exist; for now the page is intentionally minimal
* and load-fast. */
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { EmptyState } from '@/components/ui/empty-state';
import { KpiCard } from '@/components/dashboard/kpi-card';
import { VolumeTrend } from '@/components/dashboard/volume-trend';
import { ResolutionTrend } from '@/components/dashboard/resolution-trend';
import { QueueHeatmap } from '@/components/dashboard/queue-heatmap';
import { ActiveEngineers } from '@/components/dashboard/active-engineers';
import { import {
AlertTriangle,
Database,
Shield,
CalendarClock,
RefreshCw, RefreshCw,
ArrowRight,
CheckCircle2,
XCircle,
Clock,
Activity, Activity,
Sparkles, Sparkles,
Plug, Users,
KeyRound, Layers,
TrendingUp,
Timer,
} from 'lucide-react'; } from 'lucide-react';
interface IntegrationHealthItem {
key: string;
name: string;
category: string;
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown';
configured: boolean;
latencyMs?: number;
error?: string | null;
tokenExpiry?: {
envVar: string;
expiresAt: string;
daysRemaining: number;
subject?: string | null;
} | null;
checkedAt: string;
}
interface IntegrationHealthResponse {
items: IntegrationHealthItem[];
summary: {
total: number;
ok: number;
failed: number;
notConfigured: number;
expiringWithin14Days: number;
expired: number;
hasIssues: boolean;
};
}
interface Overview { interface Overview {
today: {
openedToday: number;
resolvedToday: number;
openTotal: number;
slaBreaches: number;
yesterdayOpened: number;
last7DayAvgResolved: number;
};
attention: { attention: {
linkConflicts: number; linkConflicts: number;
itglueUnlinked: number; itglueUnlinked: number;
@ -78,16 +67,6 @@ interface Overview {
fieldGapsCount: number; fieldGapsCount: number;
status: string; status: string;
}>; }>;
syncHealth: Array<{
id: string;
name: string;
syncType: string;
isEnabled: boolean;
lastRun: string | null;
lastStatus: string | null;
lastError: string | null;
nextRun: string | null;
}>;
stats: { stats: {
activeCompanies: number; activeCompanies: number;
configurationItems: number; configurationItems: number;
@ -95,7 +74,22 @@ interface Overview {
}; };
} }
const STALE_HOURS = 24; interface Trends {
volumeByDay: Array<{ date: string; count: number }>;
resolutionByDay: Array<{ date: string; avgHours: number | null }>;
queueHeatmap: Array<{
queueId: number;
queueLabel: string;
total: number;
byPriority: Record<number, number>;
}>;
activeEngineers: Array<{
resourceId: string;
name: string;
hours: number;
ticketsTouched: number;
}>;
}
function relTime(iso: string | null): string { function relTime(iso: string | null): string {
if (!iso) return 'never'; if (!iso) return 'never';
@ -110,42 +104,26 @@ function relTime(iso: string | null): string {
return `${day} d ago`; return `${day} d ago`;
} }
function isStale(iso: string | null): boolean {
if (!iso) return true;
return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000;
}
function syncStatusIcon(s: { lastStatus: string | null; lastRun: string | null; isEnabled: boolean }) {
if (!s.isEnabled) return <span className="text-muted-foreground text-xs">off</span>;
if (s.lastStatus === 'failed')
return <XCircle className="size-4 text-destructive" aria-label="failed" />;
if (isStale(s.lastRun))
return <Clock className="size-4 text-amber-500" aria-label="stale" />;
if (s.lastStatus === 'success')
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
return <Clock className="size-4 text-muted-foreground" aria-label="never run" />;
}
export default function DashboardPage() { export default function DashboardPage() {
const [data, setData] = useState<Overview | null>(null); const [data, setData] = useState<Overview | null>(null);
const [health, setHealth] = useState<IntegrationHealthResponse | null>(null); const [trends, setTrends] = useState<Trends | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function load(): Promise<void> { async function load() {
setLoading(true); setLoading(true);
try { try {
const [overviewRes, healthRes] = await Promise.all([ const [overviewRes, trendsRes] = await Promise.all([
fetch('/api/dashboard/overview'), fetch('/api/dashboard/overview', { cache: 'no-store' }),
fetch('/api/dashboard/integration-health'), fetch('/api/dashboard/trends', { cache: 'no-store' }),
]); ]);
if (!overviewRes.ok) { if (!overviewRes.ok) {
const body = (await overviewRes.json().catch(() => ({}))) as { error?: string }; const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${overviewRes.status}`); throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
} }
setData((await overviewRes.json()) as Overview); setData((await overviewRes.json()) as Overview);
if (healthRes.ok) { if (trendsRes.ok) {
setHealth((await healthRes.json()) as IntegrationHealthResponse); setTrends((await trendsRes.json()) as Trends);
} }
setError(null); setError(null);
} catch (err) { } catch (err) {
@ -159,287 +137,301 @@ export default function DashboardPage() {
void load(); void load();
}, []); }, []);
const today = data?.today;
const openedDelta = today
? today.openedToday - today.yesterdayOpened
: 0;
const resolvedDelta = today
? Math.round((today.resolvedToday - today.last7DayAvgResolved) * 10) / 10
: 0;
return ( return (
<div className="container mx-auto px-6 py-6 space-y-6"> <>
<div className="flex items-center justify-between"> <PageHeader
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1> title="Operations"
<Button onClick={load} variant="outline" size="sm" disabled={loading}> description={new Date().toLocaleDateString(undefined, {
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> weekday: 'long',
Refresh year: 'numeric',
</Button> month: 'long',
</div> day: 'numeric',
})}
accent
watermark
actions={
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
}
/>
{error && ( <div className="container mx-auto px-6 py-6 space-y-6">
<Alert variant="destructive"> {error && (
<AlertTitle>Failed to load</AlertTitle> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertTitle>Failed to load</AlertTitle>
</Alert> <AlertDescription>{error}</AlertDescription>
)} </Alert>
)}
{/* NEEDS ATTENTION ----------------------------------------------------- */} {/* TODAY SNAPSHOT ----------------------------------------------- */}
<section> <section>
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"> <h2 className="metric-label mb-3">Today</h2>
Needs attention <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
</h2> <KpiCard
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> label="Opened"
<AttentionCard value={today?.openedToday ?? null}
icon={AlertTriangle} delta={
value={data?.attention.linkConflicts} today
label="Device-link conflicts" ? { value: openedDelta, label: 'vs yesterday' }
href="/admin/device-link-conflicts" : undefined
tone={data && data.attention.linkConflicts > 0 ? 'warn' : 'ok'} }
/> caption={today && `Yesterday: ${today.yesterdayOpened}`}
<AttentionCard loading={!data}
icon={Database} />
value={data?.attention.itglueUnlinked} <KpiCard
label="IT Glue ↛ Autotask" label="Resolved"
sub="unlinked configurations" value={today?.resolvedToday ?? null}
href="/admin/device-link-conflicts" delta={
tone="info" today
/> ? {
<AttentionCard value: resolvedDelta,
icon={Shield} label: 'vs 7-day avg',
value={data?.attention.s1Unmapped} }
label="S1 unmapped" : undefined
sub="missing site → company mapping" }
href="/sentinelone/mappings" caption={today && `7-day avg: ${today.last7DayAvgResolved}`}
tone="info" tone="accent"
/> loading={!data}
<AttentionCard />
icon={CalendarClock} <KpiCard
value={data?.attention.schedules.enabled} label="Open total"
label={`Schedules on / ${data?.attention.schedules.total ?? '—'}`} value={today?.openTotal ?? null}
href="/admin/sync/autotask" loading={!data}
tone="info" />
/> <KpiCard
label="SLA breaches"
value={today?.slaBreaches ?? null}
tone={
today && today.slaBreaches > 0 ? 'attention' : 'default'
}
caption={
today && today.slaBreaches === 0
? 'All on track'
: 'Past due, still open'
}
loading={!data}
/>
</div>
</section>
{/* NEEDS ATTENTION ---------------------------------------------- */}
<section>
<h2 className="metric-label mb-3">Needs attention</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard
label="Device-link conflicts"
value={data?.attention.linkConflicts ?? null}
tone={
data && data.attention.linkConflicts > 0 ? 'warn' : 'default'
}
href="/admin/device-link-conflicts"
loading={!data}
/>
<KpiCard
label="IT Glue unlinked"
value={data?.attention.itglueUnlinked ?? null}
caption="Configurations without an Autotask CI"
href="/admin/device-link-conflicts"
loading={!data}
/>
<KpiCard
label="S1 unmapped"
value={data?.attention.s1Unmapped ?? null}
caption="Sites missing a company mapping"
href="/sentinelone/mappings"
loading={!data}
/>
<KpiCard
label="Schedules on"
value={
data
? `${data.attention.schedules.enabled}/${data.attention.schedules.total}`
: null
}
href="/admin"
loading={!data}
/>
</div>
</section>
{/* QUEUE POSTURE ------------------------------------------------ */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<Card className="lg:col-span-8">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Layers className="h-4 w-4" />
Queue posture
</CardTitle>
</CardHeader>
<CardContent>
{!trends ? (
<Skeleton className="h-48" />
) : (
<QueueHeatmap data={trends.queueHeatmap} />
)}
</CardContent>
</Card>
<Card className="lg:col-span-4">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Active engineers
</CardTitle>
</CardHeader>
<CardContent>
{!trends ? (
<Skeleton className="h-48" />
) : (
<ActiveEngineers data={trends.activeEngineers} />
)}
</CardContent>
</Card>
</div> </div>
</section>
{/* RECENT OBSERVATIONS + AUDITS ---------------------------------------- */} {/* TRENDS ------------------------------------------------------- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Activity className="size-4" /> <TrendingUp className="h-4 w-4" />
Recent device observations Volume · last 30 days
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{data === null && !error ? ( {!trends ? (
<RowSkeletons /> <Skeleton className="h-44" />
) : data?.observations.length === 0 ? ( ) : (
<p className="text-sm text-muted-foreground">No observations recorded yet.</p> <VolumeTrend data={trends.volumeByDay} />
) : ( )}
<div className="space-y-1"> </CardContent>
{data?.observations.map((o) => ( </Card>
<div <Card>
key={o.id} <CardHeader className="pb-3">
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0" <CardTitle className="text-base flex items-center gap-2">
> <Timer className="h-4 w-4" />
<div className="min-w-0 flex-1"> Mean resolution time · last 30 days
<div className="font-medium truncate">{o.hostname ?? '(unanchored)'}</div> </CardTitle>
<div className="text-xs text-muted-foreground"> </CardHeader>
<span className="font-mono">{o.kind}</span> <CardContent>
{o.companyName && <span> · {o.companyName}</span>} {!trends ? (
<Skeleton className="h-44" />
) : (
<ResolutionTrend data={trends.resolutionByDay} />
)}
</CardContent>
</Card>
</div>
{/* RECENT ACTIVITY --------------------------------------------- */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="h-4 w-4" />
Recent device observations
</CardTitle>
</CardHeader>
<CardContent>
{!data ? (
<RowSkeletons />
) : data.observations.length === 0 ? (
<EmptyState
icon={Activity}
title="No observations recorded"
description="Device telemetry from LogLift and RMM will appear here."
size="sm"
/>
) : (
<div className="space-y-1">
{data.observations.map((o) => (
<div
key={o.id}
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{o.hostname ?? '(unanchored)'}</div>
<div className="text-xs text-muted-foreground">
<span className="num">{o.kind}</span>
{o.companyName && <span> · {o.companyName}</span>}
</div>
</div>
<div className="num text-xs text-muted-foreground shrink-0 ml-3">
{relTime(o.collectedAt)}
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground shrink-0 ml-3"> ))}
{relTime(o.collectedAt)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="size-4" />
Recent audits
</CardTitle>
</CardHeader>
<CardContent>
{data === null && !error ? (
<RowSkeletons />
) : data?.audits.length === 0 ? (
<p className="text-sm text-muted-foreground">No endpoint audits yet.</p>
) : (
<div className="space-y-1">
{data?.audits.map((a) => (
<div
key={a.id}
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
<div className="text-xs text-muted-foreground">
score {a.overallScore?.toFixed(2) ?? '—'} · {a.fieldGapsCount} gaps
{a.companyName && <span> · {a.companyName}</span>}
</div>
</div>
<div className="text-xs text-muted-foreground shrink-0 ml-3">
{relTime(a.generatedAt)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* INTEGRATION HEALTH -------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Plug className="size-4" />
Integration health
{health?.summary.hasIssues && (
<Badge variant="destructive" className="text-[10px]">issues</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{!health ? (
<RowSkeletons />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{health.items
.slice()
.sort((a, b) => statusOrder(a.status) - statusOrder(b.status))
.map((i) => (
<IntegrationRow key={i.key} item={i} />
))}
</div>
)}
</CardContent>
</Card>
{/* SYNC HEALTH --------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Sync health</CardTitle>
</CardHeader>
<CardContent>
{data === null && !error ? (
<RowSkeletons />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{data?.syncHealth.map((s) => (
<div key={s.id} className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
<div className="min-w-0 flex-1 truncate">{s.name}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
<span>{relTime(s.lastRun)}</span>
{syncStatusIcon(s)}
</div>
</div> </div>
))} )}
</div> </CardContent>
)} </Card>
</CardContent>
</Card>
{/* STATS FOOTER -------------------------------------------------------- */} <Card>
{data && ( <CardHeader className="pb-3">
<p className="text-xs text-muted-foreground"> <CardTitle className="text-base flex items-center gap-2">
{data.stats.activeCompanies} companies · {data.stats.configurationItems.toLocaleString()} CIs ·{' '} <Sparkles className="h-4 w-4" />
{data.stats.xref.total.toLocaleString()} xref rows ( Recent audits
{data.stats.xref.total > 0 </CardTitle>
? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100) </CardHeader>
: 0} <CardContent>
% linked) {!data ? (
</p> <RowSkeletons />
)} ) : data.audits.length === 0 ? (
</div> <EmptyState
); icon={Sparkles}
} title="No endpoint audits yet"
description="Asset-audit results from the analyzer will appear here."
size="sm"
/>
) : (
<div className="space-y-1">
{data.audits.map((a) => (
<div
key={a.id}
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
<div className="text-xs text-muted-foreground">
<span className="num">score {a.overallScore?.toFixed(2) ?? '—'}</span>
{' · '}
<span className="num">{a.fieldGapsCount} gaps</span>
{a.companyName && <span> · {a.companyName}</span>}
</div>
</div>
<div className="num text-xs text-muted-foreground shrink-0 ml-3">
{relTime(a.generatedAt)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
function AttentionCard(props: { {/* STATS FOOTER ------------------------------------------------- */}
icon: React.ElementType; {data && (
value: number | undefined; <p className="text-xs text-muted-foreground">
label: string; <span className="num">{data.stats.activeCompanies}</span> active companies ·{' '}
sub?: string; <span className="num">{data.stats.configurationItems.toLocaleString()}</span> configuration items ·{' '}
href: string; <span className="num">{data.stats.xref.total.toLocaleString()}</span> xref rows{' '}
tone: 'ok' | 'warn' | 'info'; ({data.stats.xref.total > 0
}) { ? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100)
const { icon: Icon, value, label, sub, href, tone } = props; : 0}% linked)
const valueColor = </p>
tone === 'warn' && value && value > 0 )}
? 'text-amber-600 dark:text-amber-500'
: tone === 'ok'
? 'text-foreground'
: 'text-foreground';
return (
<Link href={href} className="block">
<Card className="hover:shadow-md transition-shadow h-full">
<CardContent className="pt-4 pb-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<Icon className="size-4 text-muted-foreground" />
<ArrowRight className="size-3.5 text-muted-foreground" />
</div>
<div className={`text-2xl font-semibold tabular-nums ${valueColor}`}>
{value === undefined ? '—' : value.toLocaleString()}
</div>
<div className="text-sm font-medium leading-tight">{label}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</CardContent>
</Card>
</Link>
);
}
function statusOrder(s: IntegrationHealthItem['status']): number {
switch (s) {
case 'auth_failed': return 0;
case 'unreachable': return 1;
case 'unknown': return 2;
case 'ok': return 3;
case 'not_configured': return 4;
default: return 5;
}
}
function statusBadge(item: IntegrationHealthItem) {
const expiringSoon =
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
if (item.status === 'auth_failed' || item.status === 'unreachable')
return <XCircle className="size-4 text-destructive" aria-label={item.status} />;
if (expired)
return <KeyRound className="size-4 text-destructive" aria-label="token expired" />;
if (expiringSoon)
return <KeyRound className="size-4 text-amber-500" aria-label="token expires soon" />;
if (item.status === 'ok')
return <CheckCircle2 className="size-4 text-emerald-500" aria-label="ok" />;
if (item.status === 'unknown')
return <CheckCircle2 className="size-4 text-muted-foreground" aria-label="configured" />;
return <span className="text-xs text-muted-foreground">off</span>;
}
function IntegrationRow({ item }: { item: IntegrationHealthItem }) {
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
const expiringSoon =
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
const detail =
item.status === 'auth_failed' || item.status === 'unreachable'
? item.error?.slice(0, 80)
: expired
? `token expired ${Math.abs(item.tokenExpiry!.daysRemaining).toFixed(0)} d ago`
: expiringSoon
? `token expires in ${item.tokenExpiry!.daysRemaining.toFixed(0)} d`
: item.latencyMs !== undefined
? `${item.latencyMs} ms`
: null;
return (
<div className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
<div className="min-w-0 flex-1 truncate">{item.name}</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
{detail && <span className="truncate max-w-[20ch]">{detail}</span>}
{statusBadge(item)}
</div> </div>
</div> </>
); );
} }

View file

@ -27,6 +27,14 @@ import {
} from 'recharts'; } from 'recharts';
import { Users, RefreshCw } from 'lucide-react'; import { Users, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface UserOption { interface UserOption {
@ -579,66 +587,61 @@ export default function EngagementProfilePage() {
<CardTitle className="text-sm font-medium">Monthly Breakdown</CardTitle> <CardTitle className="text-sm font-medium">Monthly Breakdown</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <Table>
<table className="w-full text-sm"> <TableHeader>
<thead> <TableRow className="text-xs text-muted-foreground uppercase tracking-wide">
<tr className="border-b text-xs text-muted-foreground uppercase tracking-wide"> <TableHead>Month</TableHead>
<th className="text-left px-4 py-2.5 font-medium">Month</th> <TableHead className="text-right">Hours</TableHead>
<th className="text-right px-3 py-2.5 font-medium">Hours</th> <TableHead className="text-right">Billable</TableHead>
<th className="text-right px-3 py-2.5 font-medium">Billable</th> <TableHead className="text-right">Bill %</TableHead>
<th className="text-right px-3 py-2.5 font-medium">Bill %</th> <TableHead className="text-right hidden sm:table-cell">Days</TableHead>
<th className="text-right px-3 py-2.5 font-medium hidden sm:table-cell">Days</th> <TableHead className="text-right hidden md:table-cell">Meetings</TableHead>
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Meetings</th> <TableHead className="text-right hidden md:table-cell">Messages</TableHead>
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Messages</th> <TableHead className="text-right hidden lg:table-cell">Emails</TableHead>
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Emails</th> <TableHead className="text-right hidden lg:table-cell">Calls</TableHead>
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Calls</th> </TableRow>
</tr> </TableHeader>
</thead> <TableBody>
<tbody> {[...monthly].reverse().map(m => {
{[...monthly].reverse().map(m => { const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0;
const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0; const isEmpty =
const isEmpty = m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0;
m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0; const totalCalls = m.zoomClientCalls + m.teamsCalls;
const totalCalls = m.zoomClientCalls + m.teamsCalls; return (
return ( <TableRow
<tr key={m.month}
key={m.month} className={cn(isEmpty && 'opacity-40')}
className={cn( >
'border-b last:border-0 hover:bg-muted/30 transition-colors', <TableCell className="font-medium">{monthLabel(m.month)}</TableCell>
isEmpty && 'opacity-40' <TableCell className="text-right num">
)} {m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
> </TableCell>
<td className="px-4 py-2 font-medium">{monthLabel(m.month)}</td> <TableCell className="text-right num text-emerald-600 dark:text-emerald-400">
<td className="px-3 py-2 text-right tabular-nums"> {m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'} </TableCell>
</td> <TableCell className="text-right num">
<td className="px-3 py-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400"> {m.hoursWorked > 0 ? `${pct}%` : '—'}
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'} </TableCell>
</td> <TableCell className="text-right num hidden sm:table-cell">
<td className="px-3 py-2 text-right tabular-nums"> {m.daysWorked > 0 ? m.daysWorked : '—'}
{m.hoursWorked > 0 ? `${pct}%` : '—'} </TableCell>
</td> <TableCell className="text-right num hidden md:table-cell">
<td className="px-3 py-2 text-right tabular-nums hidden sm:table-cell"> {m.totalMeetings > 0 ? m.totalMeetings : '—'}
{m.daysWorked > 0 ? m.daysWorked : '—'} </TableCell>
</td> <TableCell className="text-right num hidden md:table-cell">
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell"> {m.teamsMessages > 0 ? m.teamsMessages : '—'}
{m.totalMeetings > 0 ? m.totalMeetings : '—'} </TableCell>
</td> <TableCell className="text-right num hidden lg:table-cell">
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell"> {m.emailsSent > 0 ? m.emailsSent : '—'}
{m.teamsMessages > 0 ? m.teamsMessages : '—'} </TableCell>
</td> <TableCell className="text-right num hidden lg:table-cell">
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell"> {totalCalls > 0 ? totalCalls : '—'}
{m.emailsSent > 0 ? m.emailsSent : '—'} </TableCell>
</td> </TableRow>
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell"> );
{totalCalls > 0 ? totalCalls : '—'} })}
</td> </TableBody>
</tr> </Table>
);
})}
</tbody>
</table>
</div>
</CardContent> </CardContent>
</Card> </Card>
</> </>

View file

@ -7,8 +7,8 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-plex-sans), 'Helvetica Neue', Helvetica, Arial, 'Liberation Sans', sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -121,3 +121,5 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@import "./styles/brand.css";

View file

@ -1,16 +1,32 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { AppNavigation } from "@/components/navigation/app-navigation"; import { AppNavigation } from "@/components/navigation/app-navigation";
import { TaglineFooter } from "@/components/branding/tagline-footer";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { AuthProvider } from "@/components/auth/auth-provider"; import { AuthProvider } from "@/components/auth/auth-provider";
const inter = Inter({ subsets: ["latin"] }); // IBM Plex Sans replaces the brand-mandated Helvetica/Arial. The 2013
// standards guide called for Helvetica Bold for headers and Helvetica
// Light for the tagline; Plex Sans honors the spirit (clean engineered
// sans) while loading reliably from Google Fonts. Weight 300 covers the
// "Light" usage in the tagline footer.
const plexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-plex-sans",
});
const plexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-plex-mono",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Pulse - PSA Management System", title: "Pulse · Operations console",
description: "Modern dashboard for Autotask PSA integration with RMM and NMS mapping", description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
icons: { icons: {
icon: [ icon: [
{ url: "/favicon.png", sizes: "any" }, { url: "/favicon.png", sizes: "any" },
@ -27,8 +43,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning className={`${plexSans.variable} ${plexMono.variable}`}>
<body className={inter.className}> <body className="font-sans antialiased">
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="system"
@ -36,9 +52,10 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<AuthProvider> <AuthProvider>
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background flex flex-col">
<AppNavigation /> <AppNavigation />
<main>{children}</main> <main className="flex-1">{children}</main>
<TaglineFooter />
</div> </div>
</AuthProvider> </AuthProvider>
<Toaster position="top-right" richColors /> <Toaster position="top-right" richColors />

541
app/status/page.tsx Normal file
View file

@ -0,0 +1,541 @@
/* /status System health dashboard.
*
* Pulls from:
* GET /api/dashboard/integration-health (live API check + token expiry)
* GET /api/dashboard/overview (syncHealth array)
*
* Surfaces what's wrong so the dashboard can stay focused on operational
* KPIs. Polls every 60 s while the page is visible. */
'use client';
import { useEffect, useState } from 'react';
import { PageHeader } from '@/components/navigation/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
import { StatusBadge } from '@/components/ui/status-badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { WorkerPulse } from '@/components/status/worker-pulse';
import {
AlertTriangle,
KeyRound,
RefreshCw,
ShieldCheck,
Plug,
Clock,
Activity,
} from 'lucide-react';
// ── Types ────────────────────────────────────────────────────────────
type IntegrationCategory =
| 'psa' | 'rmm' | 'docs' | 'security' | 'backup'
| 'network' | 'identity' | 'mdm' | 'mail'
| 'finance' | 'productivity' | 'llm';
interface IntegrationHealthItem {
key: string;
name: string;
category: IntegrationCategory;
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown' | 'disabled';
configured: boolean;
latencyMs?: number;
error?: string | null;
tokenExpiry?: {
envVar: string;
expiresAt: string;
daysRemaining: number;
subject?: string | null;
} | null;
checkedAt: string;
}
interface IntegrationHealthResponse {
items: IntegrationHealthItem[];
summary: {
total: number;
ok: number;
failed: number;
notConfigured: number;
disabled: number;
expiringWithin14Days: number;
expired: number;
hasIssues: boolean;
};
}
interface SyncHealthItem {
id: string;
name: string;
syncType: string;
isEnabled: boolean;
lastRun: string | null;
lastStatus: string | null;
lastError: string | null;
nextRun: string | null;
}
interface OverviewResponse {
syncHealth: SyncHealthItem[];
}
interface WorkerSnapshot {
name: string;
lastActivity: string | null;
inFlight: number;
oneHour: { success: number; failure: number };
}
interface WorkersResponse {
workers: WorkerSnapshot[];
}
const WORKER_FRESHNESS: Record<string, number> = {
Analyzer: 5,
'RMM Overshell': 10,
'Sync scheduler': 60,
};
// ── Helpers ──────────────────────────────────────────────────────────
const STALE_HOURS = 24;
const POLL_MS = 60_000;
const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
psa: 'PSA',
rmm: 'RMM',
docs: 'Documentation',
security: 'Security',
backup: 'Backup',
network: 'Network',
identity: 'Identity',
mdm: 'MDM',
mail: 'Mail',
finance: 'Finance',
productivity: 'Productivity',
llm: 'LLM',
};
const CATEGORY_ORDER: IntegrationCategory[] = [
'psa', 'rmm', 'docs', 'security', 'backup',
'network', 'identity', 'mdm', 'mail',
'finance', 'productivity', 'llm',
];
function relTime(iso: string | null): string {
if (!iso) return 'never';
const ms = Date.now() - new Date(iso).getTime();
if (ms < 0) return 'in the future';
const min = Math.floor(ms / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min} min ago`;
const hr = Math.floor(min / 60);
if (hr < 48) return `${hr} h ago`;
const day = Math.floor(hr / 24);
return `${day} d ago`;
}
function isStale(iso: string | null): boolean {
if (!iso) return true;
return Date.now() - new Date(iso).getTime() > STALE_HOURS * 3600_000;
}
function integrationLight(item: IntegrationHealthItem): StatusLightState {
if (item.status === 'disabled') return 'idle';
const tokenExpired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
const tokenExpiring =
item.tokenExpiry &&
item.tokenExpiry.daysRemaining > 0 &&
item.tokenExpiry.daysRemaining <= 14;
if (item.status === 'auth_failed' || item.status === 'unreachable' || tokenExpired) {
return 'error';
}
if (tokenExpiring) return 'warn';
if (item.status === 'ok') return 'ok';
if (item.status === 'not_configured') return 'idle';
return 'idle';
}
function syncLight(item: SyncHealthItem): StatusLightState {
if (!item.isEnabled) return 'idle';
if (item.lastStatus === 'failed') return 'error';
if (isStale(item.lastRun)) return 'warn';
if (item.lastStatus === 'success') return 'ok';
return 'idle';
}
// ── Page ─────────────────────────────────────────────────────────────
export default function StatusPage() {
const [health, setHealth] = useState<IntegrationHealthResponse | null>(null);
const [overview, setOverview] = useState<OverviewResponse | null>(null);
const [workers, setWorkers] = useState<WorkerSnapshot[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
async function load(force = false) {
setRefreshing(true);
try {
const [hRes, oRes, wRes] = await Promise.all([
fetch(`/api/dashboard/integration-health${force ? '?refresh=1' : ''}`, { cache: 'no-store' }),
fetch('/api/dashboard/overview', { cache: 'no-store' }),
fetch('/api/status/workers', { cache: 'no-store' }),
]);
if (hRes.ok) setHealth((await hRes.json()) as IntegrationHealthResponse);
if (oRes.ok) setOverview((await oRes.json()) as OverviewResponse);
if (wRes.ok) {
const j = (await wRes.json()) as WorkersResponse;
setWorkers(j.workers);
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
} finally {
setRefreshing(false);
}
}
useEffect(() => {
void load();
const id = setInterval(() => void load(), POLL_MS);
return () => clearInterval(id);
}, []);
// Roll-up
const overall: StatusLightState = !health
? 'idle'
: health.summary.failed > 0 || health.summary.expired > 0
? 'error'
: health.summary.expiringWithin14Days > 0 ||
(overview?.syncHealth.some((s) => syncLight(s) === 'error') ?? false)
? 'warn'
: 'ok';
const overallTitle = !health
? 'Loading…'
: overall === 'error'
? `${health.summary.failed} integration${health.summary.failed === 1 ? '' : 's'} failing`
: overall === 'warn'
? health.summary.expiringWithin14Days > 0
? `${health.summary.expiringWithin14Days} token${health.summary.expiringWithin14Days === 1 ? '' : 's'} expiring soon`
: 'Some sync tasks degraded'
: 'All systems operational';
// Group integrations
const grouped = (() => {
if (!health) return null;
const map: Record<string, IntegrationHealthItem[]> = {};
for (const item of health.items) {
(map[item.category] ??= []).push(item);
}
return map;
})();
const expiring = health?.items
.filter((i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 30)
.sort((a, b) => (a.tokenExpiry!.daysRemaining ?? 999) - (b.tokenExpiry!.daysRemaining ?? 999));
const failingSyncs = overview?.syncHealth.filter((s) => syncLight(s) === 'error');
const failingIntegrations = health?.items.filter(
(i) => i.status === 'auth_failed' || i.status === 'unreachable',
);
return (
<>
<PageHeader
title="System Status"
description={overallTitle}
breadcrumbs={[{ label: 'Status' }]}
accent
watermark
actions={
<>
<StatusLight state={overall} size="lg" label={overallTitle} />
<Button
onClick={() => void load(true)}
variant="outline"
size="sm"
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</>
}
/>
<div className="container mx-auto px-6 py-6 space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to load status</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* CONDITIONAL BANNER -------------------------------------------- */}
{(failingIntegrations?.length || failingSyncs?.length) ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Action needed</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5 mt-1 space-y-0.5">
{failingIntegrations?.map((i) => (
<li key={i.key}>
<span className="font-medium">{i.name}</span> {' '}
{i.status === 'auth_failed' ? 'authentication failed' : 'unreachable'}
{i.error && <span className="text-muted-foreground"> · {i.error.slice(0, 120)}</span>}
</li>
))}
{failingSyncs?.map((s) => (
<li key={s.id}>
<span className="font-medium">{s.name}</span> sync failed
{s.lastError && <span className="text-muted-foreground"> · {s.lastError.slice(0, 120)}</span>}
</li>
))}
</ul>
</AlertDescription>
</Alert>
) : null}
{/* INTEGRATION TILES --------------------------------------------- */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Plug className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Integrations
</h2>
{health && (
<span className="text-xs text-muted-foreground">
{health.summary.ok} of {health.summary.total - health.summary.notConfigured} healthy
</span>
)}
</div>
{!grouped ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => <Skeleton key={i} className="h-24" />)}
</div>
) : (
<div className="space-y-6">
{CATEGORY_ORDER.filter((c) => grouped[c]?.length).map((category) => (
<div key={category} className="space-y-2">
<h3 className="metric-label">{CATEGORY_LABELS[category]}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{grouped[category]
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((item) => <IntegrationTile key={item.key} item={item} />)}
</div>
</div>
))}
</div>
)}
</section>
{/* WORKERS ------------------------------------------------------- */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Workers
</h2>
</div>
{!workers ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-36" />)}
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{workers.map((w) => (
<WorkerPulse
key={w.name}
worker={w}
freshnessMinutes={WORKER_FRESHNESS[w.name]}
/>
))}
</div>
)}
</section>
{/* TOKEN EXPIRY -------------------------------------------------- */}
{expiring && expiring.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<KeyRound className="h-4 w-4" />
Tokens expiring within 30 days
</CardTitle>
</CardHeader>
<CardContent>
<div className="divide-y divide-border">
{expiring.map((item) => {
const days = item.tokenExpiry!.daysRemaining;
const tone = days <= 0 ? 'error' : days <= 14 ? 'warn' : 'pending';
return (
<div key={item.key} className="flex items-center justify-between py-2 text-sm">
<div className="min-w-0">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground"> · {item.tokenExpiry!.envVar}</span>
</div>
<StatusBadge tone={tone}>
{days <= 0 ? `expired ${Math.abs(days)} d ago` : `${days} d`}
</StatusBadge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* SYNC HEALTH --------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Clock className="h-4 w-4" />
Scheduled syncs
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{!overview ? (
<div className="px-6 py-6">
<Skeleton className="h-32" />
</div>
) : overview.syncHealth.length === 0 ? (
<div className="px-6 py-6">
<EmptyState
icon={Clock}
title="No scheduled syncs"
description="Configure schedules in Admin to populate this list."
action={{ label: 'Open admin', href: '/admin' }}
/>
</div>
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Schedule</TableHead>
<TableHead>Type</TableHead>
<TableHead>Last run</TableHead>
<TableHead>Next run</TableHead>
<TableHead className="text-right">Status</TableHead>
<TableHead className="w-10" aria-label="indicator" />
</TableRow>
</TableHeader>
<TableBody>
{overview.syncHealth.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="num text-muted-foreground">{s.syncType}</TableCell>
<TableCell className="num text-muted-foreground">{relTime(s.lastRun)}</TableCell>
<TableCell className="num text-muted-foreground">{relTime(s.nextRun)}</TableCell>
<TableCell className="text-right">
{s.isEnabled
? s.lastStatus === 'failed'
? <StatusBadge tone="error">failed</StatusBadge>
: isStale(s.lastRun)
? <StatusBadge tone="warn">stale</StatusBadge>
: s.lastStatus === 'success'
? <StatusBadge tone="ok">success</StatusBadge>
: <StatusBadge tone="neutral">idle</StatusBadge>
: <StatusBadge tone="inactive">off</StatusBadge>}
</TableCell>
<TableCell className="text-right">
<StatusLight state={syncLight(s)} size="sm" label={s.lastStatus ?? 'idle'} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* COMPLIANCE FOOTER -------------------------------------------- */}
{health && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5" />
<span><span className="num">{health.summary.ok}</span> healthy</span>
<span>·</span>
<span><span className="num">{health.summary.failed}</span> failing</span>
<span>·</span>
<span><span className="num">{health.summary.notConfigured}</span> unconfigured</span>
{health.summary.disabled > 0 && (
<>
<span>·</span>
<span><span className="num">{health.summary.disabled}</span> disabled</span>
</>
)}
<span>·</span>
<span><span className="num">{health.summary.expiringWithin14Days}</span> expiring</span>
<span>·</span>
<span>last checked {relTime(health.items[0]?.checkedAt ?? null)}</span>
</p>
)}
</div>
</>
);
}
// ── Integration tile ────────────────────────────────────────────────
function IntegrationTile({ item }: { item: IntegrationHealthItem }) {
const light = integrationLight(item);
const expired = item.tokenExpiry && item.tokenExpiry.daysRemaining <= 0;
const expiringSoon =
item.tokenExpiry && item.tokenExpiry.daysRemaining > 0 && item.tokenExpiry.daysRemaining <= 14;
let detail: string | null = null;
if (item.status === 'disabled') detail = 'disabled by operator';
else if (item.status === 'auth_failed') detail = 'authentication failed';
else if (item.status === 'unreachable') detail = 'unreachable';
else if (expired) detail = `token expired ${Math.abs(item.tokenExpiry!.daysRemaining)} d ago`;
else if (expiringSoon) detail = `token expires in ${item.tokenExpiry!.daysRemaining} d`;
else if (item.status === 'not_configured') detail = 'not configured';
else if (item.status === 'ok' && item.latencyMs !== undefined) detail = `${item.latencyMs} ms`;
else if (item.status === 'unknown' && item.configured) detail = 'configured';
return (
<div
className={
'rounded-md border bg-card px-3 py-3 flex items-start gap-3 ' +
(light === 'error'
? 'border-destructive/40'
: item.status === 'disabled'
? 'border-border opacity-60'
: 'border-border')
}
>
<StatusLight state={light} size="md" label={item.status} className="mt-1" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="font-medium truncate">{item.name}</p>
{(expired || expiringSoon) && (
<KeyRound className={`h-3.5 w-3.5 shrink-0 ${expired ? 'text-destructive' : 'text-amber-500'}`} />
)}
</div>
{detail && (
<p className={`text-xs num truncate ${light === 'error' ? 'text-destructive' : 'text-muted-foreground'}`}>
{detail}
</p>
)}
{item.error && light === 'error' && (
<p className="text-xs text-muted-foreground/80 truncate" title={item.error}>
{item.error.slice(0, 80)}
</p>
)}
</div>
</div>
);
}

170
app/styles/brand.css Normal file
View file

@ -0,0 +1,170 @@
/* === Wulf Consulting brand layer ====================================
*
* Authoritative palette per the Logo Standards Guide (docs/StandardsGuide
* (1).pdf, dated 2013-09-04):
*
* Primary blue #0075AD (Pantone 110-7 U)
* Secondary gray #6D6E70 (Pantone 179-10 U)
* Primary face Helvetica / Arial Bold for headers, Light for tagline
* Tagline "Don't be afraid to cry" (Helvetica Light, gray, 16pt ref.)
*
* Typography update (2026-05): the brand-mandated Helvetica/Arial has
* been replaced with IBM Plex Sans. Plex Sans honors the spirit of the
* mandate (clean engineered sans, supports a Light weight for the
* tagline) while loading reliably from Google Fonts; Helvetica/Arial
* stay in the fallback chain so the look degrades gracefully. Plex
* Mono carries numeric data (KPIs, IDs, timestamps).
*
* Imported once from app/globals.css. The :root overrides below repoint
* --primary / --ring / --accent / fonts; the rest of the shadcn token
* graph stays untouched.
* ==================================================================== */
:root {
/* --- Brand-scoped tokens (do not consume directly from components;
they exist so we can reason about brand vs. app intent). ---- */
--wulf-blue: oklch(0.540 0.136 233.3); /* #0075AD */
--wulf-blue-700: oklch(0.470 0.142 234.5); /* hover / pressed */
--wulf-blue-300: oklch(0.760 0.090 232); /* tinted fills */
--wulf-gray: oklch(0.515 0.001 271); /* #6D6E70 */
--wulf-gray-100: oklch(0.970 0.002 271); /* near-white surface */
--wulf-gray-300: oklch(0.880 0.002 271); /* borders */
--wulf-gray-700: oklch(0.380 0.002 271); /* body text on light bg */
/* --- App-level overrides: repoint shadcn tokens to Wulf values. ---
Light theme. --- */
--primary: var(--wulf-blue);
--primary-foreground: oklch(0.985 0 0);
--accent: var(--wulf-blue);
--accent-foreground: oklch(0.985 0 0);
--ring: var(--wulf-blue);
--sidebar-primary: var(--wulf-blue);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: var(--wulf-blue);
/* Chart slot 2 = Wulf Blue so single-series charts default to brand. */
--chart-2: var(--wulf-blue);
/* Font tokens are wired in app/globals.css (@theme inline) they
resolve --font-helvetica and --font-plex-mono variables that are
supplied by app/layout.tsx via next/font. We don't override them
here. */
}
.dark {
/* Slightly lifted blue for dark surfaces — keeps the tone, raises L. */
--primary: oklch(0.660 0.150 233.3);
--accent: oklch(0.660 0.150 233.3);
--ring: oklch(0.660 0.150 233.3);
--sidebar-primary: oklch(0.660 0.150 233.3);
--sidebar-ring: oklch(0.660 0.150 233.3);
--chart-2: oklch(0.660 0.150 233.3);
}
/* === Utility classes ================================================
*
* These are the canonical helpers for the dashboard refresh. Prefer
* them over re-rolling spacing / typography per page.
* ==================================================================== */
@utility num {
/* Numeric data tabular nums in the mono face. Use on KPI values,
table cells, timestamps, IDs. Never wrap full sentences in this. */
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
@utility num-lg {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.015em;
font-size: 1.875rem; /* text-3xl */
line-height: 2.25rem;
font-weight: 500;
}
@utility num-xl {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
font-size: 2.25rem; /* text-4xl */
line-height: 2.5rem;
font-weight: 600;
}
@utility metric-label {
/* Pair with .num / .num-lg. 11px uppercase tracked label sitting
above a metric value. */
font-size: 0.6875rem; /* 11px */
line-height: 1rem;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 500;
color: var(--muted-foreground);
}
@utility surface-brand {
background-color: var(--wulf-gray-100);
}
@utility surface-brand-ink {
background-color: var(--wulf-gray);
color: oklch(0.985 0 0);
}
@utility rule-brand {
/* The 2px Wulf-blue rule used under PageHeader when accent={true}.
Echoes the standards-guide blue header band. */
border-bottom: 2px solid var(--wulf-blue);
}
@utility text-chrome {
/* Sidebar / chrome text. Use for nav text, table column headers, and
anywhere the brand gray should carry weight without going full ink. */
color: var(--wulf-gray);
}
@utility border-chrome {
border-color: var(--wulf-gray-300);
}
@utility tagline {
/* Footer tagline. Helvetica Light, gray, 12px, gentle tracking.
Do NOT use this anywhere except the page footer line. */
font-family: var(--font-sans);
font-weight: 300;
color: var(--wulf-gray);
font-size: 0.75rem;
letter-spacing: 0.01em;
}
/* === Wolf-mark watermark ============================================
*
* Apply .has-mark-watermark to a positioned container; place a child
* with class `mark-watermark` inside. The child stays behind content
* via z-index, anchored to the right edge.
* ==================================================================== */
@utility has-mark-watermark {
position: relative;
isolation: isolate;
}
/* The child class can't be a `@utility` (it's only meaningful in the
parent context), so it's a plain rule. */
.mark-watermark {
position: absolute;
inset-block: 0;
inset-inline-end: 1.5rem;
z-index: -1;
opacity: 0.04;
pointer-events: none;
height: 100%;
aspect-ratio: 412 / 290; /* W mark intrinsic ratio */
color: var(--wulf-blue);
}
.dark .mark-watermark {
opacity: 0.06;
}

View file

@ -6,6 +6,14 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'; } from 'recharts';
@ -144,19 +152,19 @@ function TicketRow({ t, categoryFilter, onFilter }: {
return ( return (
<> <>
<tr <TableRow
className="border-b hover:bg-muted/10 cursor-pointer text-xs align-middle" className="cursor-pointer text-xs align-middle"
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
> >
<td className="pl-3 pr-2 py-2 w-6"> <TableCell className="w-6 pl-3">
{open {open
? <ChevronDown className="h-3 w-3 text-muted-foreground" /> ? <ChevronDown className="h-3 w-3 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 text-muted-foreground" />} : <ChevronRight className="h-3 w-3 text-muted-foreground" />}
</td> </TableCell>
<td className="pr-3 py-2 font-mono font-medium">{t.ticket_number}</td> <TableCell className="num font-medium">{t.ticket_number}</TableCell>
<td className="px-3 py-2 max-w-[140px] truncate">{t.company_name ?? '—'}</td> <TableCell className="max-w-[140px] truncate">{t.company_name ?? '—'}</TableCell>
<td className="px-3 py-2 font-mono text-[11px]">{t.device_hostname ?? '—'}</td> <TableCell className="num text-[11px]">{t.device_hostname ?? '—'}</TableCell>
<td className="px-3 py-2"> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
style={{ borderColor: catCfg?.color, color: catCfg?.color }} style={{ borderColor: catCfg?.color, color: catCfg?.color }}
@ -164,15 +172,15 @@ function TicketRow({ t, categoryFilter, onFilter }: {
> >
{catLabel(t.problem_category)} {catLabel(t.problem_category)}
</Badge> </Badge>
</td> </TableCell>
<td className="px-3 py-2 text-muted-foreground">{resLabel(t.resolution_type)}</td> <TableCell className="text-muted-foreground">{resLabel(t.resolution_type)}</TableCell>
<td className="px-3 py-2 text-center"> <TableCell className="text-center">
{t.same_day_close {t.same_day_close
? <CheckCircle2 className="h-3.5 w-3.5 text-green-500 mx-auto" /> ? <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 mx-auto" />
: <span className="text-muted-foreground"></span>} : <span className="text-muted-foreground"></span>}
</td> </TableCell>
<td className="px-3 py-2 text-right">{parseFloat(t.hours_worked).toFixed(2)}h</td> <TableCell className="text-right num">{parseFloat(t.hours_worked).toFixed(2)}h</TableCell>
<td className="px-3 py-2"> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }} style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }}
@ -180,12 +188,12 @@ function TicketRow({ t, categoryFilter, onFilter }: {
> >
{t.complexity} {t.complexity}
</Badge> </Badge>
</td> </TableCell>
<td className="px-3 py-2 text-muted-foreground">{timeAgo(t.ticket_created_at)}</td> <TableCell className="text-muted-foreground num">{timeAgo(t.ticket_created_at)}</TableCell>
</tr> </TableRow>
{open && ( {open && (
<tr className="border-b bg-muted/5"> <TableRow className="bg-muted/5">
<td colSpan={10} className="px-8 pb-3 pt-2"> <TableCell colSpan={10} className="px-8 pb-3 pt-2">
<div className="grid grid-cols-2 gap-4 text-xs"> <div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5"> <div className="space-y-1.5">
{t.work_summary && ( {t.work_summary && (
@ -222,8 +230,8 @@ function TicketRow({ t, categoryFilter, onFilter }: {
</div> </div>
)} )}
</div> </div>
</td> </TableCell>
</tr> </TableRow>
)} )}
</> </>
); );
@ -668,42 +676,40 @@ export default function VeeamAnalysisPage() {
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <Table>
<table className="w-full text-sm"> <TableHeader className="bg-muted/50">
<thead> <TableRow>
<tr className="border-b bg-muted/50 text-xs"> <TableHead className="w-6" />
<th className="w-6" /> <TableHead>Ticket</TableHead>
<th className="px-3 py-2.5 text-left font-medium">Ticket</th> <TableHead>Client</TableHead>
<th className="px-3 py-2.5 text-left font-medium">Client</th> <TableHead>Device</TableHead>
<th className="px-3 py-2.5 text-left font-medium">Device</th> <TableHead>Category</TableHead>
<th className="px-3 py-2.5 text-left font-medium">Category</th> <TableHead>Resolution</TableHead>
<th className="px-3 py-2.5 text-left font-medium">Resolution</th> <TableHead className="text-center">Same-day</TableHead>
<th className="px-3 py-2.5 text-center font-medium">Same-day</th> <TableHead className="text-right">Hours</TableHead>
<th className="px-3 py-2.5 text-right font-medium">Hours</th> <TableHead>Complexity</TableHead>
<th className="px-3 py-2.5 text-left font-medium">Complexity</th> <TableHead>Age</TableHead>
<th className="px-3 py-2.5 text-left font-medium">Age</th> </TableRow>
</tr> </TableHeader>
</thead> <TableBody>
<tbody> {data.tickets.length > 0
{data.tickets.length > 0 ? data.tickets.map(t => (
? data.tickets.map(t => ( <TicketRow
<TicketRow key={t.ticket_number}
key={t.ticket_number} t={t}
t={t} categoryFilter={catFilter}
categoryFilter={catFilter} onFilter={handleCatFilter}
onFilter={handleCatFilter} />
/> ))
)) : (
: ( <TableRow>
<tr> <TableCell colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
<td colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground"> No analyzed tickets yet run the analysis above.
No analyzed tickets yet run the analysis above. </TableCell>
</td> </TableRow>
</tr> )}
)} </TableBody>
</tbody> </Table>
</table>
</div>
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (

View file

@ -8,6 +8,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { import {
RefreshCw, CheckCircle2, AlertTriangle, WifiOff, GitCompare, RefreshCw, CheckCircle2, AlertTriangle, WifiOff, GitCompare,
Ticket, ChevronRight, ChevronDown, Sparkles, Loader2, Ticket, ChevronRight, ChevronDown, Sparkles, Loader2,
@ -326,21 +334,21 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
return ( return (
<> <>
{/* Group summary header — columns align with the detail table below */} {/* Group summary header — columns align with the detail table below */}
<tr <TableRow
className="border-b bg-muted/30 hover:bg-muted/50 cursor-pointer select-none" className="bg-muted/30 cursor-pointer select-none"
onClick={() => setOpen(o => !o)} onClick={() => setOpen(o => !o)}
> >
{/* Device col: chevron + org name */} {/* Device col: chevron + org name */}
<td className="pl-3 pr-2 py-2.5 w-44"> <TableCell className="w-44">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{open {open
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />} : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />}
<span className="font-semibold text-sm truncate">{group.org_name ?? 'Unknown'}</span> <span className="font-semibold text-sm truncate">{group.org_name ?? 'Unknown'}</span>
</div> </div>
</td> </TableCell>
{/* Match col: status pills */} {/* Match col: status pills */}
<td className="px-3 py-2.5 w-36"> <TableCell className="w-36">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{group.counts.both > 0 && ( {group.counts.both > 0 && (
<Badge variant="default" className="text-[10px] h-4 px-1">{group.counts.both} Both</Badge> <Badge variant="default" className="text-[10px] h-4 px-1">{group.counts.both} Both</Badge>
@ -355,33 +363,33 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
<Badge variant="outline" className="text-[10px] h-4 px-1">{group.counts.offline_suppressed} Offline</Badge> <Badge variant="outline" className="text-[10px] h-4 px-1">{group.counts.offline_suppressed} Offline</Badge>
)} )}
</div> </div>
</td> </TableCell>
{/* Pulse shadow col: total devices flagged */} {/* Pulse shadow col: total devices flagged */}
<td className="px-3 py-2.5 text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{actionable > 0 {actionable > 0
? <span className="font-medium text-foreground">{actionable} device{actionable !== 1 ? 's' : ''} need attention</span> ? <span className="font-medium text-foreground">{actionable} device{actionable !== 1 ? 's' : ''} need attention</span>
: <span>{group.rows.length} device{group.rows.length !== 1 ? 's' : ''}</span>} : <span>{group.rows.length} device{group.rows.length !== 1 ? 's' : ''}</span>}
</td> </TableCell>
{/* AT tickets col: ticket count */} {/* AT tickets col: ticket count */}
<td className="px-3 py-2.5 text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{group.totalAtTickets > 0 {group.totalAtTickets > 0
? <>{group.totalAtTickets} ticket{group.totalAtTickets !== 1 ? 's' : ''}{group.totalAtOpen > 0 && <span className="text-orange-500 ml-1">· {group.totalAtOpen} open</span>}</> ? <>{group.totalAtTickets} ticket{group.totalAtTickets !== 1 ? 's' : ''}{group.totalAtOpen > 0 && <span className="text-orange-500 ml-1">· {group.totalAtOpen} open</span>}</>
: <span></span>} : <span></span>}
</td> </TableCell>
</tr> </TableRow>
{/* Device detail rows */} {/* Device detail rows */}
{open && group.rows.map((row) => { {open && group.rows.map((row) => {
const cfg = STATUS_CONFIG[row.status]; const cfg = STATUS_CONFIG[row.status];
return ( return (
<tr key={row.key} className={`border-b last:border-0 hover:bg-muted/10 align-top text-xs ${cfg.rowAccent}`}> <TableRow key={row.key} className={`align-top text-xs ${cfg.rowAccent}`}>
<td className="pl-9 pr-3 py-2.5 font-mono font-medium w-44 text-[11px]"> <TableCell className="pl-9 num font-medium w-44 text-[11px]">
{row.hostname ?? <span className="italic text-muted-foreground">unknown</span>} {row.hostname ?? <span className="italic text-muted-foreground">unknown</span>}
</td> </TableCell>
<td className="px-3 py-2.5 w-36"> <TableCell className="w-36">
<Badge variant={cfg.badgeVariant} className="text-[10px]">{cfg.label}</Badge> <Badge variant={cfg.badgeVariant} className="text-[10px]">{cfg.label}</Badge>
</td> </TableCell>
<td className="px-3 py-2.5 space-y-0.5 max-w-[240px]"> <TableCell className="space-y-0.5 max-w-[240px]">
{row.pulse ? ( {row.pulse ? (
<> <>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@ -398,8 +406,8 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
)} )}
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<AtTicketCell <AtTicketCell
tickets={row.at_tickets} tickets={row.at_tickets}
ticketCount={row.at_ticket_count} ticketCount={row.at_ticket_count}
@ -407,8 +415,8 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
orgName={row.org_name} orgName={row.org_name}
hoursOffline={row.offline?.hours_offline} hoursOffline={row.offline?.hours_offline}
/> />
</td> </TableCell>
</tr> </TableRow>
); );
})} })}
</> </>
@ -533,16 +541,16 @@ export default function VeeamComparisonPage() {
<TabsContent value={filter} className="mt-4"> <TabsContent value={filter} className="mt-4">
<div className="rounded-md border overflow-hidden"> <div className="rounded-md border overflow-hidden">
<table className="w-full text-sm"> <Table>
<thead> <TableHeader className="bg-muted/50">
<tr className="border-b bg-muted/50"> <TableRow>
<th className="px-3 py-2.5 text-left font-medium text-xs w-44">Device</th> <TableHead className="text-xs w-44">Device</TableHead>
<th className="px-3 py-2.5 text-left font-medium text-xs w-36">Match</th> <TableHead className="text-xs w-36">Match</TableHead>
<th className="px-3 py-2.5 text-left font-medium text-xs">Pulse Shadow</th> <TableHead className="text-xs">Pulse Shadow</TableHead>
<th className="px-3 py-2.5 text-left font-medium text-xs">Autotask Tickets</th> <TableHead className="text-xs">Autotask Tickets</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{groups.length > 0 ? groups.map(group => ( {groups.length > 0 ? groups.map(group => (
<ClientGroupRow <ClientGroupRow
key={group.org_name ?? 'unknown'} key={group.org_name ?? 'unknown'}
@ -550,16 +558,16 @@ export default function VeeamComparisonPage() {
defaultOpen={(group.counts.both + group.counts.pulse_only) > 0} defaultOpen={(group.counts.both + group.counts.pulse_only) > 0}
/> />
)) : ( )) : (
<tr> <TableRow>
<td colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground"> <TableCell colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground">
{data.matches.length === 0 {data.matches.length === 0
? 'No data yet — RPO check must run at least once.' ? 'No data yet — RPO check must run at least once.'
: 'No rows match this filter.'} : 'No rows match this filter.'}
</td> </TableCell>
</tr> </TableRow>
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
{groups.length > 0 && ( {groups.length > 0 && (
<p className="text-xs text-muted-foreground mt-2 pl-1"> <p className="text-xs text-muted-foreground mt-2 pl-1">

View file

@ -1,35 +1,100 @@
/* DataTable paginated, sortable, optionally expandable list table.
*
* Backed by @tanstack/react-table v8 in **manual** mode: the parent owns
* data fetching, sort + search dispatch, and the page/pageSize state.
* The table itself just renders what it's given and emits user-input
* callbacks.
*
* External API is intentionally stable from the previous custom
* implementation:
*
* <DataTable
* columns={[{ key, label, sortable?, render? }]}
* data={rows}
* totalCount={n}
* page={1} pageSize={25}
* onPageChange={fn}
* onSort={(col, dir) => } // optional
* onSearch={(q) => } // optional
* onRowClick={(row) => } // optional
* isLoading={false} // optional
*
* // NEW since the TanStack rewrite:
* getRowCanExpand={(row) => boolean} // optional, default false
* renderSubRow={(row) => <>} // optional; required when expandable
* />
*
* The Column shape is the same as before (key/label/sortable/render).
* Internally we translate to ColumnDef so existing consumers keep working
* without code changes. */
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import {
type ColumnDef,
type ExpandedState,
type Row,
type SortingState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, ArrowUpDown, ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Loader2,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface Column { export interface Column<TData = any> {
key: string; key: string;
label: string; label: string;
sortable?: boolean; sortable?: boolean;
render?: (value: any, row: any) => React.ReactNode; render?: (value: any, row: TData) => React.ReactNode;
} }
interface DataTableProps { export interface DataTableProps<TData = any> {
columns: Column[]; columns: Column<TData>[];
data: any[]; data: TData[];
totalCount: number; totalCount: number;
page: number; page: number;
pageSize: number; pageSize: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onSort?: (column: string, direction: 'asc' | 'desc') => void; onSort?: (column: string, direction: 'asc' | 'desc') => void;
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
onRowClick?: (row: any) => void; onRowClick?: (row: TData) => void;
isLoading?: boolean; isLoading?: boolean;
/** Per-row gate for expansion. Return true to enable a chevron toggle. */
getRowCanExpand?: (row: TData) => boolean;
/** Renders the expanded sub-row body when a row is open. */
renderSubRow?: (row: TData) => React.ReactNode;
/** Empty-state slot. Defaults to a neutral "No results" message. */
emptyTitle?: string;
emptyDescription?: string;
} }
export default function DataTable({ export default function DataTable<TData = any>({
columns, columns,
data, data,
totalCount, totalCount,
@ -40,37 +105,97 @@ export default function DataTable({
onSearch, onSearch,
onRowClick, onRowClick,
isLoading = false, isLoading = false,
}: DataTableProps) { getRowCanExpand,
renderSubRow,
emptyTitle = 'No data found',
emptyDescription = 'Try adjusting your search or filters.',
}: DataTableProps<TData>) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [sortColumn, setSortColumn] = useState<string | null>(null); const [sorting, setSorting] = useState<SortingState>([]);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [expanded, setExpanded] = useState<ExpandedState>({});
const totalPages = Math.ceil(totalCount / pageSize); const expandable = !!renderSubRow;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const handleSort = (columnKey: string) => { // Translate the legacy Column shape into TanStack ColumnDef.
if (!onSort) return; const tanstackColumns = useMemo<ColumnDef<TData>[]>(() => {
const cols: ColumnDef<TData>[] = [];
const newDirection = sortColumn === columnKey && sortDirection === 'asc' ? 'desc' : 'asc'; // Lead expansion column when expansion is enabled.
setSortColumn(columnKey); if (expandable) {
setSortDirection(newDirection); cols.push({
onSort(columnKey, newDirection); id: '__expand',
}; header: () => null,
cell: ({ row }) =>
row.getCanExpand() ? (
<button
onClick={(e) => {
e.stopPropagation();
row.toggleExpanded();
}}
aria-label={row.getIsExpanded() ? 'Collapse row' : 'Expand row'}
className="inline-flex h-6 w-6 items-center justify-center rounded-sm hover:bg-muted/60"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
) : null,
size: 32,
});
}
for (const c of columns) {
cols.push({
id: c.key,
accessorKey: c.key,
enableSorting: !!c.sortable,
header: c.label,
cell: ({ row, getValue }) =>
c.render ? c.render(getValue(), row.original) : (getValue() as React.ReactNode),
});
}
return cols;
}, [columns, expandable]);
const table = useReactTable<TData>({
data,
columns: tanstackColumns,
state: { sorting, expanded },
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
setSorting(next);
// Defer to caller for actual data fetch.
if (onSort && next.length > 0) {
onSort(next[0].id, next[0].desc ? 'desc' : 'asc');
}
},
onExpandedChange: setExpanded,
getRowCanExpand: getRowCanExpand
? (row) => getRowCanExpand(row.original)
: () => expandable,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
manualPagination: true,
manualSorting: true,
pageCount: totalPages,
});
const handleSearch = () => { const handleSearch = () => {
if (onSearch) { onSearch?.(searchQuery);
onSearch(searchQuery);
}
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Search Bar */}
{onSearch && ( {onSearch && (
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder="Search"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
@ -84,92 +209,82 @@ export default function DataTable({
</div> </div>
)} )}
{/* Table */} <div className="border rounded-md overflow-hidden bg-card">
<div className="border rounded-lg overflow-hidden bg-card">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50"> {table.getHeaderGroups().map((headerGroup) => (
{columns.map((column) => ( <TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
<TableHead key={column.key} className="font-semibold"> {headerGroup.headers.map((header) => {
{column.sortable ? ( const sortable = header.column.getCanSort();
<Button const sortDir = header.column.getIsSorted();
variant="ghost" return (
size="sm" <TableHead
onClick={() => handleSort(column.key)} key={header.id}
className="h-8 -ml-3 hover:bg-muted/80 transition-colors" style={header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined}
className="font-semibold"
> >
{column.label} {header.isPlaceholder ? null : sortable ? (
{sortColumn === column.key ? ( <Button
sortDirection === 'asc' ? ( variant="ghost"
<ArrowUp className="ml-2 h-4 w-4" /> size="sm"
) : ( onClick={header.column.getToggleSortingHandler()}
<ArrowDown className="ml-2 h-4 w-4" /> className="h-8 -ml-3"
) >
{flexRender(header.column.columnDef.header, header.getContext())}
<SortIcon dir={sortDir === 'asc' ? 'asc' : sortDir === 'desc' ? 'desc' : null} />
</Button>
) : ( ) : (
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" /> flexRender(header.column.columnDef.header, header.getContext())
)} )}
</Button> </TableHead>
) : ( );
column.label })}
)} </TableRow>
</TableHead> ))}
))}
</TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
Array.from({ length: 5 }).map((_, index) => ( renderLoadingRows(table)
<TableRow key={index}> ) : table.getRowModel().rows.length === 0 ? (
{columns.map((column) => (
<TableCell key={column.key}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
))
) : data.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="text-center py-12"> <TableCell colSpan={tanstackColumns.length} className="py-8">
<div className="flex flex-col items-center gap-2 text-muted-foreground"> <EmptyState icon={Search} title={emptyTitle} description={emptyDescription} size="sm" />
<Search className="w-8 h-8 opacity-50" />
<p className="text-sm font-medium">No data found</p>
<p className="text-xs">Try adjusting your search or filters</p>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.map((row, index) => ( table.getRowModel().rows.map((row) => (
<TableRow <ExpandableRow
key={row.id || index} key={row.id}
className={onRowClick ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''} row={row}
onClick={() => onRowClick?.(row)} onRowClick={onRowClick}
> renderSubRow={renderSubRow}
{columns.map((column) => ( colSpan={tanstackColumns.length}
<TableCell key={column.key}> />
{column.render ? column.render(row[column.key], row) : row[column.key]}
</TableCell>
))}
</TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{/* Pagination */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2"> <div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
<div className="text-sm text-muted-foreground font-medium"> <div className="text-sm text-muted-foreground">
Showing <span className="font-semibold text-foreground">{Math.min((page - 1) * pageSize + 1, totalCount)}</span> to{' '} Showing <span className="font-medium text-foreground num">
<span className="font-semibold text-foreground">{Math.min(page * pageSize, totalCount)}</span> of{' '} {totalCount === 0 ? 0 : Math.min((page - 1) * pageSize + 1, totalCount)}
<span className="font-semibold text-foreground">{totalCount}</span> results </span>{' '}
to <span className="font-medium text-foreground num">
{Math.min(page * pageSize, totalCount)}
</span>{' '}
of <span className="font-medium text-foreground num">{totalCount}</span> results
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => onPageChange(1)} onClick={() => onPageChange(1)}
disabled={page === 1 || isLoading} disabled={page <= 1 || isLoading}
className="h-8 w-8" className="h-8 w-8"
aria-label="First page"
> >
<ChevronsLeft className="w-4 h-4" /> <ChevronsLeft className="w-4 h-4" />
</Button> </Button>
@ -177,22 +292,22 @@ export default function DataTable({
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
disabled={page === 1 || isLoading} disabled={page <= 1 || isLoading}
className="h-8 w-8" className="h-8 w-8"
aria-label="Previous page"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</Button> </Button>
<div className="flex items-center gap-1 px-3"> <span className="px-3 text-sm font-medium num">
<span className="text-sm font-medium"> Page {page} of {totalPages}
Page {page} of {totalPages || 1} </span>
</span>
</div>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => onPageChange(page + 1)} onClick={() => onPageChange(page + 1)}
disabled={page === totalPages || isLoading} disabled={page >= totalPages || isLoading}
className="h-8 w-8" className="h-8 w-8"
aria-label="Next page"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Button> </Button>
@ -200,8 +315,9 @@ export default function DataTable({
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => onPageChange(totalPages)} onClick={() => onPageChange(totalPages)}
disabled={page === totalPages || isLoading} disabled={page >= totalPages || isLoading}
className="h-8 w-8" className="h-8 w-8"
aria-label="Last page"
> >
<ChevronsRight className="w-4 h-4" /> <ChevronsRight className="w-4 h-4" />
</Button> </Button>
@ -210,3 +326,57 @@ export default function DataTable({
</div> </div>
); );
} }
function ExpandableRow<TData>({
row,
onRowClick,
renderSubRow,
colSpan,
}: {
row: Row<TData>;
onRowClick?: (row: TData) => void;
renderSubRow?: (row: TData) => React.ReactNode;
colSpan: number;
}) {
const clickable = !!onRowClick;
return (
<>
<TableRow
className={cn(clickable && 'cursor-pointer')}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{row.getIsExpanded() && renderSubRow && (
<TableRow className="bg-muted/20 hover:bg-muted/20">
<TableCell colSpan={colSpan} className="px-6 py-3">
{renderSubRow(row.original)}
</TableCell>
</TableRow>
)}
</>
);
}
function SortIcon({ dir }: { dir: 'asc' | 'desc' | null }) {
if (dir === 'asc') return <ArrowUp className="ml-2 h-4 w-4" />;
if (dir === 'desc') return <ArrowDown className="ml-2 h-4 w-4" />;
return <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />;
}
function renderLoadingRows<TData>(table: ReturnType<typeof useReactTable<TData>>) {
const cols = table.getAllLeafColumns().length;
return Array.from({ length: 5 }).map((_, i) => (
<TableRow key={`loading-${i}`}>
{Array.from({ length: cols }).map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-5 w-full" />
</TableCell>
))}
</TableRow>
));
}

View file

@ -6,79 +6,23 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, Loader2, User, Building2, MessageSquare, Clock } from 'lucide-react'; import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, Loader2, User, Building2, MessageSquare, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { StatusBadge } from '@/components/ui/status-badge';
import {
priorityBadge,
ticketStatusBadge,
sourceBadge,
classificationBadge,
companyTypeBadge,
publishBadge,
activeBadge,
yesNoBadge,
billableBadge,
approvedBadge,
toneClass,
paletteClass,
} from '@/lib/status-registry';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
// ── Static picklist maps (Autotask standard values from DB) ──────────────────
const PRIORITY_MAP: Record<number, { label: string; cls: string }> = {
2: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
3: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
4: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
6: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
7: { label: 'Very Low', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
8: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
9: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
10: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
11: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
};
const STATUS_COLOR: Record<string, string> = {
'New': 'bg-blue-500/15 text-blue-600 border border-blue-500/30',
'In Progress': 'bg-indigo-500/15 text-indigo-600 border border-indigo-500/30',
'Complete': 'bg-green-500/15 text-green-600 border border-green-500/30',
'Waiting Customer': 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
'Waiting Materials': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
'Waiting Vendor': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
'Waiting Approval': 'bg-purple-500/15 text-purple-600 border border-purple-500/30',
'On Hold': 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
'Escalate': 'bg-red-500/15 text-red-600 border border-red-500/30',
'Escalate to Wulf': 'bg-red-500/15 text-red-600 border border-red-500/30',
'Escalate to MC': 'bg-red-500/15 text-red-600 border border-red-500/30',
'Resource Assigned': 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30',
'Service Call Scheduled': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
'Dispatched': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
'Resolved <CSAT Survey>': 'bg-green-500/15 text-green-600 border border-green-500/30',
};
const SOURCE_MAP: Record<number, string> = {
[-2]: 'System', [-1]: 'Internal',
1: 'Phone', 2: 'Email', 4: 'Web Portal', 6: 'Monitoring Alert',
8: 'RMM Alert', 17: 'Chat', 21: 'API', 22: 'Automation',
27: 'In Person', 29: 'Client Portal', 30: 'Microsoft Teams',
31: 'Webhook', 33: 'Datto RMM', 34: 'Rewst', 35: 'TimeZest',
36: 'DeskDirector', 38: 'Huntress', 39: 'Blumira', 40: 'SentinelOne',
};
const CLASSIFICATION_MAP: Record<number, { label: string; cls: string }> = {
5: { label: 'Block Hour', cls: 'bg-sky-500/15 text-sky-600 border border-sky-500/30' },
9: { label: 'Canceled', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
202: { label: 'Co-Managed', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' },
16: { label: 'Gold (Legacy)', cls: 'bg-yellow-500/15 text-yellow-600 border border-yellow-500/30' },
206: { label: 'IT Complete w/ Gold Security', cls: 'bg-amber-500/15 text-amber-600 border border-amber-500/30' },
207: { label: 'IT Core / Silver Security', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
203: { label: 'IT Foundation / Bronze Security', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
205: { label: 'IT Premier / Platinum Security', cls: 'bg-violet-500/15 text-violet-600 border border-violet-500/30' },
14: { label: 'Jeopardy Company', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
201: { label: 'Partner', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' },
15: { label: 'Platinum (Legacy)', cls: 'bg-violet-500/15 text-violet-600 border border-violet-500/30' },
13: { label: 'Residential (no-pay)', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
17: { label: 'Silver (Legacy)', cls: 'bg-zinc-500/15 text-zinc-600 border border-zinc-500/30' },
12: { label: 'T&M', cls: 'bg-teal-500/15 text-teal-600 border border-teal-500/30' },
7: { label: 'Target', cls: 'bg-emerald-500/15 text-emerald-600 border border-emerald-500/30' },
200: { label: 'Tools Only', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
18: { label: 'Bronze (Legacy)', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
};
const COMPANY_TYPE_MAP: Record<number, { label: string; cls: string }> = {
1: { label: 'Customer', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
2: { label: 'Lead', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
3: { label: 'Prospect', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' },
4: { label: 'Dead', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
6: { label: 'Cancelation', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
7: { label: 'Vendor', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
8: { label: 'Partner', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' },
};
// ── Live lookup types (fetched from DB) ─────────────────────────────────────── // ── Live lookup types (fetched from DB) ───────────────────────────────────────
interface Lookups { interface Lookups {
@ -188,23 +132,24 @@ const COMPANY_GROUPS: FieldGroup[] = [
// ── Helpers ──────────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────────
function ColorBadge({ cls, children }: { cls: string; children: React.ReactNode }) {
return <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cls}`}>{children}</span>;
}
function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { display: React.ReactNode; isEmpty: boolean } { function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { display: React.ReactNode; isEmpty: boolean } {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return { display: <span className="text-muted-foreground/40 italic text-xs"></span>, isEmpty: true }; return { display: <span className="text-muted-foreground/40 italic text-xs"></span>, isEmpty: true };
} }
switch (type) { switch (type) {
case 'bool': case 'bool': {
const badge = yesNoBadge(Boolean(value));
return { return {
display: value display: (
? <ColorBadge cls="bg-green-500/15 text-green-600 border border-green-500/30"><Check className="w-3 h-3 mr-1" />Yes</ColorBadge> <StatusBadge variantClass={badge.variantClass}>
: <ColorBadge cls="bg-slate-500/15 text-slate-500 border border-slate-500/30"><X className="w-3 h-3 mr-1" />No</ColorBadge>, {value ? <Check className="w-3 h-3 mr-1" /> : <X className="w-3 h-3 mr-1" />}
{badge.label}
</StatusBadge>
),
isEmpty: false, isEmpty: false,
}; };
}
case 'date': { case 'date': {
try { try {
const d = new Date(value); const d = new Date(value);
@ -221,28 +166,24 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look
} }
case 'status': { case 'status': {
const label = lookups.statuses[Number(value)] ?? `Status ${value}`; const label = lookups.statuses[Number(value)] ?? `Status ${value}`;
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border'; const badge = ticketStatusBadge(label);
return { display: <ColorBadge cls={cls}>{label}</ColorBadge>, isEmpty: false }; return { display: <StatusBadge {...badge} />, isEmpty: false };
} }
case 'priority': { case 'priority': {
const p = PRIORITY_MAP[Number(value)]; return { display: <StatusBadge {...priorityBadge(Number(value))} />, isEmpty: false };
return { display: <ColorBadge cls={p?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{p?.label ?? `Priority ${value}`}</ColorBadge>, isEmpty: false };
} }
case 'source': { case 'source': {
const label = SOURCE_MAP[Number(value)] ?? `Source ${value}`; return { display: <StatusBadge {...sourceBadge(Number(value))} />, isEmpty: false };
return { display: <ColorBadge cls="bg-violet-500/15 text-violet-600 border border-violet-500/30">{label}</ColorBadge>, isEmpty: false };
} }
case 'queue': { case 'queue': {
const qLabel = lookups.queues[Number(value)] ?? `Queue ${value}`; const label = lookups.queues[Number(value)] ?? `Queue ${value}`;
return { display: <ColorBadge cls="bg-indigo-500/15 text-indigo-600 border border-indigo-500/30">{qLabel}</ColorBadge>, isEmpty: false }; return { display: <StatusBadge variantClass={paletteClass('indigo')}>{label}</StatusBadge>, isEmpty: false };
} }
case 'company_type': { case 'company_type': {
const ct = COMPANY_TYPE_MAP[Number(value)]; return { display: <StatusBadge {...companyTypeBadge(Number(value))} />, isEmpty: false };
return { display: <ColorBadge cls={ct?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{ct?.label ?? `Type ${value}`}</ColorBadge>, isEmpty: false };
} }
case 'classification': { case 'classification': {
const cl = CLASSIFICATION_MAP[Number(value)]; return { display: <StatusBadge {...classificationBadge(Number(value))} />, isEmpty: false };
return { display: <ColorBadge cls={cl?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{cl?.label ?? `Classification ${value}`}</ColorBadge>, isEmpty: false };
} }
case 'resource': { case 'resource': {
const name = lookups.resources[Number(value)]; const name = lookups.resources[Number(value)];
@ -264,11 +205,11 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look
} }
case 'issue_type': { case 'issue_type': {
const label = lookups.issueTypes[Number(value)] ?? `Issue ${value}`; const label = lookups.issueTypes[Number(value)] ?? `Issue ${value}`;
return { display: <ColorBadge cls="bg-sky-500/15 text-sky-600 border border-sky-500/30">{label}</ColorBadge>, isEmpty: false }; return { display: <StatusBadge variantClass={paletteClass('sky')}>{label}</StatusBadge>, isEmpty: false };
} }
case 'sub_issue_type': { case 'sub_issue_type': {
const label = lookups.subIssueTypes[Number(value)] ?? `Sub-Issue ${value}`; const label = lookups.subIssueTypes[Number(value)] ?? `Sub-Issue ${value}`;
return { display: <ColorBadge cls="bg-sky-500/10 text-sky-500 border border-sky-500/20">{label}</ColorBadge>, isEmpty: false }; return { display: <StatusBadge variantClass="bg-sky-500/10 text-sky-700 dark:text-sky-400">{label}</StatusBadge>, isEmpty: false };
} }
case 'config_item': { case 'config_item': {
const name = lookups.configItems[Number(value)]; const name = lookups.configItems[Number(value)];
@ -413,8 +354,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
<div className="shrink-0 flex flex-col items-end gap-1"> <div className="shrink-0 flex flex-col items-end gap-1">
{(() => { {(() => {
const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`; const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`;
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border'; return <StatusBadge {...ticketStatusBadge(label)} />;
return <ColorBadge cls={cls}>{label}</ColorBadge>;
})()} })()}
</div> </div>
</div> </div>
@ -427,9 +367,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
</DialogDescription> </DialogDescription>
</div> </div>
{'is_active' in data && ( {'is_active' in data && (
<ColorBadge cls={data.is_active ? 'bg-green-500/15 text-green-600 border border-green-500/30' : 'bg-slate-500/15 text-slate-500 border border-slate-500/30'}> <StatusBadge {...activeBadge(Boolean(data.is_active))} />
{data.is_active ? 'Active' : 'Inactive'}
</ColorBadge>
)} )}
</div> </div>
)} )}
@ -652,10 +590,10 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
</span> </span>
)} )}
{entry.billable && ( {entry.billable && (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-green-500/15 text-green-600 border border-green-500/30">Billable</span> <StatusBadge {...billableBadge(true)} />
)} )}
{entry.approved && ( {entry.approved && (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-500/15 text-blue-600 border border-blue-500/30">Approved</span> <StatusBadge {...approvedBadge(true)} />
)} )}
</div> </div>
{entry.notes && ( {entry.notes && (
@ -695,14 +633,6 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{notes.map((note) => { {notes.map((note) => {
const publishCls: Record<number, string> = {
1: 'bg-green-500/15 text-green-600 border border-green-500/30',
2: 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
4: 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
};
const publishLabel: Record<number, string> = {
1: 'All Users', 2: 'Internal', 4: 'Internal Only',
};
return ( return (
<div key={note.id} className="rounded-lg border p-4 space-y-2"> <div key={note.id} className="rounded-lg border p-4 space-y-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@ -714,9 +644,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
</span> </span>
)} )}
{note.publish != null && ( {note.publish != null && (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${publishCls[note.publish] ?? 'bg-muted text-muted-foreground border border-border'}`}> <StatusBadge {...publishBadge(Number(note.publish))} />
{publishLabel[note.publish] ?? `Publish ${note.publish}`}
</span>
)} )}
{note.title && ( {note.title && (
<span className="text-sm font-semibold text-foreground">{note.title}</span> <span className="text-sm font-semibold text-foreground">{note.title}</span>

View file

@ -0,0 +1,28 @@
/* TaglineFooter bottom-of-page brand line.
*
* "Don't be afraid to cry · Wulf Consulting" in Helvetica Light gray, per
* the standards guide. Mounted once in app/layout.tsx; never inline this
* elsewhere. Hidden on /mobile (which has its own shell). */
'use client';
import { usePathname } from 'next/navigation';
import { Separator } from '@/components/ui/separator';
export function TaglineFooter() {
const pathname = usePathname();
if (pathname?.startsWith('/mobile') || pathname?.startsWith('/kiosk')) {
return null;
}
return (
<footer className="container mx-auto px-6 pt-8 pb-6">
<Separator className="mb-3" />
<p className="tagline flex flex-wrap items-center gap-x-2 gap-y-1">
<span>Don&apos;t be afraid to cry</span>
<span aria-hidden="true">·</span>
<span>Wulf Consulting</span>
</p>
</footer>
);
}

View file

@ -0,0 +1,44 @@
/* Wulf Consulting brand mark.
* Wraps the canonical PNG assets shipped from the standards guide:
* public/branding/wulf-mark.png W mark only (412×290 ratio)
* public/branding/wulf-wordmark.png full "Wulf Consulting" wordmark
*
* Use `variant="mark"` for the W glyph alone (top-bar brand link, watermark)
* and `variant="wordmark"` for the full lockup (login screen, marketing). */
import Image from 'next/image';
import { cn } from '@/lib/utils';
interface WulfMarkProps {
variant?: 'mark' | 'wordmark';
className?: string;
alt?: string;
priority?: boolean;
}
export function WulfMark({
variant = 'mark',
className,
alt,
priority = false,
}: WulfMarkProps) {
const src =
variant === 'wordmark'
? '/branding/wulf-wordmark.png'
: '/branding/wulf-mark.png';
const dimensions =
variant === 'wordmark'
? { width: 686, height: 290 }
: { width: 412, height: 290 };
return (
<Image
src={src}
width={dimensions.width}
height={dimensions.height}
priority={priority}
alt={alt ?? (variant === 'wordmark' ? 'Wulf Consulting' : 'Wulf')}
className={cn('select-none', className)}
/>
);
}

View file

@ -0,0 +1,73 @@
/* ActiveEngineers top engineers today by hours logged.
*
* Compact list: name + ticket count + hours bar. Sorted by hours
* desc upstream. Empty when no time has been logged yet today. */
'use client';
import { Activity } from 'lucide-react';
import { EmptyState } from '@/components/ui/empty-state';
interface Engineer {
resourceId: string;
name: string;
hours: number;
ticketsTouched: number;
}
interface ActiveEngineersProps {
data: Engineer[];
}
export function ActiveEngineers({ data }: ActiveEngineersProps) {
if (data.length === 0) {
return (
<EmptyState
icon={Activity}
title="No time logged today"
description="Engineers will appear here as they post time entries."
size="sm"
/>
);
}
const max = data.reduce((m, e) => Math.max(m, e.hours), 0) || 1;
const totalHours = data.reduce((s, e) => s + e.hours, 0);
const totalTickets = data.reduce((s, e) => s + e.ticketsTouched, 0);
return (
<div className="space-y-1">
{data.map((e) => {
const pct = (e.hours / max) * 100;
return (
<div key={e.resourceId} className="grid grid-cols-[1fr_auto] items-center gap-3 py-1">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{e.name}</div>
<div className="relative h-1 w-full bg-muted rounded-sm mt-1 overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-primary/70"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<div className="text-right shrink-0">
<div className="num text-sm">{e.hours.toFixed(1)}h</div>
<div className="text-xs text-muted-foreground">
<span className="num">{e.ticketsTouched}</span>{' '}
ticket{e.ticketsTouched === 1 ? '' : 's'}
</div>
</div>
</div>
);
})}
<div className="border-t pt-2 mt-2 flex justify-between text-xs text-muted-foreground">
<span>Total today</span>
<span>
<span className="num">{totalHours.toFixed(1)}h</span>{' '}
across <span className="num">{totalTickets}</span>{' '}
ticket{totalTickets === 1 ? '' : 's'}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,123 @@
/* KpiCard primary unit of the operations dashboard.
*
*
* LABEL
* 1,247 12 vs yesterday
* optional caption
*
*
* Numerics use the .num-xl utility (Plex Mono, tabular-nums, large).
* Tone "accent" gets a 2px Wulf-blue left border.
* Tone "warn" gets the destructive border when value > 0. */
import Link from 'next/link';
import { ArrowRight, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
export type KpiTone = 'default' | 'accent' | 'warn' | 'attention';
interface KpiDelta {
/** Numeric delta (+ for up, - for down). */
value: number;
/** Suffix shown after the delta. e.g. "vs yesterday". */
label?: string;
/** When true, "down" is good (e.g., SLA breaches). */
invertedSentiment?: boolean;
}
interface KpiCardProps {
label: string;
/** Display value. number formatted with locale, string passed through. null → em-dash. */
value: number | string | null | undefined;
delta?: KpiDelta;
caption?: React.ReactNode;
tone?: KpiTone;
href?: string;
loading?: boolean;
}
const TONE_STYLES: Record<KpiTone, string> = {
default: 'border-l-transparent',
accent: 'border-l-primary',
warn: 'border-l-amber-500',
attention: 'border-l-destructive',
};
export function KpiCard({
label,
value,
delta,
caption,
tone = 'default',
href,
loading = false,
}: KpiCardProps) {
const display =
value === null || value === undefined
? '—'
: typeof value === 'number'
? value.toLocaleString()
: value;
const inner = (
<Card
className={cn(
'h-full border-l-2 transition-shadow',
TONE_STYLES[tone],
href && 'hover:shadow-sm',
)}
>
<CardContent className="pt-4 pb-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="metric-label">{label}</span>
{href && <ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
<div className="flex items-baseline gap-3">
{loading ? (
<span className="h-9 w-24 animate-pulse rounded bg-muted" />
) : (
<span className="num-xl">{display}</span>
)}
{delta && !loading && <DeltaIndicator delta={delta} />}
</div>
{caption && !loading && (
<div className="text-xs text-muted-foreground">{caption}</div>
)}
</CardContent>
</Card>
);
return href ? (
<Link href={href} className="block">
{inner}
</Link>
) : (
inner
);
}
function DeltaIndicator({ delta }: { delta: KpiDelta }) {
const { value, label, invertedSentiment = false } = delta;
if (value === 0) {
return (
<span className="num text-xs text-muted-foreground inline-flex items-center gap-1">
<Minus className="h-3 w-3" />
{label ?? 'no change'}
</span>
);
}
const positive = value > 0;
const good = invertedSentiment ? !positive : positive;
const colorClass = good
? 'text-emerald-600 dark:text-emerald-400'
: 'text-destructive';
const Icon = positive ? ArrowUpRight : ArrowDownRight;
return (
<span className={cn('num text-xs inline-flex items-center gap-1', colorClass)}>
<Icon className="h-3 w-3" />
{Math.abs(value)}
{label && <span className="text-muted-foreground ml-1">{label}</span>}
</span>
);
}

View file

@ -0,0 +1,134 @@
/* QueueHeatmap open tickets by queue × priority.
*
* Geometric grid; each cell is a square tinted with the brand blue.
* Intensity scales linearly to the most-loaded cell (so the heaviest
* cell renders at full saturation). Per-row totals on the right; the
* column headers carry priority labels.
*
* Empty cells render as a thin dashed outline rather than nothing
* preserves the grid alignment and makes the absence visible. */
'use client';
import { priorityBadge } from '@/lib/status-registry';
import { cn } from '@/lib/utils';
interface HeatmapRow {
queueId: number;
queueLabel: string;
total: number;
byPriority: Record<number, number>;
}
interface QueueHeatmapProps {
data: HeatmapRow[];
/** Priority IDs in column order. Defaults to Critical→Very Low. */
priorities?: number[];
}
const DEFAULT_PRIORITIES = [2, 3, 4, 6, 7];
export function QueueHeatmap({ data, priorities = DEFAULT_PRIORITIES }: QueueHeatmapProps) {
const max = data.reduce(
(m, row) => Math.max(m, ...priorities.map((p) => row.byPriority[p] ?? 0)),
1,
);
if (data.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4">
No open tickets.
</p>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm border-separate border-spacing-x-1 border-spacing-y-0">
<thead>
<tr>
<th className="text-left metric-label py-1 pr-3">Queue</th>
{priorities.map((p) => {
const badge = priorityBadge(p);
return (
<th key={p} className="metric-label py-1 px-1 text-center w-12">
{badge.label.slice(0, 3)}
</th>
);
})}
<th className="metric-label py-1 pl-3 text-right">Total</th>
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.queueId} className="hover:bg-muted/30">
<td
className="py-1 pr-3 truncate max-w-[260px]"
title={row.queueLabel}
>
{row.queueLabel}
</td>
{priorities.map((p) => (
<Cell
key={p}
value={row.byPriority[p] ?? 0}
max={max}
priorityLabel={priorityBadge(p).label}
queueLabel={row.queueLabel}
/>
))}
<td className="py-1 pl-3 num text-right text-muted-foreground">
{row.total}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function Cell({
value,
max,
priorityLabel,
queueLabel,
}: {
value: number;
max: number;
priorityLabel: string;
queueLabel: string;
}) {
if (value === 0) {
return (
<td className="py-1 px-1">
<div
className="h-7 w-full rounded-sm border border-dashed border-border/50"
aria-label="0"
/>
</td>
);
}
// Normalize 0..1 then floor into one of 6 opacity steps; smallest cell still
// reads, largest is at 0.85.
const ratio = Math.min(value / max, 1);
const opacity = Math.max(0.18, ratio * 0.85);
return (
<td className="py-1 px-1">
<div
className={cn(
'h-7 w-full rounded-sm flex items-center justify-center num text-xs',
'transition-opacity hover:opacity-100',
)}
style={{
backgroundColor: `color-mix(in oklch, var(--primary) ${Math.round(opacity * 100)}%, transparent)`,
color: ratio > 0.55 ? 'var(--primary-foreground)' : 'var(--foreground)',
}}
title={`${queueLabel} · ${priorityLabel}: ${value}`}
aria-label={`${queueLabel}, ${priorityLabel}: ${value}`}
>
{value}
</div>
</td>
);
}

View file

@ -0,0 +1,75 @@
/* ResolutionTrend line chart of average resolution hours per day completed,
* last 30 days. Single series, Wulf Blue stroke. */
'use client';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface ResolutionPoint {
date: string;
avgHours: number | null;
}
interface ResolutionTrendProps {
data: ResolutionPoint[];
height?: number;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function ResolutionTrend({ data, height = 180 }: ResolutionTrendProps) {
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="2 4" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={fmtDate}
interval="preserveStartEnd"
minTickGap={48}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={32}
tickFormatter={(v: number) => `${v}h`}
/>
<Tooltip
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 6,
fontSize: 12,
}}
labelFormatter={(value) => fmtDate(String(value))}
formatter={(value) =>
value == null ? ['—', 'avg'] : [`${Number(value).toFixed(1)} h`, 'avg']
}
/>
<Line
type="monotone"
dataKey="avgHours"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,78 @@
/* VolumeTrend area chart of tickets opened per day for the last 30 days.
*
* Single series, Wulf Blue fill at 20%. No grid, minimal axes the
* shape is what matters. Tooltip carries the exact count + date. */
'use client';
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface VolumePoint {
date: string;
count: number;
}
interface VolumeTrendProps {
data: VolumePoint[];
height?: number;
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function VolumeTrend({ data, height = 180 }: VolumeTrendProps) {
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 0 }}>
<defs>
<linearGradient id="volumeFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--primary)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--primary)" stopOpacity={0.02} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
tickFormatter={fmtDate}
interval="preserveStartEnd"
minTickGap={48}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={28}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 6,
fontSize: 12,
}}
labelFormatter={(value) => fmtDate(String(value))}
formatter={(value) => [value ?? 0, 'opened']}
/>
<Area
type="monotone"
dataKey="count"
stroke="var(--primary)"
strokeWidth={1.5}
fill="url(#volumeFill)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
);
}

View file

@ -29,6 +29,9 @@ import {
} from '@/components/ui/navigation-menu'; } from '@/components/ui/navigation-menu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ThemeToggle } from '@/components/theme-toggle'; import { ThemeToggle } from '@/components/theme-toggle';
import { StatusIndicator } from '@/components/navigation/status-indicator';
import { UserMenu } from '@/components/navigation/user-menu';
import { MobileNav } from '@/components/navigation/mobile-nav';
import { useSession } from '@/lib/auth-client'; import { useSession } from '@/lib/auth-client';
interface NavItem { interface NavItem {
@ -132,9 +135,51 @@ const navigationItems: NavItem[] = [
}, },
{ {
title: 'Admin', title: 'Admin',
href: '/admin',
icon: Activity, icon: Activity,
description: 'Sync, mappings, workflow, reports, tools & access' children: [
{
title: 'Admin home',
href: '/admin',
icon: Activity,
description: 'Tile index of every admin tool',
},
{
title: 'Sync schedules',
href: '/admin/sync',
icon: GitCompare,
description: 'Cron-driven syncs for Autotask, IT Glue, Veeam, Engagement, etc.',
},
{
title: 'Workflow rules',
href: '/admin/workflow',
icon: Brain,
description: 'Classification, AI prompts, ticket digest, and execution history',
},
{
title: 'RMM Overshell',
href: '/admin/rmm-overshell',
icon: Activity,
description: 'Curated Datto RMM scripts; recent executions and rate limits',
},
{
title: 'IT Glue write log',
href: '/admin/itglue-writes',
icon: Database,
description: 'Audit-driven changes pushed back to IT Glue; revert from here',
},
{
title: 'Device-link conflicts',
href: '/admin/device-link-conflicts',
icon: AlertTriangle,
description: 'Cross-system mappings needing a human decision',
},
{
title: 'Users & roles',
href: '/admin/users',
icon: Users,
description: 'Invite users, manage roles and permissions',
},
],
}, },
]; ];
@ -163,133 +208,116 @@ export function AppNavigation() {
return ( return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-6 flex h-16 items-center justify-between"> <div className="container mx-auto px-6 flex h-16 items-center justify-between">
{/* Logo and App Name */} {/* Mobile hamburger + brand */}
<Link href="/" className="flex items-center space-x-3 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<img <MobileNav items={visibleItems} pathname={pathname} isActive={isActive} />
src="/wulff-logo.png" <Link href="/" className="flex items-center space-x-3">
alt="Wulf Consulting" <img
className="h-10 w-auto" src="/wulff-logo.png"
/> alt="Wulf Consulting"
<div className="hidden sm:block"> className="h-10 w-auto"
<h1 className="text-xl font-semibold tracking-tight">Pulse</h1> />
<p className="text-xs text-muted-foreground">PSA Management System</p> <div className="hidden sm:block">
</div> <h1 className="text-xl font-semibold tracking-tight">Pulse</h1>
</Link> <p className="text-xs text-muted-foreground">Operations console</p>
</div>
</Link>
</div>
{/* Main Navigation — centered */} {/* Main Navigation — centered, desktop only */}
<NavigationMenu> <NavigationMenu className="hidden md:flex">
<NavigationMenuList> <NavigationMenuList>
{visibleItems.map((item) => ( {visibleItems.map((item) => {
<NavigationMenuItem key={item.title}> const childActive = item.children?.some((c) => isActive(c.href)) ?? false;
{item.children ? ( const flatActive = isActive(item.href);
<> // Brand-blue 2px underline marks active state — echoes the
<NavigationMenuTrigger className={cn( // PageHeader rule rather than filling the button with primary.
"h-9 px-4 py-2", const activeRule = 'relative after:absolute after:inset-x-2 after:bottom-0 after:h-[2px] after:bg-primary after:rounded-full';
item.children.some(child => isActive(child.href)) && "bg-primary text-primary-foreground" return (
)}> <NavigationMenuItem key={item.title}>
{item.icon && <item.icon className="w-4 h-4 mr-2" />} {item.children ? (
{item.title} <>
</NavigationMenuTrigger> <NavigationMenuTrigger
<NavigationMenuContent> className={cn(
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]"> 'h-9 px-4 py-2',
{item.children.map((child) => ( childActive && cn(activeRule, 'text-foreground'),
<li key={child.title}> )}
<NavigationMenuLink asChild> >
<Link {item.icon && <item.icon className="w-4 h-4 mr-2" />}
href={child.href || '#'} {item.title}
className={cn( </NavigationMenuTrigger>
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-primary/10 hover:text-primary focus:bg-primary/10 focus:text-primary", <NavigationMenuContent>
isActive(child.href) && "bg-primary text-primary-foreground" <ul className="grid gap-1 p-2 min-w-[320px] max-w-[440px]">
)} {item.children.map((child) => {
> const active = isActive(child.href);
<div className="flex items-center text-sm font-medium leading-none"> return (
{child.icon && <child.icon className="w-4 h-4 mr-2" />} <li key={child.title}>
{child.title} <NavigationMenuLink asChild>
</div> <Link
{child.description && ( href={child.href || '#'}
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground"> className={cn(
{child.description} 'flex items-start gap-3 rounded-sm px-3 py-2 leading-none no-underline outline-none transition-colors',
</p> 'hover:bg-accent/40 focus:bg-accent/40',
)} active && 'bg-primary/10 text-primary',
</Link> )}
</NavigationMenuLink> >
</li> {child.icon && (
))} <child.icon
</ul> className={cn(
</NavigationMenuContent> 'w-4 h-4 mt-0.5 shrink-0',
</> active ? 'text-primary' : 'text-muted-foreground',
) : ( )}
<Link href={item.href || '#'} legacyBehavior passHref> />
<NavigationMenuLink className={cn( )}
navigationMenuTriggerStyle(), <div className="min-w-0 flex-1">
"h-9", <div className={cn('text-sm font-medium leading-none', active && 'text-primary')}>
isActive(item.href) && "bg-primary text-primary-foreground" {child.title}
)}> </div>
{item.icon && <item.icon className="w-4 h-4 mr-2" />} {child.description && (
{item.title} <p className="mt-1 line-clamp-2 text-xs leading-snug text-muted-foreground">
</NavigationMenuLink> {child.description}
</Link> </p>
)} )}
</NavigationMenuItem> </div>
))} </Link>
</NavigationMenuLink>
</li>
);
})}
</ul>
</NavigationMenuContent>
</>
) : (
<Link href={item.href || '#'} legacyBehavior passHref>
<NavigationMenuLink
className={cn(
navigationMenuTriggerStyle(),
'h-9',
flatActive && cn(activeRule, 'text-foreground'),
)}
>
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
{item.title}
</NavigationMenuLink>
</Link>
)}
</NavigationMenuItem>
);
})}
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
{/* Right Side Actions */} {/* Right Side Actions */}
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<StatusIndicator />
<ThemeToggle /> <ThemeToggle />
<UserMenu />
</div> </div>
</div> </div>
</header> </header>
); );
} }
// Breadcrumb component for secondary navigation // PageHeader moved to ./page-header.tsx; re-exported for backwards compatibility.
export interface BreadcrumbItem { export { PageHeader } from '@/components/navigation/page-header';
label: string; export type { BreadcrumbItem, PageHeaderProps } from '@/components/navigation/page-header';
href?: string;
}
interface PageHeaderProps {
title: string;
description?: string;
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
}
export function PageHeader({ title, description, breadcrumbs, actions }: PageHeaderProps) {
return (
<div className="border-b">
<div className="container mx-auto px-6 py-4">
{/* Breadcrumbs */}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav className="flex items-center space-x-2 text-sm text-muted-foreground mb-2">
{breadcrumbs.map((crumb, index) => (
<div key={index} className="flex items-center">
{index > 0 && <span className="mx-2">/</span>}
{crumb.href ? (
<Link href={crumb.href} className="hover:text-foreground transition-colors">
{crumb.label}
</Link>
) : (
<span className="text-foreground">{crumb.label}</span>
)}
</div>
))}
</nav>
)}
{/* Title and Actions */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground mt-1">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,149 @@
/* MobileNav hamburger menu for sub-md viewports.
*
* Reuses the same nav config that the desktop NavigationMenu consumes
* (passed in via prop) so the IA stays in sync. Renders as a Sheet
* sliding in from the left, with the same active-route treatment
* (2 px brand-blue underline). */
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Menu } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
interface NavItem {
title: string;
href?: string;
icon?: React.ElementType;
description?: string;
children?: NavItem[];
}
interface MobileNavProps {
items: NavItem[];
pathname: string;
isActive: (href?: string) => boolean;
}
export function MobileNav({ items, pathname, isActive }: MobileNavProps) {
const [open, setOpen] = useState(false);
// Close the sheet when the path changes — handles the link click before
// useEffect from a route subscription. Effect would also work but this
// is simpler and side-effect-free.
const handleNav = () => setOpen(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="md:hidden h-9 w-9"
aria-label="Open navigation"
>
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-72 p-0 flex flex-col">
<SheetHeader className="border-b">
<SheetTitle className="px-4 py-3 text-left">Pulse</SheetTitle>
</SheetHeader>
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
{items.map((item) => (
<MobileNavGroup
key={item.title}
item={item}
isActive={isActive}
onNav={handleNav}
/>
))}
</nav>
<Separator />
<p className="tagline px-4 py-3">
Don&apos;t be afraid to cry · Wulf Consulting
</p>
</SheetContent>
</Sheet>
);
}
function MobileNavGroup({
item,
isActive,
onNav,
}: {
item: NavItem;
isActive: (href?: string) => boolean;
onNav: () => void;
}) {
if (!item.children) {
const active = isActive(item.href);
return (
<Link
href={item.href || '#'}
onClick={onNav}
className={cn(
'flex items-center gap-3 rounded-sm px-3 py-2 text-sm transition-colors',
'hover:bg-accent/50',
active
? 'text-primary font-medium border-l-2 border-primary pl-[10px]'
: 'text-foreground border-l-2 border-transparent pl-[10px]',
)}
>
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
{item.title}
</Link>
);
}
const groupActive = item.children.some((c) => isActive(c.href));
return (
<div className="space-y-0.5">
<p
className={cn(
'flex items-center gap-3 px-3 pt-3 pb-1 metric-label',
groupActive && 'text-primary',
)}
>
{item.icon && <item.icon className="h-3.5 w-3.5 shrink-0" />}
{item.title}
</p>
<div className="ml-2 space-y-0.5">
{item.children.map((child) => {
const active = isActive(child.href);
return (
<Link
key={child.title}
href={child.href || '#'}
onClick={onNav}
className={cn(
'flex items-center gap-3 rounded-sm px-3 py-1.5 text-sm transition-colors',
'hover:bg-accent/50',
active
? 'text-primary font-medium border-l-2 border-primary pl-[10px]'
: 'text-foreground border-l-2 border-transparent pl-[10px]',
)}
>
{child.icon && <child.icon className="h-4 w-4 shrink-0 text-muted-foreground" />}
<span className="truncate">{child.title}</span>
</Link>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
/* PageHeader bordered page-title block.
*
* Sits below <AppNavigation /> at the top of any page. Owns the page
* title, description, breadcrumbs, and a right-side actions slot.
*
* Two brand props:
* accent replaces the bottom border with a 2px Wulf-blue rule,
* echoing the standards-guide blue header band.
* watermark renders the W mark behind the title at very low
* opacity, giving the page a quiet brand signature.
*
* Re-exported from components/navigation/app-navigation.tsx for
* back-compat; new code can import from here directly. */
import Link from 'next/link';
import { cn } from '@/lib/utils';
import { WulfMark } from '@/components/branding/wulf-mark';
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface PageHeaderProps {
title: string;
description?: string;
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
/** Replace the standard border-b with a 2px Wulf-blue rule. */
accent?: boolean;
/** Render the W mark watermark behind the title at ~4% opacity. */
watermark?: boolean;
}
export function PageHeader({
title,
description,
breadcrumbs,
actions,
accent = false,
watermark = false,
}: PageHeaderProps) {
return (
<div className={cn(accent ? 'rule-brand' : 'border-b')}>
<div className={cn('container mx-auto px-6 py-4', watermark && 'has-mark-watermark')}>
{watermark && (
<span className="mark-watermark" aria-hidden="true">
<WulfMark variant="mark" alt="" />
</span>
)}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav
aria-label="Breadcrumb"
className="flex items-center space-x-2 text-sm text-muted-foreground mb-2"
>
{breadcrumbs.map((crumb, index) => (
<div key={`${crumb.label}-${index}`} className="flex items-center">
{index > 0 && <span className="mx-2" aria-hidden="true">/</span>}
{crumb.href ? (
<Link
href={crumb.href}
className="hover:text-foreground transition-colors"
>
{crumb.label}
</Link>
) : (
<span className="text-foreground" aria-current="page">
{crumb.label}
</span>
)}
</div>
))}
</nav>
)}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground mt-1">{description}</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2 shrink-0">{actions}</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
/* StatusIndicator top-bar entry point to /status.
*
* Polls /api/dashboard/integration-health every 60 s, rolls up overall
* state into a single StatusLight, and links to /status. Title attribute
* gives a quick textual hint; click goes to the full page. */
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
interface HealthSummary {
failed: number;
expired: number;
expiringWithin14Days: number;
hasIssues: boolean;
}
const POLL_MS = 60_000;
export function StatusIndicator() {
const [summary, setSummary] = useState<HealthSummary | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch('/api/dashboard/integration-health', { cache: 'no-store' });
if (!res.ok || cancelled) return;
const j = (await res.json()) as { summary: HealthSummary };
if (!cancelled) setSummary(j.summary);
} catch {
/* leave summary null — light renders idle */
}
}
void load();
const id = setInterval(() => void load(), POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
const state: StatusLightState = !summary
? 'idle'
: summary.failed > 0 || summary.expired > 0
? 'error'
: summary.expiringWithin14Days > 0
? 'warn'
: 'ok';
const title = !summary
? 'System status'
: state === 'error'
? `${summary.failed + summary.expired} integration issue(s)`
: state === 'warn'
? `${summary.expiringWithin14Days} token(s) expiring soon`
: 'All systems operational';
return (
<Link
href="/status"
title={title}
aria-label={title}
className="inline-flex h-9 w-9 items-center justify-center rounded-md hover:bg-accent/40 transition-colors"
>
<StatusLight state={state} size="md" label={title} />
</Link>
);
}

View file

@ -0,0 +1,100 @@
/* UserMenu top-bar dropdown anchored to the signed-in user.
*
* Shows the user's name, email, and role pill at the top, then offers
* shortcuts to /settings and /settings/security, and a sign-out action
* that bounces back to /auth/sign-in. */
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useSession, signOut } from '@/lib/auth-client';
import { LogOut, User as UserIcon, ShieldCheck, Settings } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { StatusBadge } from '@/components/ui/status-badge';
import { Button } from '@/components/ui/button';
export function UserMenu() {
const { data: session } = useSession();
const router = useRouter();
const user = session?.user as
| { name?: string; email?: string; role?: string }
| undefined;
if (!user) return null;
const initials = (user.name ?? user.email ?? '?')
.split(/[\s@]/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('');
const role = user.role ?? 'user';
const roleTone =
role === 'super-admin' ? 'accent' : role === 'admin' ? 'info' : 'neutral';
const roleLabel = role === 'super-admin' ? 'Super-admin' : role === 'admin' ? 'Admin' : 'User';
async function handleSignOut() {
await signOut();
router.push('/auth/sign-in');
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-9 px-2 gap-2"
aria-label={`Account · ${user.email ?? user.name ?? 'signed in'}`}
>
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-primary/15 text-primary text-[11px] font-semibold">
{initials}
</span>
<UserIcon className="h-3.5 w-3.5 text-muted-foreground hidden md:inline" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="px-3 py-2">
<div className="flex flex-col gap-1">
{user.name && <span className="text-sm font-medium leading-tight">{user.name}</span>}
{user.email && (
<span className="text-xs text-muted-foreground truncate" title={user.email}>
{user.email}
</span>
)}
<StatusBadge tone={roleTone} size="xs" className="self-start mt-1">
{roleLabel}
</StatusBadge>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="gap-2">
<Settings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/security" className="gap-2">
<ShieldCheck className="h-4 w-4" />
Security
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleSignOut} className="gap-2 text-destructive focus:text-destructive">
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,118 @@
/* WorkerPulse single worker heartbeat tile.
*
* State derives from:
* last activity is fresher than the worker's expected cadence ok / pending
* last activity stale idle (worker quiet, not necessarily broken)
* any failures in the last hour warn (degraded)
* all 1h runs failing error
*
* Per-worker freshness thresholds:
* Analyzer 5 min (poll every 2s, gets work intermittently)
* RMM Overshell 10 min
* Sync scheduler 60 min (cron-driven; quietest worker) */
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
interface WorkerSnapshot {
name: string;
lastActivity: string | null;
inFlight: number;
oneHour: { success: number; failure: number };
}
interface WorkerPulseProps {
worker: WorkerSnapshot;
/** Stale threshold in minutes; varies per worker. */
freshnessMinutes?: number;
}
function relTime(iso: string | null): string {
if (!iso) return 'never';
const ms = Date.now() - new Date(iso).getTime();
if (ms < 0) return 'just now';
const min = Math.floor(ms / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min} min ago`;
const hr = Math.floor(min / 60);
if (hr < 48) return `${hr} h ago`;
const day = Math.floor(hr / 24);
return `${day} d ago`;
}
export function WorkerPulse({ worker, freshnessMinutes = 30 }: WorkerPulseProps) {
const { lastActivity, inFlight, oneHour } = worker;
const fresh =
lastActivity != null &&
Date.now() - new Date(lastActivity).getTime() < freshnessMinutes * 60_000;
let state: StatusLightState;
let stateLabel: string;
if (oneHour.failure > 0 && oneHour.success === 0) {
state = 'error';
stateLabel = 'failing';
} else if (oneHour.failure > 0) {
state = 'warn';
stateLabel = 'degraded';
} else if (inFlight > 0) {
state = 'pending';
stateLabel = 'in flight';
} else if (fresh) {
state = 'ok';
stateLabel = 'ok';
} else {
state = 'idle';
stateLabel = 'idle';
}
return (
<Card>
<CardContent className="pt-4 pb-3 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium leading-none">{worker.name}</p>
<p className="text-xs text-muted-foreground mt-1 capitalize">{stateLabel}</p>
</div>
<StatusLight state={state} size="lg" pulse={state === 'pending'} label={stateLabel} />
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<Stat label="In flight" value={inFlight} />
<Stat label="Ok · 1h" value={oneHour.success} />
<Stat label="Fail · 1h" value={oneHour.failure} tone={oneHour.failure > 0 ? 'error' : 'default'} />
</div>
<p className="text-xs text-muted-foreground">
Last activity <span className="num">{relTime(lastActivity)}</span>
</p>
</CardContent>
</Card>
);
}
function Stat({
label,
value,
tone = 'default',
}: {
label: string;
value: number;
tone?: 'default' | 'error';
}) {
return (
<div className="rounded-sm bg-muted/40 py-1.5 px-2">
<p
className={
'num text-base ' + (tone === 'error' ? 'text-destructive' : 'text-foreground')
}
>
{value}
</p>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{label}
</p>
</div>
);
}

View file

@ -0,0 +1,66 @@
/* EmptyState — shared empty / zero-data placeholder.
*
* Replaces the "No reports yet." centered text scattered across pages.
* Renders a dashed-border panel with a lucide icon, headline, optional
* description, and an optional CTA button.
*
* Use within Card content, table empty rows, or dialog bodies. */
import Link from 'next/link';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface EmptyStateAction {
label: string;
href?: string;
onClick?: () => void;
}
interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: EmptyStateAction;
size?: 'sm' | 'md';
className?: string;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
size = 'md',
className,
}: EmptyStateProps) {
const padding = size === 'sm' ? 'py-6 px-4' : 'py-10 px-6';
const iconSize = size === 'sm' ? 'h-5 w-5' : 'h-6 w-6';
const button = action ? (
<Button asChild={!!action.href} variant="outline" size="sm" onClick={action.onClick}>
{action.href ? <Link href={action.href}>{action.label}</Link> : <span>{action.label}</span>}
</Button>
) : null;
return (
<div
className={cn(
'flex flex-col items-center justify-center text-center gap-2 rounded-md border border-dashed border-border/60',
padding,
className,
)}
>
{Icon && (
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className={iconSize} />
</span>
)}
<p className="text-sm font-medium text-foreground">{title}</p>
{description && (
<p className="text-sm text-muted-foreground max-w-prose">{description}</p>
)}
{button && <div className="mt-2">{button}</div>}
</div>
);
}

View file

@ -98,8 +98,9 @@ export function MultiSelect({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)] min-w-[260px]" className="p-0 w-[var(--radix-popover-trigger-width)] min-w-[260px] max-w-[calc(100vw-1rem)]"
align="start" align="start"
collisionPadding={8}
> >
{options.length >= searchThreshold && ( {options.length >= searchThreshold && (
<div className="p-2 border-b"> <div className="p-2 border-b">

143
components/ui/sheet.tsx Normal file
View file

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -0,0 +1,97 @@
/* Skeleton helpers standardized loading shells.
*
* Use these instead of one-off `<Skeleton className="h-12" />` so the
* loading state matches the post-load layout. Each helper renders a
* shape that approximates a specific final element.
*
* Available helpers:
* SkeletonRow single line row (table row, list item)
* SkeletonRows repeated rows with a stagger
* SkeletonCard KPI / summary card body
* SkeletonChart recharts placeholder of fixed height
* SkeletonHeader page sub-section title placeholder */
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
interface SkeletonProps {
className?: string;
}
export function SkeletonRow({ className }: SkeletonProps) {
return (
<div className={cn('flex items-center gap-3 py-2', className)}>
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-12" />
</div>
);
}
export function SkeletonRows({ count = 5, className }: SkeletonProps & { count?: number }) {
return (
<div className={cn('space-y-1', className)}>
{Array.from({ length: count }).map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
);
}
export function SkeletonCard({ className }: SkeletonProps) {
return (
<div className={cn('rounded-md border p-4 space-y-3', className)}>
<Skeleton className="h-3 w-24" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-3 w-40" />
</div>
);
}
export function SkeletonChart({
className,
height = 180,
}: SkeletonProps & { height?: number }) {
return (
<div className={cn('w-full', className)} style={{ height }}>
<div className="relative h-full w-full overflow-hidden rounded-md bg-muted/40">
<Skeleton className="h-full w-full opacity-60" />
</div>
</div>
);
}
export function SkeletonHeader({ className }: SkeletonProps) {
return (
<div className={cn('space-y-2', className)}>
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-48" />
</div>
);
}
/** SkeletonTable — header row + N body rows. Wrap in Card with p-0 for best fit. */
export function SkeletonTable({
rows = 5,
cols = 4,
className,
}: SkeletonProps & { rows?: number; cols?: number }) {
return (
<div className={cn('overflow-hidden', className)}>
<div className="bg-muted/40 px-4 py-2.5 flex gap-4">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="h-3 flex-1" />
))}
</div>
<div className="divide-y divide-border">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="px-4 py-2.5 flex gap-4">
{Array.from({ length: cols }).map((_, j) => (
<Skeleton key={j} className="h-4 flex-1" />
))}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
/* StatusBadge — small rounded pill carrying a status / category label.
*
* Driven by lib/status-registry.ts: callers either pass an explicit
* { variantClass, label } pair (the shape returned by registry helpers
* like priorityBadge / ticketStatusBadge / classificationBadge), or pass
* a `tone` and `children` for one-off semantic states.
*
* Visual: rounded-sm to match the geometric brand direction, no border,
* tinted background carries the weight. */
import { cn } from '@/lib/utils';
import {
TONE_CLASS,
type StatusTone,
type BadgeProps as RegistryBadge,
} from '@/lib/status-registry';
interface BaseProps {
size?: 'xs' | 'sm';
className?: string;
children?: React.ReactNode;
}
interface ToneVariantProps extends BaseProps {
/** Semantic state when no registry entry exists (children is the label). */
tone: StatusTone;
variantClass?: never;
label?: never;
}
interface PassthroughVariantProps extends BaseProps, RegistryBadge {
/** Spread the result of a registry helper directly. */
tone?: never;
}
interface CustomVariantProps extends BaseProps {
/** Free-form variant class (e.g. one of PALETTE_CLASS values). */
variantClass: string;
tone?: never;
label?: string;
}
type StatusBadgeProps = ToneVariantProps | PassthroughVariantProps | CustomVariantProps;
const SIZE_CLASS = {
xs: 'text-[10px] leading-4 px-1.5 py-px',
sm: 'text-xs leading-4 px-2 py-0.5',
} as const;
export function StatusBadge(props: StatusBadgeProps) {
const { size = 'sm', className, children } = props;
let tint: string;
let body: React.ReactNode = children ?? null;
if ('tone' in props && props.tone) {
tint = TONE_CLASS[props.tone];
} else {
tint = props.variantClass;
if (!body && 'label' in props && props.label) body = props.label;
}
return (
<span
className={cn(
'inline-flex items-center rounded-sm font-medium align-middle whitespace-nowrap',
SIZE_CLASS[size],
tint,
className,
)}
>
{body}
</span>
);
}

View file

@ -0,0 +1,53 @@
/* StatusLight 8px colored square indicator.
*
* The defining geometry of /status: square, not circle, faintly
* outlined so it reads on white. Five states map to the brand
* status palette. Optional pulse is for "in flight" / "running" rows. */
import { cn } from '@/lib/utils';
export type StatusLightState = 'ok' | 'warn' | 'error' | 'idle' | 'pending';
interface StatusLightProps {
state: StatusLightState;
size?: 'sm' | 'md' | 'lg';
pulse?: boolean;
label?: string;
className?: string;
}
const sizeMap = {
sm: 'h-1.5 w-1.5',
md: 'h-2 w-2',
lg: 'h-3 w-3',
} as const;
const stateMap: Record<StatusLightState, string> = {
ok: 'bg-emerald-500',
warn: 'bg-amber-500',
error: 'bg-destructive',
idle: 'bg-muted-foreground/40',
pending: 'bg-primary',
};
export function StatusLight({
state,
size = 'md',
pulse = false,
label,
className,
}: StatusLightProps) {
return (
<span
role="status"
aria-label={label ?? state}
className={cn(
'inline-block ring-1 ring-foreground/10 align-middle',
sizeMap[size],
stateMap[state],
pulse && state === 'pending' && 'animate-pulse',
className,
)}
/>
);
}

BIN
docs/CR-EmailTagline.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
docs/StandardsGuide (1).pdf Normal file

Binary file not shown.

BIN
docs/WULF_RGB (1).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
docs/W_RGB (1).png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -56,7 +56,7 @@ export async function sendMagicLinkEmail({
<div style="max-width: 600px; margin: 0 auto;"> <div style="max-width: 600px; margin: 0 auto;">
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;"> <div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div> <div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div> <div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">Operations console</div>
</div> </div>
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;"> <div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">Sign in to your account</h2> <h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">Sign in to your account</h2>
@ -126,7 +126,7 @@ export async function sendInvitationEmail({
<div style="max-width: 600px; margin: 0 auto;"> <div style="max-width: 600px; margin: 0 auto;">
<div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;"> <div style="background: #0f172a; padding: 24px 32px; border-radius: 8px 8px 0 0;">
<div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div> <div style="color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: -0.01em;">Pulse</div>
<div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">PSA Management System</div> <div style="color: #94a3b8; font-size: 13px; margin-top: 2px;">Operations console</div>
</div> </div>
<div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;"> <div style="background: #ffffff; padding: 32px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">You're invited!</h2> <h2 style="color: #0f172a; margin: 0 0 12px; font-size: 18px;">You're invited!</h2>

View file

@ -20,7 +20,8 @@ export type HealthStatus =
| 'auth_failed' // configured, server returned 401/403 | 'auth_failed' // configured, server returned 401/403
| 'unreachable' // configured, network/DNS/TLS error | 'unreachable' // configured, network/DNS/TLS error
| 'not_configured' // env vars missing | 'not_configured' // env vars missing
| 'unknown'; // configured, no live check implemented | 'unknown' // configured, no live check implemented
| 'disabled'; // operator-suppressed (see INTEGRATIONS_DISABLED env)
export interface TokenExpiry { export interface TokenExpiry {
envVar: string; envVar: string;
@ -250,6 +251,54 @@ function checkConfigOnly(
}; };
} }
/**
* Operator-side disable list. Set INTEGRATIONS_DISABLED to a comma- or
* space-separated list of integration keys (or aliases) to suppress them
* from the /status page and the top-bar indicator. Disabled entries
* render muted and don't count toward failure summaries.
*
* Aliases:
* sentinelone, s1 s1
* datto, datto-rmm datto_rmm
* itglue, it-glue itglue
* msgraph, ms-graph msgraph
*/
const KEY_ALIASES: Record<string, string> = {
sentinelone: 's1',
's1': 's1',
datto: 'datto_rmm',
'datto-rmm': 'datto_rmm',
'datto_rmm': 'datto_rmm',
itglue: 'itglue',
'it-glue': 'itglue',
'it_glue': 'itglue',
msgraph: 'msgraph',
'ms-graph': 'msgraph',
'ms_graph': 'msgraph',
};
function getDisabledKeys(): Set<string> {
const raw = process.env.INTEGRATIONS_DISABLED;
if (!raw) return new Set();
return new Set(
raw
.split(/[\s,]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
.map((s) => KEY_ALIASES[s] ?? s),
);
}
function applyDisableOverlay(items: IntegrationHealth[]): IntegrationHealth[] {
const disabled = getDisabledKeys();
if (disabled.size === 0) return items;
return items.map((item) =>
disabled.has(item.key)
? { ...item, status: 'disabled', error: null, configured: false }
: item,
);
}
export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Promise<IntegrationHealth[]> { export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Promise<IntegrationHealth[]> {
if (!opts?.skipCache && cache && cache.expiresAt > Date.now()) { if (!opts?.skipCache && cache && cache.expiresAt > Date.now()) {
return cache.data; return cache.data;
@ -278,8 +327,9 @@ export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Pr
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm', Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
['ANTHROPIC_API_KEY'])), ['ANTHROPIC_API_KEY'])),
]); ]);
cache = { expiresAt: Date.now() + CACHE_TTL_MS, data: results }; const overlaid = applyDisableOverlay(results);
return results; cache = { expiresAt: Date.now() + CACHE_TTL_MS, data: overlaid };
return overlaid;
} }
export function clearIntegrationHealthCache(): void { export function clearIntegrationHealthCache(): void {
@ -291,14 +341,20 @@ export interface HealthSummary {
ok: number; ok: number;
failed: number; failed: number;
notConfigured: number; notConfigured: number;
disabled: number;
expiringWithin14Days: number; expiringWithin14Days: number;
expired: number; expired: number;
hasIssues: boolean; hasIssues: boolean;
} }
export function summarize(items: IntegrationHealth[]): HealthSummary { export function summarize(items: IntegrationHealth[]): HealthSummary {
let ok = 0, failed = 0, notConfigured = 0, expiringWithin14Days = 0, expired = 0; let ok = 0, failed = 0, notConfigured = 0, disabled = 0;
let expiringWithin14Days = 0, expired = 0;
for (const i of items) { for (const i of items) {
if (i.status === 'disabled') {
disabled += 1;
continue;
}
if (i.status === 'ok' || i.status === 'unknown') ok += 1; if (i.status === 'ok' || i.status === 'unknown') ok += 1;
else if (i.status === 'auth_failed' || i.status === 'unreachable') failed += 1; else if (i.status === 'auth_failed' || i.status === 'unreachable') failed += 1;
else if (i.status === 'not_configured') notConfigured += 1; else if (i.status === 'not_configured') notConfigured += 1;
@ -309,7 +365,7 @@ export function summarize(items: IntegrationHealth[]): HealthSummary {
} }
return { return {
total: items.length, total: items.length,
ok, failed, notConfigured, ok, failed, notConfigured, disabled,
expiringWithin14Days, expired, expiringWithin14Days, expired,
hasIssues: failed > 0 || expired > 0 || expiringWithin14Days > 0, hasIssues: failed > 0 || expired > 0 || expiringWithin14Days > 0,
}; };

263
lib/status-registry.ts Normal file
View file

@ -0,0 +1,263 @@
/* Centralized status / priority / classification color registry.
*
* Lifts the hard-coded maps from components/admin/DetailModal.tsx so the
* same labels and colors can be rendered anywhere via <StatusBadge />.
*
* Two axes:
* TONE semantic state (ok / warn / error / info / accent / neutral / pending / inactive)
* PALETTE arbitrary categorical hues used when there is no semantic
* intent (e.g. classifications, sources, queue colors)
*
* Components consume `BadgeProps` returned by the helpers below.
*
* The class strings here use Tailwind v4 named-palette colors at /15
* background and -600/-700 text; the recipe matches the existing app
* style and reads correctly on both light and dark surfaces. */
export type StatusTone =
| 'ok'
| 'warn'
| 'error'
| 'info'
| 'accent'
| 'neutral'
| 'pending'
| 'inactive';
export type PaletteHue =
| 'red'
| 'orange'
| 'amber'
| 'yellow'
| 'green'
| 'emerald'
| 'sky'
| 'blue'
| 'cyan'
| 'teal'
| 'indigo'
| 'violet'
| 'purple'
| 'slate'
| 'zinc';
export interface BadgeProps {
label: string;
/** className snippet for the badge body — pass through to <StatusBadge /> */
variantClass: string;
}
/* ── Tone → Tailwind classes ───────────────────────────────────────── */
export const TONE_CLASS: Record<StatusTone, string> = {
ok: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
warn: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
error: 'bg-destructive/15 text-destructive',
info: 'bg-primary/15 text-primary',
accent: 'bg-primary/15 text-primary',
neutral: 'bg-slate-500/15 text-slate-700 dark:text-slate-300',
pending: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
inactive: 'bg-muted text-muted-foreground',
};
export const PALETTE_CLASS: Record<PaletteHue, string> = {
red: 'bg-red-500/15 text-red-700 dark:text-red-400',
orange: 'bg-orange-500/15 text-orange-700 dark:text-orange-400',
amber: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
yellow: 'bg-yellow-500/15 text-yellow-700 dark:text-yellow-400',
green: 'bg-green-500/15 text-green-700 dark:text-green-400',
emerald: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
sky: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
blue: 'bg-blue-500/15 text-blue-700 dark:text-blue-400',
cyan: 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-400',
teal: 'bg-teal-500/15 text-teal-700 dark:text-teal-400',
indigo: 'bg-indigo-500/15 text-indigo-700 dark:text-indigo-400',
violet: 'bg-violet-500/15 text-violet-700 dark:text-violet-400',
purple: 'bg-purple-500/15 text-purple-700 dark:text-purple-400',
slate: 'bg-slate-500/15 text-slate-700 dark:text-slate-300',
zinc: 'bg-zinc-500/15 text-zinc-700 dark:text-zinc-300',
};
const NEUTRAL_BADGE: BadgeProps = {
label: '—',
variantClass: TONE_CLASS.inactive,
};
/* ── Priorities (Autotask numeric IDs) ────────────────────────────── */
const PRIORITY_REGISTRY: Record<number, { label: string; tone: StatusTone }> = {
2: { label: 'Critical', tone: 'error' },
3: { label: 'High', tone: 'warn' },
4: { label: 'Medium', tone: 'pending' },
6: { label: 'Low', tone: 'info' },
7: { label: 'Very Low', tone: 'neutral' },
// Mirrored entries from the older picklist (Autotask renumbered)
8: { label: 'Critical', tone: 'error' },
9: { label: 'High', tone: 'warn' },
10: { label: 'Medium', tone: 'pending' },
11: { label: 'Low', tone: 'info' },
};
export function priorityBadge(id: number | null | undefined): BadgeProps {
if (id == null) return NEUTRAL_BADGE;
const hit = PRIORITY_REGISTRY[Number(id)];
if (!hit) return { label: `Priority ${id}`, variantClass: TONE_CLASS.inactive };
return { label: hit.label, variantClass: TONE_CLASS[hit.tone] };
}
/* ── Ticket statuses (string label keyed) ─────────────────────────── */
const TICKET_STATUS_REGISTRY: Record<string, StatusTone> = {
'New': 'info',
'In Progress': 'pending',
'Complete': 'ok',
'Resolved <CSAT Survey>': 'ok',
'Waiting Customer': 'warn',
'Waiting Materials': 'warn',
'Waiting Vendor': 'warn',
'Waiting Approval': 'warn',
'On Hold': 'neutral',
'Escalate': 'error',
'Escalate to Wulf': 'error',
'Escalate to MC': 'error',
'Resource Assigned': 'pending',
'Service Call Scheduled': 'pending',
'Dispatched': 'pending',
};
export function ticketStatusBadge(label: string | null | undefined): BadgeProps {
if (!label) return NEUTRAL_BADGE;
const tone = TICKET_STATUS_REGISTRY[label] ?? 'inactive';
return { label, variantClass: TONE_CLASS[tone] };
}
/* ── Sources (Autotask) ───────────────────────────────────────────── */
const SOURCE_REGISTRY: Record<number, string> = {
[-2]: 'System',
[-1]: 'Internal',
1: 'Phone',
2: 'Email',
4: 'Web Portal',
6: 'Monitoring Alert',
8: 'RMM Alert',
17: 'Chat',
21: 'API',
22: 'Automation',
27: 'In Person',
29: 'Client Portal',
30: 'Microsoft Teams',
31: 'Webhook',
33: 'Datto RMM',
34: 'Rewst',
35: 'TimeZest',
36: 'DeskDirector',
38: 'Huntress',
39: 'Blumira',
40: 'SentinelOne',
};
export function sourceBadge(id: number | null | undefined): BadgeProps {
if (id == null) return NEUTRAL_BADGE;
const label = SOURCE_REGISTRY[Number(id)] ?? `Source ${id}`;
return { label, variantClass: PALETTE_CLASS.violet };
}
/* ── Classification (Autotask) ────────────────────────────────────── */
const CLASSIFICATION_REGISTRY: Record<number, { label: string; hue: PaletteHue }> = {
5: { label: 'Block Hour', hue: 'sky' },
9: { label: 'Canceled', hue: 'slate' },
202: { label: 'Co-Managed', hue: 'cyan' },
16: { label: 'Gold (Legacy)', hue: 'yellow' },
206: { label: 'IT Complete w/ Gold Security', hue: 'amber' },
207: { label: 'IT Core / Silver Security', hue: 'blue' },
203: { label: 'IT Foundation / Bronze Security', hue: 'orange' },
205: { label: 'IT Premier / Platinum Security', hue: 'violet' },
14: { label: 'Jeopardy Company', hue: 'red' },
201: { label: 'Partner', hue: 'purple' },
15: { label: 'Platinum (Legacy)', hue: 'violet' },
13: { label: 'Residential (no-pay)', hue: 'slate' },
17: { label: 'Silver (Legacy)', hue: 'zinc' },
12: { label: 'T&M', hue: 'teal' },
7: { label: 'Target', hue: 'emerald' },
200: { label: 'Tools Only', hue: 'slate' },
18: { label: 'Bronze (Legacy)', hue: 'orange' },
};
export function classificationBadge(id: number | null | undefined): BadgeProps {
if (id == null) return NEUTRAL_BADGE;
const hit = CLASSIFICATION_REGISTRY[Number(id)];
if (!hit) return { label: `Classification ${id}`, variantClass: TONE_CLASS.inactive };
return { label: hit.label, variantClass: PALETTE_CLASS[hit.hue] };
}
/* ── Company type (Autotask) ──────────────────────────────────────── */
const COMPANY_TYPE_REGISTRY: Record<number, { label: string; hue: PaletteHue }> = {
1: { label: 'Customer', hue: 'green' },
2: { label: 'Lead', hue: 'blue' },
3: { label: 'Prospect', hue: 'purple' },
4: { label: 'Dead', hue: 'slate' },
6: { label: 'Cancelation', hue: 'red' },
7: { label: 'Vendor', hue: 'orange' },
8: { label: 'Partner', hue: 'cyan' },
};
export function companyTypeBadge(id: number | null | undefined): BadgeProps {
if (id == null) return NEUTRAL_BADGE;
const hit = COMPANY_TYPE_REGISTRY[Number(id)];
if (!hit) return { label: `Type ${id}`, variantClass: TONE_CLASS.inactive };
return { label: hit.label, variantClass: PALETTE_CLASS[hit.hue] };
}
/* ── Note publish levels (Autotask) ──────────────────────────────── */
const PUBLISH_REGISTRY: Record<number, { label: string; tone: StatusTone }> = {
1: { label: 'All Users', tone: 'ok' },
2: { label: 'Internal', tone: 'warn' },
4: { label: 'Internal Only', tone: 'neutral' },
};
export function publishBadge(id: number | null | undefined): BadgeProps {
if (id == null) return NEUTRAL_BADGE;
const hit = PUBLISH_REGISTRY[Number(id)];
if (!hit) return { label: `Publish ${id}`, variantClass: TONE_CLASS.inactive };
return { label: hit.label, variantClass: TONE_CLASS[hit.tone] };
}
/* ── Boolean badges (active / billable / approved / yes / no) ────── */
export function activeBadge(active: boolean | null | undefined): BadgeProps {
if (active == null) return NEUTRAL_BADGE;
return active
? { label: 'Active', variantClass: TONE_CLASS.ok }
: { label: 'Inactive', variantClass: TONE_CLASS.inactive };
}
export function yesNoBadge(value: boolean | null | undefined): BadgeProps {
if (value == null) return NEUTRAL_BADGE;
return value
? { label: 'Yes', variantClass: TONE_CLASS.ok }
: { label: 'No', variantClass: TONE_CLASS.inactive };
}
export function billableBadge(value: boolean | null | undefined): BadgeProps {
if (!value) return NEUTRAL_BADGE;
return { label: 'Billable', variantClass: TONE_CLASS.ok };
}
export function approvedBadge(value: boolean | null | undefined): BadgeProps {
if (!value) return NEUTRAL_BADGE;
return { label: 'Approved', variantClass: TONE_CLASS.info };
}
/* ── Generic helpers (for inline use without registry entry) ─────── */
export function toneClass(tone: StatusTone): string {
return TONE_CLASS[tone];
}
export function paletteClass(hue: PaletteHue): string {
return PALETTE_CLASS[hue];
}

3680
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -45,6 +45,7 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"pg": "^8.11.0", "pg": "^8.11.0",
"radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@ -68,6 +69,7 @@
"baseline-browser-mapping": "2.10.8", "baseline-browser-mapping": "2.10.8",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"shadcn": "^4.6.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5", "typescript": "^5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB