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:
parent
1112a06afe
commit
9bfb57553d
75 changed files with 9352 additions and 1827 deletions
11
.mcp.json
Normal file
11
.mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
288
ARCHITECTURE.md
Normal file
288
ARCHITECTURE.md
Normal 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 |
|
||||||
|
|---|---|
|
||||||
|
| 001–014 | Core schema, auth tables, admin settings |
|
||||||
|
| 015–026 | Ticket fields, queues, RMM site mappings, integration health |
|
||||||
|
| 027–032 | Datto RMM, Veeam agents/alarms, priorities, ticket categories, RMM webhooks |
|
||||||
|
| 037–044 | IT Glue (large), Veeam RPO, contract services, engagement |
|
||||||
|
| 045–055 | Zoom, Teams, morning summary, ping suppression, ticket digest, Zabbix WAN, QBO, Mimecast |
|
||||||
|
| 056–068 | UDFs, Autotask tags, Duo, project phases, recurring revenue, Veeam ticket analysis |
|
||||||
|
| 069–074 | Analyzer (jobs, analyses, stage executions, aggregate reports, cost audit, link-aware bundles, provider) |
|
||||||
|
| 075–076 | IT Glue audit + ticket xrefs |
|
||||||
|
| 077–078 | RMM Overshell, LogLift uploads |
|
||||||
|
| 079–080 | Endpoint data model, device-xref `company_id` |
|
||||||
|
|
||||||
|
**Watch out for:**
|
||||||
|
- Duplicate numbers exist (002, 004, 009). Apply order is filesystem-sort
|
||||||
|
alphabetical, not numeric. Don't introduce more.
|
||||||
|
- Conventions: `IF NOT EXISTS` for tables/indexes, `ON CONFLICT DO NOTHING`
|
||||||
|
for seed data, audit columns `created_at` / `updated_at` / `synced_at` /
|
||||||
|
`is_deleted` / `deleted_at`.
|
||||||
|
- `priorities` has no `is_deleted` column — caught the hard way (`9acf48e`).
|
||||||
|
- Columns are **`snake_case`**; API responses are **`camelCase`**. Handlers
|
||||||
|
transform manually. No ORM.
|
||||||
|
|
||||||
|
DB access is the singleton at `lib/services/postgres-client.ts` —
|
||||||
|
`postgresClient.query()`, `.transaction()`, `.upsert()`, `.bulkUpsert()`.
|
||||||
|
|
||||||
|
## 7. Deployment
|
||||||
|
|
||||||
|
**Docker Compose** at the repo root.
|
||||||
|
- `postgres` — Postgres 16, port 5432. Migrations volume mounted at
|
||||||
|
`/docker-entrypoint-initdb.d/`. Init runs once per volume.
|
||||||
|
- `redis` — Redis 7, port 6380 (host) / 6379 (container). Cache only.
|
||||||
|
- `app` — built from `Dockerfile` (turbopack, `output: 'standalone'`); runs
|
||||||
|
`node server.js`. Port 3100. `.env.local` mounted read-only. Traefik labels
|
||||||
|
for `pulse.wulfconsulting.cloud` (HTTPS via Cloudflare cert).
|
||||||
|
|
||||||
|
`npm run build` uses turbopack (Next 16 default). `npm run dev` for local;
|
||||||
|
`npx tsc --noEmit --pretty` for type check; `npm test` (vitest) for the
|
||||||
|
analyzer / RMM / B2 / link-discovery unit tests. **No CI**; type-check is the
|
||||||
|
only safety net for code that doesn't have unit tests.
|
||||||
|
|
||||||
|
## 8. Invariants & gotchas
|
||||||
|
|
||||||
|
1. **Worker side-effect imports.** Importing `sync-scheduler.ts`,
|
||||||
|
`analyzer/worker.ts`, or `rmm/worker.ts` from a hot path starts the loop.
|
||||||
|
2. **No external queue.** Multiple instances duplicate pollers. Analyzer is
|
||||||
|
safe via row locking; sync scheduler is not — pin to one instance.
|
||||||
|
3. **IT Glue redaction is mandatory** for any LLM-bound query. Use
|
||||||
|
`itglue-search.ts`, never the raw client.
|
||||||
|
4. **Provider-scoped idempotency.** `force=false` only short-circuits if the
|
||||||
|
same provider produced the existing analysis.
|
||||||
|
5. **Cost ceiling at $2.00** before Stage 4. Above that, Opus is skipped and
|
||||||
|
the analysis is flagged.
|
||||||
|
6. **Webhook handlers return 200 on failure** (Autotask) to avoid
|
||||||
|
deactivation. Errors are logged, not surfaced.
|
||||||
|
7. **Postgres init runs migrations once.** Existing volumes won't re-run them.
|
||||||
|
8. **Duplicate migration numbers.** Apply order is alphabetic.
|
||||||
|
9. **`.env` is committed.** Treat the values as potentially real production
|
||||||
|
secrets; don't log or echo them.
|
||||||
|
10. **RMM script registry is in code.** `lib/services/rmm/scripts/` —
|
||||||
|
unregistered scripts can't execute.
|
||||||
|
11. **LogLift zip-bomb guard** caps inflated payloads at 100 MB.
|
||||||
|
12. **Stale analyzer jobs reset on worker boot.** Don't rely on `in_flight`
|
||||||
|
state surviving restarts.
|
||||||
|
|
||||||
|
## 9. Where to look
|
||||||
|
|
||||||
|
| Concern | Start here |
|
||||||
|
|---|---|
|
||||||
|
| HTTP routes | `app/api/**/route.ts` |
|
||||||
|
| Pages | `app/**/page.tsx` |
|
||||||
|
| Worker boot | `lib/services/{sync-scheduler,analyzer/worker,rmm/worker}.ts` |
|
||||||
|
| Postgres access | `lib/services/postgres-client.ts` |
|
||||||
|
| Auth wiring | `lib/auth.ts`, `lib/auth-utils.ts`, `lib/permissions.ts`, `middleware.ts` |
|
||||||
|
| Analyzer pipeline | `lib/services/analyzer/pipeline.ts` + `stages/` |
|
||||||
|
| LLM dispatch | `lib/services/llm/{call,models,pricing}.ts` |
|
||||||
|
| RMM executor | `lib/services/rmm/{executor,worker,target-resolver}.ts` |
|
||||||
|
| IT Glue write-back | `lib/services/analyzer/asset-audit/` |
|
||||||
|
| Per-feature notes | `docs/` (one file per system) |
|
||||||
54
CLAUDE.md
54
CLAUDE.md
|
|
@ -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
396
DESIGN.md
Normal 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.
|
||||||
|
|
@ -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">
|
|
||||||
<h1 className="text-3xl font-bold">Addigy Devices</h1>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={filterOnline}
|
|
||||||
onChange={(e) => setFilterOnline(e.target.checked)}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
<span>Online Only</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={fetchDevices}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Loading...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded mb-4">
|
|
||||||
<strong>Error:</strong> {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<p className="mt-4 text-gray-600">Loading devices...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 text-gray-600">
|
<PageHeader
|
||||||
Found {devices.length} device{devices.length !== 1 ? 's' : ''}
|
title="Addigy devices"
|
||||||
</div>
|
description={
|
||||||
|
loading
|
||||||
|
? 'Loading…'
|
||||||
|
: `${devices.length} device${devices.length === 1 ? '' : 's'}${filterOnline ? ' · online only' : ''}`
|
||||||
|
}
|
||||||
|
breadcrumbs={[{ label: 'Addigy devices' }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={filterOnline}
|
||||||
|
onCheckedChange={(v) => setFilterOnline(v === true)}
|
||||||
|
aria-label="Filter to online devices only"
|
||||||
|
/>
|
||||||
|
<span>Online only</span>
|
||||||
|
</label>
|
||||||
|
<Button onClick={fetchDevices} variant="outline" size="sm" disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="bg-white shadow-md rounded-lg overflow-hidden">
|
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||||
<div className="overflow-x-auto">
|
{error && (
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<Alert variant="destructive">
|
||||||
<thead className="bg-gray-50">
|
<AlertTitle>Failed to load</AlertTitle>
|
||||||
<tr>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
</Alert>
|
||||||
Device Name
|
)}
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<Card>
|
||||||
Model
|
<CardContent className="p-0">
|
||||||
</th>
|
{loading ? (
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<div className="p-6 space-y-2">
|
||||||
OS Version
|
<Skeleton className="h-8 w-full" />
|
||||||
</th>
|
<Skeleton className="h-8 w-full" />
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<Skeleton className="h-8 w-3/4" />
|
||||||
Current User
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Free Disk
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Security
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{devices.map((device) => (
|
|
||||||
<tr key={device.agentid} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{device['Device Name']}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
) : devices.length === 0 ? (
|
||||||
{device['Serial Number'] || 'N/A'}
|
<div className="p-6">
|
||||||
</div>
|
<EmptyState
|
||||||
</td>
|
icon={Laptop}
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
title="No devices found"
|
||||||
{device['Device Model Name'] || 'Unknown'}
|
description={
|
||||||
</td>
|
filterOnline
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
? 'No devices are currently online.'
|
||||||
{device['MAC OS X Version'] ||
|
: 'Addigy has not synced any devices yet.'
|
||||||
device['iOS Version'] ||
|
}
|
||||||
'N/A'}
|
size="sm"
|
||||||
</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>
|
||||||
) : (
|
) : (
|
||||||
'N/A'
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Device</TableHead>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>OS</TableHead>
|
||||||
|
<TableHead>Current user</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Free disk</TableHead>
|
||||||
|
<TableHead>Security</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{devices.map((device) => {
|
||||||
|
const freePct = device['Free Disk Percentage'];
|
||||||
|
const freeTone =
|
||||||
|
freePct === undefined
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: freePct < 20
|
||||||
|
? 'text-destructive'
|
||||||
|
: freePct < 40
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: 'text-emerald-600 dark:text-emerald-400';
|
||||||
|
return (
|
||||||
|
<TableRow key={device.agentid}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{device['Device Name']}</div>
|
||||||
|
<div className="text-xs text-muted-foreground num">
|
||||||
|
{device['Serial Number'] || '—'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{device['Device Model Name'] || 'Unknown'}</TableCell>
|
||||||
|
<TableCell className="num">
|
||||||
|
{device['MAC OS X Version'] || device['iOS Version'] || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{device['Current User'] || '—'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge tone={device.online ? 'ok' : 'inactive'}>
|
||||||
|
{device.online ? 'Online' : 'Offline'}
|
||||||
|
</StatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={`text-right num ${freeTone}`}>
|
||||||
|
{freePct !== undefined ? `${freePct}%` : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<SecurityFlag label="FW" enabled={Boolean(device['Firewall Enabled'])} />
|
||||||
|
<SecurityFlag label="FV" enabled={Boolean(device['FileVault Enabled'])} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
)}
|
)}
|
||||||
</td>
|
</CardContent>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
</Card>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span
|
|
||||||
className={`text-xs ${
|
|
||||||
device['Firewall Enabled']
|
|
||||||
? 'text-green-600'
|
|
||||||
: 'text-red-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
FW: {device['Firewall Enabled'] ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-xs ${
|
|
||||||
device['FileVault Enabled']
|
|
||||||
? 'text-green-600'
|
|
||||||
: 'text-red-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
FV: {device['FileVault Enabled'] ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
);
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
function SecurityFlag({ label, enabled }: { label: string; enabled: boolean }) {
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Audit Log"
|
||||||
|
description="View system activity and security events"
|
||||||
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Audit Log' }]}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold">Audit Log</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
View system activity and security events
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Suspense fallback={<AuditLogSkeleton />}>
|
<Suspense fallback={<AuditLogSkeleton />}>
|
||||||
<AuditLogTable />
|
<AuditLogTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +24,13 @@ 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
|
||||||
|
title="Database Browser"
|
||||||
|
description="Inspect synced data from PostgreSQL"
|
||||||
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Database Browser' }]}
|
||||||
|
accent
|
||||||
|
actions={
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
|
@ -32,13 +38,9 @@ export default function DataBrowserPage() {
|
||||||
<span className="hidden sm:inline">Back to Dashboard</span>
|
<span className="hidden sm:inline">Back to Dashboard</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Database className="w-8 h-8" />
|
}
|
||||||
<div>
|
/>
|
||||||
<h1 className="text-3xl font-bold">Database Browser</h1>
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
<p className="text-muted-foreground">Inspect synced data from PostgreSQL</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<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;
|
||||||
|
|
@ -58,5 +60,6 @@ export default function DataBrowserPage() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +106,13 @@ export default function DeviceLinkConflictsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
<div className="container mx-auto px-6 py-6 max-w-6xl space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -249,5 +257,6 @@ export default function DeviceLinkConflictsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,17 +288,14 @@ export default function DisplaySettingsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Display Settings"
|
||||||
|
description="Configure which companies appear in the Kiosk and Mobile dashboards."
|
||||||
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Display Settings' }]}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
<div className="container mx-auto py-8 px-4 max-w-5xl">
|
<div className="container mx-auto py-8 px-4 max-w-5xl">
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
|
||||||
<SlidersHorizontal className="h-8 w-8" />
|
|
||||||
Display Settings
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Configure which companies appear in the Kiosk and Mobile dashboards.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Section
|
<Section
|
||||||
title="Kiosk"
|
title="Kiosk"
|
||||||
|
|
@ -319,5 +317,6 @@ export default function DisplaySettingsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +77,13 @@ export default function ItglueWritesPage() {
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -164,5 +172,6 @@ export default function ItglueWritesPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,6 +211,22 @@ export default function MorningSummaryPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Morning NOC Summary"
|
||||||
|
description="Scheduled 6:30 AM Mon–Fri · 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">
|
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
|
|
@ -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 Mon–Fri · 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">
|
||||||
|
|
@ -463,5 +465,6 @@ export default function MorningSummaryPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,14 +274,14 @@ 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}>
|
||||||
|
|
@ -317,5 +318,6 @@ export default function AdminIndexPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ${
|
||||||
|
|
@ -234,6 +236,7 @@ function QboPageInner() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +95,13 @@ export default function RmmOvershellAdminPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
<div className="container mx-auto px-6 py-6 max-w-5xl space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -268,5 +276,6 @@ export default function RmmOvershellAdminPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Role Management"
|
||||||
|
description="Manage roles and their permissions"
|
||||||
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Roles' }]}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold">Role Management</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Manage roles and their permissions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<RoleTable />
|
<RoleTable />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Settings"
|
||||||
|
description="Configure application settings"
|
||||||
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Settings' }]}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold">Settings</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Configure application settings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
@ -169,5 +170,6 @@ export default function SettingsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 => (
|
||||||
<tr key={u.user_id} className={`hover:${bgColor}`}>
|
<TableRow key={u.user_id}>
|
||||||
<td className="px-4 py-2">
|
<TableCell>
|
||||||
<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>}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-2 text-muted-foreground">{u.email || '\u2014'}</td>
|
<TableCell className="text-muted-foreground">{u.email || '\u2014'}</TableCell>
|
||||||
<td className="px-4 py-2">{u.account_name}</td>
|
<TableCell>{u.account_name}</TableCell>
|
||||||
<td className="px-4 py-2 text-center">
|
<TableCell className="text-center">
|
||||||
{u.is_enrolled
|
{u.is_enrolled
|
||||||
? <CheckCircle2 className="w-4 h-4 text-green-500 mx-auto" />
|
? <CheckCircle2 className="w-4 h-4 text-emerald-500 mx-auto" />
|
||||||
: <span className="text-muted-foreground">No</span>
|
: <span className="text-muted-foreground">No</span>
|
||||||
}
|
}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-2 text-muted-foreground">{u.last_login ? fmtDate(u.last_login) : 'Never'}</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 text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</td>
|
<TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,44 +906,45 @@ 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) => (
|
||||||
<tr key={m.id} className="hover:bg-muted/20">
|
<TableRow key={m.id}>
|
||||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs">
|
<TableCell className="text-muted-foreground whitespace-nowrap text-xs num">
|
||||||
{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' })}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||||
<div className="truncate">{m.to}</div>
|
<div className="truncate">{m.to}</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2" style={{overflow:'hidden'}}>
|
<TableCell 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>}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||||
<div className="truncate">{m.subject || '(no subject)'}</div>
|
<div className="truncate">{m.subject || '(no subject)'}</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2" style={{overflow:'hidden'}}>
|
<TableCell style={{overflow:'hidden'}}>
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
<StatusBadge
|
||||||
|
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 || '—'}
|
{m.policyInfo || m.reason || '—'}
|
||||||
</span>
|
</StatusBadge>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2">
|
<TableCell>
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<div className="flex items-center gap-1 justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -963,12 +969,11 @@ function HeldMailTab() {
|
||||||
{releaseErrors[m.id] && (
|
{releaseErrors[m.id] && (
|
||||||
<div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div>
|
<div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
</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) => (
|
||||||
<tr key={m.id} className={`hover:bg-muted/20 ${
|
<TableRow key={m.id} className={
|
||||||
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'
|
||||||
: ''
|
: ''
|
||||||
}`}>
|
}>
|
||||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs">
|
<TableCell className="text-muted-foreground whitespace-nowrap text-xs num">
|
||||||
{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' })}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||||
<div className="truncate">{m.to}</div>
|
<div className="truncate">{m.to}</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2" style={{overflow:'hidden'}}>
|
<TableCell 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>
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||||
<div className="truncate">{m.subject || '(no subject)'}</div>
|
<div className="truncate">{m.subject || '(no subject)'}</div>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2">
|
<TableCell>
|
||||||
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
|
<StatusBadge
|
||||||
m.status === 'accepted' ? 'bg-green-500/10 text-green-700'
|
tone={
|
||||||
: m.status === 'held' ? 'bg-amber-500/10 text-amber-700'
|
m.status === 'accepted' ? 'ok' :
|
||||||
: m.status === 'rejected' || m.status === 'bounced' ? 'bg-red-500/10 text-red-600'
|
m.status === 'held' ? 'warn' :
|
||||||
: 'bg-muted text-muted-foreground'
|
m.status === 'rejected' || m.status === 'bounced' ? 'error' :
|
||||||
}`}>{m.status}</span>
|
'inactive'
|
||||||
</td>
|
}
|
||||||
<td className="px-3 py-2">
|
>
|
||||||
<span className={`text-xs font-semibold ${m.spamScore >= 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}>
|
{m.status}
|
||||||
|
</StatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`text-xs font-semibold num ${m.spamScore >= 10 ? 'text-red-600' : m.spamScore >= 5 ? 'text-amber-600' : 'text-muted-foreground'}`}>
|
||||||
{m.spamScore}
|
{m.spamScore}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2">
|
<TableCell>
|
||||||
<Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap"
|
<Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap"
|
||||||
onClick={() => setAnalysisMessage(m)}>
|
onClick={() => setAnalysisMessage(m)}>
|
||||||
<Eye className="w-3 h-3 mr-1" />
|
<Eye className="w-3 h-3 mr-1" />
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
|
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
<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" />
|
||||||
|
|
@ -424,5 +427,6 @@ export default function SyncOverviewPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,6 +173,18 @@ export default function TicketDigestPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -452,5 +450,6 @@ export default function TicketDigestPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="User Management"
|
||||||
|
description="Manage users, roles, and permissions"
|
||||||
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Users' }]}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold">User Management</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Manage users, roles, and permissions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Suspense fallback={<UserTableSkeleton />}>
|
<Suspense fallback={<UserTableSkeleton />}>
|
||||||
<UserTable />
|
<UserTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/admin/workflow/create">
|
<Link href="/admin/workflow/create">
|
||||||
<Button>
|
<Button>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Workflow
|
Create Workflow
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
{/* 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>
|
||||||
|
|
@ -285,5 +283,6 @@ export default function WorkflowListPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Zabbix WAN Monitor Setup"
|
||||||
|
description="Create or update Zabbix hosts with WAN IPs and Autotask macros for alert routing"
|
||||||
|
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Zabbix WAN' }]}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
<div className="container mx-auto py-8 max-w-6xl space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
|
||||||
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
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]) => (
|
||||||
|
|
@ -1423,5 +1419,6 @@ export default function ZabbixWanPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +37,21 @@ export default function AnalysisDetailPage({
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
const ticketNumber = analysis?.ticketNumber;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={analysis?.ticketNumber ? `Analysis · ${analysis.ticketNumber}` : 'Analysis'}
|
||||||
|
description={analysis?.id && `id ${analysis.id.slice(0, 8)}`}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Analyzer', href: '/analyzer/tickets' },
|
||||||
|
...(ticketNumber
|
||||||
|
? [{ label: ticketNumber, href: `/analyzer/ticket/${encodeURIComponent(ticketNumber)}` }]
|
||||||
|
: []),
|
||||||
|
{ label: 'Analysis' },
|
||||||
|
]}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
<div className="container mx-auto px-6 py-6 max-w-5xl">
|
<div className="container mx-auto px-6 py-6 max-w-5xl">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
|
|
@ -58,5 +73,6 @@ export default function AnalysisDetailPage({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
]}
|
||||||
|
accent
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
||||||
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
|
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||||
<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,
|
|
||||||
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">
|
||||||
|
|
@ -163,5 +159,6 @@ export default function TicketAnalyzerPage({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
143
app/api/dashboard/trends/route.ts
Normal file
143
app/api/dashboard/trends/route.ts
Normal 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),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
102
app/api/status/workers/route.ts
Normal file
102
app/api/status/workers/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,16 +137,35 @@ 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"
|
||||||
|
description={new Date().toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
accent
|
||||||
|
watermark
|
||||||
|
actions={
|
||||||
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
|
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
|
||||||
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Failed to load</AlertTitle>
|
<AlertTitle>Failed to load</AlertTitle>
|
||||||
|
|
@ -176,62 +173,187 @@ export default function DashboardPage() {
|
||||||
</Alert>
|
</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}`}
|
||||||
|
loading={!data}
|
||||||
/>
|
/>
|
||||||
<AttentionCard
|
<KpiCard
|
||||||
icon={Database}
|
label="Resolved"
|
||||||
value={data?.attention.itglueUnlinked}
|
value={today?.resolvedToday ?? null}
|
||||||
label="IT Glue ↛ Autotask"
|
delta={
|
||||||
sub="unlinked configurations"
|
today
|
||||||
href="/admin/device-link-conflicts"
|
? {
|
||||||
tone="info"
|
value: resolvedDelta,
|
||||||
|
label: 'vs 7-day avg',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
caption={today && `7-day avg: ${today.last7DayAvgResolved}`}
|
||||||
|
tone="accent"
|
||||||
|
loading={!data}
|
||||||
/>
|
/>
|
||||||
<AttentionCard
|
<KpiCard
|
||||||
icon={Shield}
|
label="Open total"
|
||||||
value={data?.attention.s1Unmapped}
|
value={today?.openTotal ?? null}
|
||||||
label="S1 unmapped"
|
loading={!data}
|
||||||
sub="missing site → company mapping"
|
|
||||||
href="/sentinelone/mappings"
|
|
||||||
tone="info"
|
|
||||||
/>
|
/>
|
||||||
<AttentionCard
|
<KpiCard
|
||||||
icon={CalendarClock}
|
label="SLA breaches"
|
||||||
value={data?.attention.schedules.enabled}
|
value={today?.slaBreaches ?? null}
|
||||||
label={`Schedules on / ${data?.attention.schedules.total ?? '—'}`}
|
tone={
|
||||||
href="/admin/sync/autotask"
|
today && today.slaBreaches > 0 ? 'attention' : 'default'
|
||||||
tone="info"
|
}
|
||||||
|
caption={
|
||||||
|
today && today.slaBreaches === 0
|
||||||
|
? 'All on track'
|
||||||
|
: 'Past due, still open'
|
||||||
|
}
|
||||||
|
loading={!data}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* RECENT OBSERVATIONS + AUDITS ---------------------------------------- */}
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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" />
|
||||||
|
Volume · last 30 days
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!trends ? (
|
||||||
|
<Skeleton className="h-44" />
|
||||||
|
) : (
|
||||||
|
<VolumeTrend data={trends.volumeByDay} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Timer className="h-4 w-4" />
|
||||||
|
Mean resolution time · last 30 days
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!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
|
Recent device observations
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{data === null && !error ? (
|
{!data ? (
|
||||||
<RowSkeletons />
|
<RowSkeletons />
|
||||||
) : data?.observations.length === 0 ? (
|
) : data.observations.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No observations recorded yet.</p>
|
<EmptyState
|
||||||
|
icon={Activity}
|
||||||
|
title="No observations recorded"
|
||||||
|
description="Device telemetry from LogLift and RMM will appear here."
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{data?.observations.map((o) => (
|
{data.observations.map((o) => (
|
||||||
<div
|
<div
|
||||||
key={o.id}
|
key={o.id}
|
||||||
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
||||||
|
|
@ -239,11 +361,11 @@ export default function DashboardPage() {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium truncate">{o.hostname ?? '(unanchored)'}</div>
|
<div className="font-medium truncate">{o.hostname ?? '(unanchored)'}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<span className="font-mono">{o.kind}</span>
|
<span className="num">{o.kind}</span>
|
||||||
{o.companyName && <span> · {o.companyName}</span>}
|
{o.companyName && <span> · {o.companyName}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground shrink-0 ml-3">
|
<div className="num text-xs text-muted-foreground shrink-0 ml-3">
|
||||||
{relTime(o.collectedAt)}
|
{relTime(o.collectedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -256,18 +378,23 @@ export default function DashboardPage() {
|
||||||
<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">
|
||||||
<Sparkles className="size-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Recent audits
|
Recent audits
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{data === null && !error ? (
|
{!data ? (
|
||||||
<RowSkeletons />
|
<RowSkeletons />
|
||||||
) : data?.audits.length === 0 ? (
|
) : data.audits.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No endpoint audits yet.</p>
|
<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">
|
<div className="space-y-1">
|
||||||
{data?.audits.map((a) => (
|
{data.audits.map((a) => (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
className="flex items-center justify-between py-1.5 text-sm border-b last:border-0"
|
||||||
|
|
@ -275,11 +402,13 @@ export default function DashboardPage() {
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
|
<div className="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
score {a.overallScore?.toFixed(2) ?? '—'} · {a.fieldGapsCount} gaps
|
<span className="num">score {a.overallScore?.toFixed(2) ?? '—'}</span>
|
||||||
|
{' · '}
|
||||||
|
<span className="num">{a.fieldGapsCount} gaps</span>
|
||||||
{a.companyName && <span> · {a.companyName}</span>}
|
{a.companyName && <span> · {a.companyName}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground shrink-0 ml-3">
|
<div className="num text-xs text-muted-foreground shrink-0 ml-3">
|
||||||
{relTime(a.generatedAt)}
|
{relTime(a.generatedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -290,156 +419,19 @@ export default function DashboardPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* INTEGRATION HEALTH -------------------------------------------------- */}
|
{/* STATS FOOTER ------------------------------------------------- */}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* STATS FOOTER -------------------------------------------------------- */}
|
|
||||||
{data && (
|
{data && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{data.stats.activeCompanies} companies · {data.stats.configurationItems.toLocaleString()} CIs ·{' '}
|
<span className="num">{data.stats.activeCompanies}</span> active companies ·{' '}
|
||||||
{data.stats.xref.total.toLocaleString()} xref rows (
|
<span className="num">{data.stats.configurationItems.toLocaleString()}</span> configuration items ·{' '}
|
||||||
{data.stats.xref.total > 0
|
<span className="num">{data.stats.xref.total.toLocaleString()}</span> xref rows{' '}
|
||||||
|
({data.stats.xref.total > 0
|
||||||
? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100)
|
? Math.round((data.stats.xref.linked / data.stats.xref.total) * 100)
|
||||||
: 0}
|
: 0}% linked)
|
||||||
% linked)
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</>
|
||||||
}
|
|
||||||
|
|
||||||
function AttentionCard(props: {
|
|
||||||
icon: React.ElementType;
|
|
||||||
value: number | undefined;
|
|
||||||
label: string;
|
|
||||||
sub?: string;
|
|
||||||
href: string;
|
|
||||||
tone: 'ok' | 'warn' | 'info';
|
|
||||||
}) {
|
|
||||||
const { icon: Icon, value, label, sub, href, tone } = props;
|
|
||||||
const valueColor =
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
<tr
|
<TableRow
|
||||||
key={m.month}
|
key={m.month}
|
||||||
className={cn(
|
className={cn(isEmpty && 'opacity-40')}
|
||||||
'border-b last:border-0 hover:bg-muted/30 transition-colors',
|
|
||||||
isEmpty && 'opacity-40'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 font-medium">{monthLabel(m.month)}</td>
|
<TableCell className="font-medium">{monthLabel(m.month)}</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums">
|
<TableCell className="text-right num">
|
||||||
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
|
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400">
|
<TableCell className="text-right num text-emerald-600 dark:text-emerald-400">
|
||||||
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
|
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums">
|
<TableCell className="text-right num">
|
||||||
{m.hoursWorked > 0 ? `${pct}%` : '—'}
|
{m.hoursWorked > 0 ? `${pct}%` : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums hidden sm:table-cell">
|
<TableCell className="text-right num hidden sm:table-cell">
|
||||||
{m.daysWorked > 0 ? m.daysWorked : '—'}
|
{m.daysWorked > 0 ? m.daysWorked : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
<TableCell className="text-right num hidden md:table-cell">
|
||||||
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
|
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
<TableCell className="text-right num hidden md:table-cell">
|
||||||
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
|
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
<TableCell className="text-right num hidden lg:table-cell">
|
||||||
{m.emailsSent > 0 ? m.emailsSent : '—'}
|
{m.emailsSent > 0 ? m.emailsSent : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
<TableCell className="text-right num hidden lg:table-cell">
|
||||||
{totalCalls > 0 ? totalCalls : '—'}
|
{totalCalls > 0 ? totalCalls : '—'}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
541
app/status/page.tsx
Normal 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
170
app/styles/brand.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,23 +676,22 @@ 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
|
||||||
|
|
@ -695,15 +702,14 @@ export default function VeeamAnalysisPage() {
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: (
|
: (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
|
<TableCell 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.
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
const sortDir = header.column.getIsSorted();
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
style={header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined}
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : sortable ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSort(column.key)}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
className="h-8 -ml-3 hover:bg-muted/80 transition-colors"
|
className="h-8 -ml-3"
|
||||||
>
|
>
|
||||||
{column.label}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
{sortColumn === column.key ? (
|
<SortIcon dir={sortDir === 'asc' ? 'asc' : sortDir === 'desc' ? 'desc' : null} />
|
||||||
sortDirection === 'asc' ? (
|
|
||||||
<ArrowUp className="ml-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="ml-2 h-4 w-4" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
column.label
|
flexRender(header.column.columnDef.header, header.getContext())
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</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>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
28
components/branding/tagline-footer.tsx
Normal file
28
components/branding/tagline-footer.tsx
Normal 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't be afraid to cry</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>Wulf Consulting</span>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/branding/wulf-mark.tsx
Normal file
44
components/branding/wulf-mark.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/dashboard/active-engineers.tsx
Normal file
73
components/dashboard/active-engineers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
components/dashboard/kpi-card.tsx
Normal file
123
components/dashboard/kpi-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
components/dashboard/queue-heatmap.tsx
Normal file
134
components/dashboard/queue-heatmap.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
components/dashboard/resolution-trend.tsx
Normal file
75
components/dashboard/resolution-trend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
components/dashboard/volume-trend.tsx
Normal file
78
components/dashboard/volume-trend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
icon: Activity,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'Admin home',
|
||||||
href: '/admin',
|
href: '/admin',
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
description: 'Sync, mappings, workflow, reports, tools & access'
|
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,8 +208,10 @@ 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">
|
||||||
|
<MobileNav items={visibleItems} pathname={pathname} isActive={isActive} />
|
||||||
|
<Link href="/" className="flex items-center space-x-3">
|
||||||
<img
|
<img
|
||||||
src="/wulff-logo.png"
|
src="/wulff-logo.png"
|
||||||
alt="Wulf Consulting"
|
alt="Wulf Consulting"
|
||||||
|
|
@ -172,124 +219,105 @@ export function AppNavigation() {
|
||||||
/>
|
/>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<h1 className="text-xl font-semibold tracking-tight">Pulse</h1>
|
<h1 className="text-xl font-semibold tracking-tight">Pulse</h1>
|
||||||
<p className="text-xs text-muted-foreground">PSA Management System</p>
|
<p className="text-xs text-muted-foreground">Operations console</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Navigation — centered */}
|
{/* Main Navigation — centered, desktop only */}
|
||||||
<NavigationMenu>
|
<NavigationMenu className="hidden md:flex">
|
||||||
<NavigationMenuList>
|
<NavigationMenuList>
|
||||||
{visibleItems.map((item) => (
|
{visibleItems.map((item) => {
|
||||||
|
const childActive = item.children?.some((c) => isActive(c.href)) ?? false;
|
||||||
|
const flatActive = isActive(item.href);
|
||||||
|
// Brand-blue 2px underline marks active state — echoes the
|
||||||
|
// PageHeader rule rather than filling the button with primary.
|
||||||
|
const activeRule = 'relative after:absolute after:inset-x-2 after:bottom-0 after:h-[2px] after:bg-primary after:rounded-full';
|
||||||
|
return (
|
||||||
<NavigationMenuItem key={item.title}>
|
<NavigationMenuItem key={item.title}>
|
||||||
{item.children ? (
|
{item.children ? (
|
||||||
<>
|
<>
|
||||||
<NavigationMenuTrigger className={cn(
|
<NavigationMenuTrigger
|
||||||
"h-9 px-4 py-2",
|
className={cn(
|
||||||
item.children.some(child => isActive(child.href)) && "bg-primary text-primary-foreground"
|
'h-9 px-4 py-2',
|
||||||
)}>
|
childActive && cn(activeRule, 'text-foreground'),
|
||||||
|
)}
|
||||||
|
>
|
||||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||||
{item.title}
|
{item.title}
|
||||||
</NavigationMenuTrigger>
|
</NavigationMenuTrigger>
|
||||||
<NavigationMenuContent>
|
<NavigationMenuContent>
|
||||||
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
|
<ul className="grid gap-1 p-2 min-w-[320px] max-w-[440px]">
|
||||||
{item.children.map((child) => (
|
{item.children.map((child) => {
|
||||||
|
const active = isActive(child.href);
|
||||||
|
return (
|
||||||
<li key={child.title}>
|
<li key={child.title}>
|
||||||
<NavigationMenuLink asChild>
|
<NavigationMenuLink asChild>
|
||||||
<Link
|
<Link
|
||||||
href={child.href || '#'}
|
href={child.href || '#'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
'flex items-start gap-3 rounded-sm px-3 py-2 leading-none no-underline outline-none transition-colors',
|
||||||
isActive(child.href) && "bg-primary text-primary-foreground"
|
'hover:bg-accent/40 focus:bg-accent/40',
|
||||||
|
active && 'bg-primary/10 text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center text-sm font-medium leading-none">
|
{child.icon && (
|
||||||
{child.icon && <child.icon className="w-4 h-4 mr-2" />}
|
<child.icon
|
||||||
|
className={cn(
|
||||||
|
'w-4 h-4 mt-0.5 shrink-0',
|
||||||
|
active ? 'text-primary' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn('text-sm font-medium leading-none', active && 'text-primary')}>
|
||||||
{child.title}
|
{child.title}
|
||||||
</div>
|
</div>
|
||||||
{child.description && (
|
{child.description && (
|
||||||
<p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
|
<p className="mt-1 line-clamp-2 text-xs leading-snug text-muted-foreground">
|
||||||
{child.description}
|
{child.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Link href={item.href || '#'} legacyBehavior passHref>
|
<Link href={item.href || '#'} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={cn(
|
<NavigationMenuLink
|
||||||
|
className={cn(
|
||||||
navigationMenuTriggerStyle(),
|
navigationMenuTriggerStyle(),
|
||||||
"h-9",
|
'h-9',
|
||||||
isActive(item.href) && "bg-primary text-primary-foreground"
|
flatActive && cn(activeRule, 'text-foreground'),
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||||
{item.title}
|
{item.title}
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</NavigationMenuItem>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
149
components/navigation/mobile-nav.tsx
Normal file
149
components/navigation/mobile-nav.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/navigation/page-header.tsx
Normal file
91
components/navigation/page-header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
components/navigation/status-indicator.tsx
Normal file
71
components/navigation/status-indicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
components/navigation/user-menu.tsx
Normal file
100
components/navigation/user-menu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
components/status/worker-pulse.tsx
Normal file
118
components/status/worker-pulse.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
components/ui/empty-state.tsx
Normal file
66
components/ui/empty-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
143
components/ui/sheet.tsx
Normal 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,
|
||||||
|
}
|
||||||
97
components/ui/skeleton-helpers.tsx
Normal file
97
components/ui/skeleton-helpers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
components/ui/status-badge.tsx
Normal file
75
components/ui/status-badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/ui/status-light.tsx
Normal file
53
components/ui/status-light.tsx
Normal 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
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
BIN
docs/StandardsGuide (1).pdf
Normal file
Binary file not shown.
BIN
docs/WULF_RGB (1).png
Normal file
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
BIN
docs/W_RGB (1).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
263
lib/status-registry.ts
Normal 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
3680
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
BIN
public/branding/cr-email-tagline.png
Normal file
BIN
public/branding/cr-email-tagline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/branding/wulf-mark.png
Normal file
BIN
public/branding/wulf-mark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
public/branding/wulf-wordmark.png
Normal file
BIN
public/branding/wulf-wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Loading…
Add table
Add a link
Reference in a new issue