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.
|
||||
|
||||
`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
|
||||
- Next.js 16 + React 19 (App Router, `reactCompiler: true`, `output: 'standalone'`)
|
||||
|
|
@ -73,7 +79,9 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
|
|||
| Datto RMM | `DATTO_RMM_*` |
|
||||
| Veeam VSPC | `VEEAM_VSPC_*` |
|
||||
| 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` |
|
||||
|
||||
## Sync & scheduling
|
||||
|
|
@ -82,8 +90,11 @@ Examples: `getAutotaskClient()`, `getMsgraphClient()`, `getDattoRmmClient()`,
|
|||
- `lib/services/sync-scheduler.ts` — node-cron singleton. **Self-initializes on
|
||||
first server-side import** (side effect at the bottom of the file). Schedules
|
||||
live in DB, admin-editable at `/admin`.
|
||||
- Webhooks (`/api/webhooks/...`, `/api/zabbix/webhook`) are public per
|
||||
`middleware.ts`; they verify HMAC themselves.
|
||||
- Webhooks (`/api/webhooks/...`, `/api/zabbix/webhook`, `/api/rmm/loglift`) are
|
||||
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
|
||||
- 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
|
||||
- Build: `npm run build` (turbopack via Next 16)
|
||||
- Type check: `npx tsc --noEmit --pretty`
|
||||
- Tests: `npm test` (vitest) — currently scoped to `lib/services/analyzer/**` only.
|
||||
No CI yet; tests are local-only. Other parts of the codebase have no tests —
|
||||
if you touch them, type-check is the only safety net.
|
||||
- Tests: `npm test` (vitest) — covers `lib/services/analyzer/**`,
|
||||
`lib/services/rmm/**`, `lib/services/b2/**`, and `lib/services/analyzer/
|
||||
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`
|
||||
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.
|
||||
- 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
|
||||
- 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.
|
||||
- Duplicate migration numbers exist (002, 004, 009) — alphabetical apply order.
|
||||
- Sync scheduler runs as a side effect of importing `sync-scheduler.ts` on the
|
||||
server. Be careful adding eager imports of that module.
|
||||
- Sync scheduler, analyzer worker, and RMM worker all auto-start as side effects
|
||||
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
|
||||
- `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
|
||||
- `POSTGRES_SYNC_SETUP.md`, `DOCKER_README.md`
|
||||
- `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 { 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() {
|
||||
const [devices, setDevices] = useState<AddigyDevice[]>([]);
|
||||
|
|
@ -10,21 +27,19 @@ export default function AddigyDevicesPage() {
|
|||
const [filterOnline, setFilterOnline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
void fetchDevices();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterOnline]);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
async function fetchDevices() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = filterOnline
|
||||
? '/api/addigy-devices?online=true'
|
||||
: '/api/addigy-devices';
|
||||
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
setDevices(result.data);
|
||||
} else {
|
||||
|
|
@ -35,159 +50,137 @@ export default function AddigyDevicesPage() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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">
|
||||
Found {devices.length} device{devices.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Addigy devices"
|
||||
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="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Device Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Model
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
OS Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
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 className="container mx-auto px-6 py-6 space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-6 space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{device['Serial Number'] || 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{device['Device Model Name'] || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{device['MAC OS X Version'] ||
|
||||
device['iOS Version'] ||
|
||||
'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{device['Current User'] || 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
device.online
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{device.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{device['Free Disk Percentage'] !== undefined ? (
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`${
|
||||
device['Free Disk Percentage'] < 20
|
||||
? 'text-red-600'
|
||||
: device['Free Disk Percentage'] < 40
|
||||
? 'text-yellow-600'
|
||||
: 'text-green-600'
|
||||
}`}
|
||||
>
|
||||
{device['Free Disk Percentage']}%
|
||||
</span>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
icon={Laptop}
|
||||
title="No devices found"
|
||||
description={
|
||||
filterOnline
|
||||
? 'No devices are currently online.'
|
||||
: 'Addigy has not synced any devices yet.'
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
</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>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 { AuditLogTable } from "@/components/admin/audit/audit-log-table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
export default function AuditLogPage() {
|
||||
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="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 />}>
|
||||
<AuditLogTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Database, Table2, Users, Ticket, CheckSquare, FolderKanban, Wrench, Tag, ArrowLeft, Home, Clock, MessageSquare } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
const entities = [
|
||||
{ name: 'Companies', icon: Users, path: '/admin/data-browser/companies', description: 'View all companies' },
|
||||
|
|
@ -23,8 +24,13 @@ const entities = [
|
|||
|
||||
export default function DataBrowserPage() {
|
||||
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="/">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
|
|
@ -32,13 +38,9 @@ export default function DataBrowserPage() {
|
|||
<span className="hidden sm:inline">Back to Dashboard</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Database className="w-8 h-8" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Database Browser</h1>
|
||||
<p className="text-muted-foreground">Inspect synced data from PostgreSQL</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
/>
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{entities.map((entity) => {
|
||||
const Icon = entity.icon;
|
||||
|
|
@ -58,5 +60,6 @@ export default function DataBrowserPage() {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface Candidate {
|
||||
ciId: string;
|
||||
|
|
@ -105,6 +106,13 @@ export default function DeviceLinkConflictsPage() {
|
|||
}
|
||||
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -249,5 +257,6 @@ export default function DeviceLinkConflictsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface CompanyCategory {
|
||||
value: number;
|
||||
|
|
@ -287,17 +288,14 @@ export default function DisplaySettingsPage() {
|
|||
}
|
||||
|
||||
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="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">
|
||||
<Section
|
||||
title="Kiosk"
|
||||
|
|
@ -319,5 +317,6 @@ export default function DisplaySettingsPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface WriteRow {
|
||||
id: string;
|
||||
|
|
@ -76,6 +77,13 @@ export default function ItglueWritesPage() {
|
|||
}, [statusFilter]);
|
||||
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -164,5 +172,6 @@ export default function ItglueWritesPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Send, RefreshCw, Trash2, Plus, CheckCircle2, XCircle,
|
||||
AlertTriangle, Clock, Loader2, ChevronDown, ChevronUp, ToggleLeft, ToggleRight,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface WebhookConfig {
|
||||
id: number;
|
||||
|
|
@ -210,6 +211,22 @@ export default function MorningSummaryPage() {
|
|||
}
|
||||
|
||||
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">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
|
|
@ -219,21 +236,6 @@ export default function MorningSummaryPage() {
|
|||
</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 */}
|
||||
{latestSummary && (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
|
|
@ -463,5 +465,6 @@ export default function MorningSummaryPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
import {
|
||||
RefreshCw,
|
||||
Network,
|
||||
|
|
@ -273,14 +274,14 @@ export default function AdminIndexPage() {
|
|||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-7xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Admin</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sync, mappings, workflows, reporting, and tooling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<PageHeader
|
||||
title="Admin"
|
||||
description="Sync, mappings, workflows, reporting, and tooling."
|
||||
breadcrumbs={[{ label: 'Admin' }]}
|
||||
accent
|
||||
/>
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{sections.map((section) => (
|
||||
<Card key={section.title}>
|
||||
|
|
@ -317,5 +318,6 @@ export default function AdminIndexPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
|
||||
Link2, Link2Off, FileText, CreditCard, Building2, ArrowDownToLine, BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface QboStatus {
|
||||
tokenStatus: 'valid' | 'expired' | 'missing';
|
||||
|
|
@ -118,19 +119,20 @@ function QboPageInner() {
|
|||
}[status?.tokenStatus ?? 'missing'];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">QuickBooks Online</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">Sync invoices, payments, deposits, transactions and financial reports</p>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
title="QuickBooks Online"
|
||||
description="Sync invoices, payments, deposits, transactions and financial reports"
|
||||
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'QuickBooks Online' }]}
|
||||
accent
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
}
|
||||
/>
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
{/* Banner */}
|
||||
{banner && (
|
||||
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
|
||||
|
|
@ -234,6 +236,7 @@ function QboPageInner() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Loader2, RefreshCw, Terminal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface Settings {
|
||||
overshellComponentUid: string | null;
|
||||
|
|
@ -94,6 +95,13 @@ export default function RmmOvershellAdminPage() {
|
|||
}
|
||||
|
||||
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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -268,5 +276,6 @@ export default function RmmOvershellAdminPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { RoleTable } from "@/components/admin/roles/role-table";
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
export default function RolesPage() {
|
||||
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="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 />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||
|
|
@ -62,14 +63,14 @@ export default function SettingsPage() {
|
|||
}
|
||||
|
||||
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="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">
|
||||
<TabsList>
|
||||
<TabsTrigger value="microsoft">Microsoft</TabsTrigger>
|
||||
|
|
@ -169,5 +170,6 @@ export default function SettingsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ import { useState, useEffect } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 {
|
||||
ArrowLeft, Activity, History, Monitor, Loader2, RefreshCw,
|
||||
ExternalLink, Server, Wifi, WifiOff, AlertTriangle, Bell, XCircle, CheckCircle2, Clock,
|
||||
|
|
@ -108,39 +117,38 @@ function HistoryTab({ refreshKey }: { refreshKey: number }) {
|
|||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Records</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead className="text-right">Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row: any, i: number) => {
|
||||
const dur = row.completed_at && row.started_at
|
||||
? Math.round((new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 1000)
|
||||
: null;
|
||||
const tone = row.status === 'completed' ? 'ok' : row.status === 'failed' ? 'error' : 'warn';
|
||||
return (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 capitalize">{row.sync_type}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
row.status === 'completed' ? 'bg-green-500/15 text-green-700' :
|
||||
row.status === 'failed' ? 'bg-red-500/15 text-red-600' :
|
||||
'bg-yellow-500/15 text-yellow-700'
|
||||
}`}>{row.status}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 tabular-nums">{row.records_added ?? 0}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{dur != null ? `${dur}s` : '—'}</td>
|
||||
</tr>
|
||||
<TableRow key={i}>
|
||||
<TableCell className="capitalize">{row.sync_type}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge tone={tone}>{row.status}</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right num">{row.records_added ?? 0}</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{fmtDate(row.started_at)}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground num">
|
||||
{dur != null ? `${dur}s` : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
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';
|
||||
|
||||
interface DuoStatus {
|
||||
|
|
@ -226,37 +234,37 @@ export default function DuoSyncPage() {
|
|||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Child Accounts ({childAccounts.length})</h2>
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium">Account Name</th>
|
||||
<th className="text-right px-4 py-2 font-medium">Users</th>
|
||||
<th className="text-right px-4 py-2 font-medium">Integrations</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Matched Company</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Last Sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Account name</TableHead>
|
||||
<TableHead className="text-right">Users</TableHead>
|
||||
<TableHead className="text-right">Integrations</TableHead>
|
||||
<TableHead>Matched company</TableHead>
|
||||
<TableHead>Last sync</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{childAccounts.map(a => (
|
||||
<tr key={a.account_id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 font-medium">{a.name}</td>
|
||||
<td className="px-4 py-2 text-right">{a.user_count}</td>
|
||||
<td className="px-4 py-2 text-right">{a.integration_count}</td>
|
||||
<td className="px-4 py-2">
|
||||
<TableRow key={a.account_id}>
|
||||
<TableCell className="font-medium">{a.name}</TableCell>
|
||||
<TableCell className="text-right num">{a.user_count}</TableCell>
|
||||
<TableCell className="text-right num">{a.integration_count}</TableCell>
|
||||
<TableCell>
|
||||
{a.autotask_company_name ? (
|
||||
<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}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{fmtDate(a.synced_at)}</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{fmtDate(a.synced_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -294,40 +302,38 @@ function FlaggedUsersTable({ title, description, users, icon, borderColor, bgCol
|
|||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className={bgColor}>
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium">User</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Email</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Account</th>
|
||||
<th className="text-center px-4 py-2 font-medium">Enrolled</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Last Login</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<Table>
|
||||
<TableHeader className={bgColor}>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead className="text-center">Enrolled</TableHead>
|
||||
<TableHead>Last login</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map(u => (
|
||||
<tr key={u.user_id} className={`hover:${bgColor}`}>
|
||||
<td className="px-4 py-2">
|
||||
<TableRow key={u.user_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{u.realname || u.username}</div>
|
||||
{u.realname && <div className="text-xs text-muted-foreground">{u.username}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{u.email || '\u2014'}</td>
|
||||
<td className="px-4 py-2">{u.account_name}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{u.email || '\u2014'}</TableCell>
|
||||
<TableCell>{u.account_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{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>
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{u.last_login ? fmtDate(u.last_login) : 'Never'}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{u.last_login ? fmtDate(u.last_login) : 'Never'}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs max-w-[200px] truncate">{u.notes || '\u2014'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ import { useState, useEffect, useCallback } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 {
|
||||
ArrowLeft, Activity, History, BookOpen, Loader2, RefreshCw,
|
||||
ExternalLink, CheckCircle2, XCircle, Clock, AlertTriangle,
|
||||
|
|
@ -41,17 +50,17 @@ function StatCard({
|
|||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'completed' ? 'bg-green-500/15 text-green-700' :
|
||||
status === 'failed' ? 'bg-red-500/15 text-red-600' :
|
||||
status === 'running' ? 'bg-blue-500/15 text-blue-700' :
|
||||
'bg-yellow-500/15 text-yellow-700';
|
||||
function SyncStatusBadge({ status }: { status: string }) {
|
||||
const tone =
|
||||
status === 'completed' ? 'ok' :
|
||||
status === 'failed' ? 'error' :
|
||||
status === 'running' ? 'info' :
|
||||
'warn';
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
<StatusBadge tone={tone}>
|
||||
{status === 'running' && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
||||
{status}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -122,33 +131,33 @@ function StatusTab({
|
|||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
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>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Entity</th>
|
||||
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
|
||||
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
|
||||
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead className="text-right">Records</TableHead>
|
||||
<TableHead className="text-right">Duration</TableHead>
|
||||
<TableHead className="text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{latest.entities.map((e: any, i: number) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-mono text-xs">{e.entity}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{e.recordsUpserted.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(e.duration)}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<TableRow key={i}>
|
||||
<TableCell className="num text-xs">{e.entity}</TableCell>
|
||||
<TableCell className="text-right num">{e.recordsUpserted.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground num">{fmtDuration(e.duration)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{e.success
|
||||
? <CheckCircle2 className="w-4 h-4 text-green-500 inline" />
|
||||
: <span title={e.error}><XCircle className="w-4 h-4 text-red-500 inline" /></span>}
|
||||
</td>
|
||||
</tr>
|
||||
? <CheckCircle2 className="w-4 h-4 text-emerald-500 inline" />
|
||||
: <span title={e.error}><XCircle className="w-4 h-4 text-destructive inline" /></span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -167,28 +176,28 @@ function HistoryTab({ history }: { history: any[] }) {
|
|||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
|
||||
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
|
||||
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Triggered by</TableHead>
|
||||
<TableHead className="text-right">Records</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead className="text-right">Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{history.map((row: any, i: number) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2"><StatusBadge status={row.status} /></td>
|
||||
<td className="px-4 py-2 text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums">{(row.total_upserted ?? 0).toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
|
||||
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(row.duration_ms)}</td>
|
||||
</tr>
|
||||
<TableRow key={i}>
|
||||
<TableCell><SyncStatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</TableCell>
|
||||
<TableCell className="text-right num">{(row.total_upserted ?? 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{fmtDate(row.started_at)}</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground num">{fmtDuration(row.duration_ms)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ import {
|
|||
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
|
||||
PauseCircle, Building2, Check, Info, TrendingUp, ExternalLink, Eye, Trash2,
|
||||
} 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';
|
||||
|
||||
function fmtDate(d: string | null | undefined) {
|
||||
|
|
@ -39,32 +49,24 @@ function StatCard({ label, value, sub, icon: Icon, cls }: {
|
|||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'delivered' ? 'bg-green-500/15 text-green-700' :
|
||||
status === 'rejected' ? 'bg-red-500/15 text-red-600' :
|
||||
status === 'held' ? 'bg-yellow-500/15 text-yellow-700' :
|
||||
status === 'bounced' ? 'bg-orange-500/15 text-orange-700' :
|
||||
status === 'spam' ? 'bg-purple-500/15 text-purple-700' :
|
||||
'bg-muted text-muted-foreground';
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{status || '—'}
|
||||
</span>
|
||||
);
|
||||
function MessageStatusBadge({ status }: { status: string }) {
|
||||
const tone =
|
||||
status === 'delivered' ? 'ok' :
|
||||
status === 'rejected' ? 'error' :
|
||||
status === 'held' ? 'warn' :
|
||||
status === 'bounced' ? 'warn' :
|
||||
status === 'spam' ? 'accent' :
|
||||
'inactive';
|
||||
return <StatusBadge tone={tone}>{status || '—'}</StatusBadge>;
|
||||
}
|
||||
|
||||
function ThreatBadge({ level }: { level: string }) {
|
||||
const cls =
|
||||
level === 'high' ? 'bg-red-500/15 text-red-600' :
|
||||
level === 'medium' ? 'bg-orange-500/15 text-orange-700' :
|
||||
level === 'low' ? 'bg-yellow-500/15 text-yellow-700' :
|
||||
'bg-muted text-muted-foreground';
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{level || 'info'}
|
||||
</span>
|
||||
);
|
||||
function ThreatLevelBadge({ level }: { level: string }) {
|
||||
const tone =
|
||||
level === 'high' ? 'error' :
|
||||
level === 'medium' ? 'warn' :
|
||||
level === 'low' ? 'pending' :
|
||||
'inactive';
|
||||
return <StatusBadge tone={tone}>{level || 'info'}</StatusBadge>;
|
||||
}
|
||||
|
||||
// ── Status Tab ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -211,30 +213,30 @@ function MessagesTab() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">From</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">To</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Subject</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Direction</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>From</TableHead>
|
||||
<TableHead>To</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Sent</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r: any) => (
|
||||
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.sender_address}>{r.sender_address ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs truncate max-w-[180px]" title={r.recipient_address}>{r.recipient_address ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs truncate max-w-[200px]" title={r.subject}>{r.subject ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs capitalize text-muted-foreground">{r.direction ?? '—'}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={r.status} /></td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.sent_datetime)}</td>
|
||||
</tr>
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="text-xs truncate max-w-[180px]" title={r.sender_address}>{r.sender_address ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs truncate max-w-[180px]" title={r.recipient_address}>{r.recipient_address ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs truncate max-w-[200px]" title={r.subject}>{r.subject ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs capitalize text-muted-foreground">{r.direction ?? '—'}</TableCell>
|
||||
<TableCell><MessageStatusBadge status={r.status} /></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap num">{fmtDate(r.sent_datetime)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -260,32 +262,32 @@ function ThreatsTab() {
|
|||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Level</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Actor</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Verdict</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">URL / File</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Level</TableHead>
|
||||
<TableHead>Actor</TableHead>
|
||||
<TableHead>Verdict</TableHead>
|
||||
<TableHead>URL / File</TableHead>
|
||||
<TableHead>When</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r: any) => (
|
||||
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 text-xs capitalize">{r.event_type ?? '—'}</td>
|
||||
<td className="px-4 py-2"><ThreatBadge level={r.threat_level} /></td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{r.actor_email ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground capitalize">{r.verdict ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground truncate max-w-[200px]" title={r.url ?? r.file_name ?? ''}>
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="text-xs capitalize">{r.event_type ?? '—'}</TableCell>
|
||||
<TableCell><ThreatLevelBadge level={r.threat_level} /></TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{r.actor_email ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground capitalize">{r.verdict ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground truncate max-w-[200px]" title={r.url ?? r.file_name ?? ''}>
|
||||
{r.url ?? r.file_name ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">{fmtDate(r.event_datetime)}</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap num">{fmtDate(r.event_datetime)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -420,41 +422,44 @@ function HistoryTab() {
|
|||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Messages</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Threats</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Messages</TableHead>
|
||||
<TableHead>Threats</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r: any, i: number) => {
|
||||
const dur = r.completed_at && r.started_at
|
||||
? new Date(r.completed_at).getTime() - new Date(r.started_at).getTime()
|
||||
: null;
|
||||
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 ?? {});
|
||||
|
||||
return (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 capitalize text-xs">{r.sync_type ?? '—'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusCls}`}>{r.status}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.messagesUpserted ?? r.records_added)}</td>
|
||||
<td className="px-4 py-2 tabular-nums text-xs">{fmtNum(meta.threatsUpserted)}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(r.started_at)}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{durStr}</td>
|
||||
</tr>
|
||||
<TableRow key={i}>
|
||||
<TableCell className="capitalize text-xs">{r.sync_type ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge tone={statusTone}>{r.status}</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="num text-xs">{fmtNum(meta.messagesUpserted ?? r.records_added)}</TableCell>
|
||||
<TableCell className="num text-xs">{fmtNum(meta.threatsUpserted)}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground num">{fmtDate(r.started_at)}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground num">{durStr}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -901,44 +906,45 @@ function HeldMailTab() {
|
|||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th style={{width:'120px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th>
|
||||
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">To</th>
|
||||
<th style={{width:'180px'}} className="text-left px-3 py-2 font-medium text-xs">From</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th>
|
||||
<th style={{width:'160px'}} className="text-left px-3 py-2 font-medium text-xs">Policy</th>
|
||||
<th style={{width:'160px'}} className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead style={{width:'120px'}} className="text-xs">Date</TableHead>
|
||||
<TableHead style={{width:'160px'}} className="text-xs">To</TableHead>
|
||||
<TableHead style={{width:'180px'}} className="text-xs">From</TableHead>
|
||||
<TableHead className="text-xs">Subject</TableHead>
|
||||
<TableHead style={{width:'160px'}} className="text-xs">Policy</TableHead>
|
||||
<TableHead style={{width:'160px'}}></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((m: any) => (
|
||||
<tr key={m.id} className="hover:bg-muted/20">
|
||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap text-xs">
|
||||
<TableRow key={m.id}>
|
||||
<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' })}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||
<div className="truncate">{m.to}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{overflow:'hidden'}}>
|
||||
</TableCell>
|
||||
<TableCell style={{overflow:'hidden'}}>
|
||||
<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>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||
<div className="truncate">{m.subject || '(no subject)'}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{overflow:'hidden'}}>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
</TableCell>
|
||||
<TableCell style={{overflow:'hidden'}}>
|
||||
<StatusBadge
|
||||
tone={
|
||||
m.policyInfo?.includes('DMARC') || m.policyInfo?.includes('Impersonation')
|
||||
? 'bg-red-500/10 text-red-600'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
? 'error'
|
||||
: 'inactive'
|
||||
}
|
||||
>
|
||||
{m.policyInfo || m.reason || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -963,12 +969,11 @@ function HeldMailTab() {
|
|||
{releaseErrors[m.id] && (
|
||||
<div className="text-xs text-red-500 text-right mt-0.5">{releaseErrors[m.id]}</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1385,13 +1390,15 @@ function DeliveredAnalysisDialog({ message, onClose, onFindSimilar, allMessages
|
|||
<div className="max-h-40 overflow-y-auto divide-y">
|
||||
{remedMatches.map(m => (
|
||||
<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)}
|
||||
onChange={e => {
|
||||
onCheckedChange={(v) => {
|
||||
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);
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs truncate">{m.subject || '(no subject)'}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
|
@ -1808,68 +1815,70 @@ function DeliveredMailTab() {
|
|||
|
||||
{loaded && !loading && filtered.length > 0 && (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th style={{width:'110px'}} className="text-left px-3 py-2 font-medium text-xs">Date</th>
|
||||
<th style={{width:'150px'}} className="text-left px-3 py-2 font-medium text-xs">To</th>
|
||||
<th style={{width:'170px'}} className="text-left px-3 py-2 font-medium text-xs">From</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-xs">Subject</th>
|
||||
<th style={{width:'90px'}} className="text-left px-3 py-2 font-medium text-xs">Status</th>
|
||||
<th style={{width:'80px'}} className="text-left px-3 py-2 font-medium text-xs">Spam</th>
|
||||
<th style={{width:'90px'}} className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead style={{width:'110px'}} className="text-xs">Date</TableHead>
|
||||
<TableHead style={{width:'150px'}} className="text-xs">To</TableHead>
|
||||
<TableHead style={{width:'170px'}} className="text-xs">From</TableHead>
|
||||
<TableHead className="text-xs">Subject</TableHead>
|
||||
<TableHead style={{width:'90px'}} className="text-xs">Status</TableHead>
|
||||
<TableHead style={{width:'80px'}} className="text-xs">Spam</TableHead>
|
||||
<TableHead style={{width:'90px'}}></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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
|
||||
? 'bg-red-500/5'
|
||||
: m.spamScore >= 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' })}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||
<div className="truncate">{m.to}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2" style={{overflow:'hidden'}}>
|
||||
</TableCell>
|
||||
<TableCell style={{overflow:'hidden'}}>
|
||||
<div className="text-xs truncate font-medium">{m.from}</div>
|
||||
{m.fromEnv && m.fromEnv !== m.from && (
|
||||
<div className="text-xs text-muted-foreground truncate">{m.fromEnv}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs" style={{overflow:'hidden'}}>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs" style={{overflow:'hidden'}}>
|
||||
<div className="truncate">{m.subject || '(no subject)'}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${
|
||||
m.status === 'accepted' ? 'bg-green-500/10 text-green-700'
|
||||
: m.status === 'held' ? 'bg-amber-500/10 text-amber-700'
|
||||
: m.status === 'rejected' || m.status === 'bounced' ? 'bg-red-500/10 text-red-600'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>{m.status}</span>
|
||||
</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'}`}>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
tone={
|
||||
m.status === 'accepted' ? 'ok' :
|
||||
m.status === 'held' ? 'warn' :
|
||||
m.status === 'rejected' || m.status === 'bounced' ? 'error' :
|
||||
'inactive'
|
||||
}
|
||||
>
|
||||
{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}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs px-2 whitespace-nowrap"
|
||||
onClick={() => setAnalysisMessage(m)}>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, CheckCircle2, XCircle, AlertTriangle, Clock, Loader2, ChevronRight } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface IntegrationCard {
|
||||
id: string;
|
||||
|
|
@ -222,18 +223,20 @@ export default function SyncOverviewPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold">Integrations & Sync</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Manage data sync across all connected platforms</p>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
title="Integrations & Sync"
|
||||
description="Manage data sync across all connected platforms"
|
||||
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Integrations & Sync' }]}
|
||||
accent
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={fetchAll} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
}
|
||||
/>
|
||||
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
|
|
@ -424,5 +427,6 @@ export default function SyncOverviewPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ import {
|
|||
CheckCircle2, XCircle, AlertTriangle, Clock, Server, HardDrive,
|
||||
Bot, Bell, ChevronDown, ChevronRight, Target, Play,
|
||||
} 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';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -35,13 +44,13 @@ function StatCard({ label, value, sub, icon: Icon, cls }: {
|
|||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === 'completed' ? 'bg-green-500/15 text-green-700' :
|
||||
status === 'failed' ? 'bg-red-500/15 text-red-600' :
|
||||
status === 'started' ? 'bg-blue-500/15 text-blue-700' :
|
||||
'bg-yellow-500/15 text-yellow-700';
|
||||
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>{status}</span>;
|
||||
function SyncStatusBadge({ status }: { status: string }) {
|
||||
const tone =
|
||||
status === 'completed' ? 'ok' :
|
||||
status === 'failed' ? 'error' :
|
||||
status === 'started' ? 'info' :
|
||||
'warn';
|
||||
return <StatusBadge tone={tone}>{status}</StatusBadge>;
|
||||
}
|
||||
|
||||
// ── Status Tab ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -135,11 +144,11 @@ function HistoryRow({ row }: { row: any }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`border-b hover:bg-muted/30 ${entities.length > 0 ? 'cursor-pointer' : ''}`}
|
||||
<TableRow
|
||||
className={entities.length > 0 ? 'cursor-pointer' : ''}
|
||||
onClick={() => entities.length > 0 && setExpanded(e => !e)}
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{entities.length > 0
|
||||
? (expanded
|
||||
|
|
@ -148,16 +157,16 @@ function HistoryRow({ row }: { row: any }) {
|
|||
: <span className="w-3.5" />}
|
||||
<span className="capitalize">{row.sync_type}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5"><StatusBadge status={row.status} /></td>
|
||||
<td className="px-4 py-2.5 tabular-nums font-medium">{(row.records_added ?? 0).toLocaleString()}</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground text-xs">{fmtDate(row.started_at)}</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground">{dur != null ? fmtDur(dur) : '—'}</td>
|
||||
<td className="px-4 py-2.5 text-muted-foreground capitalize">{row.triggered_by ?? '—'}</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell><SyncStatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="num font-medium">{(row.records_added ?? 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs num">{fmtDate(row.started_at)}</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{dur != null ? fmtDur(dur) : '—'}</TableCell>
|
||||
<TableCell className="text-muted-foreground capitalize">{row.triggered_by ?? '—'}</TableCell>
|
||||
</TableRow>
|
||||
{expanded && entities.length > 0 && (
|
||||
<tr className="border-b bg-muted/20">
|
||||
<td colSpan={6} className="px-8 py-3">
|
||||
<TableRow className="bg-muted/20">
|
||||
<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>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{entities.map((e) => (
|
||||
|
|
@ -176,8 +185,8 @@ function HistoryRow({ row }: { row: any }) {
|
|||
{row.error_message}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -201,21 +210,21 @@ function VeeamHistoryTab({ refreshKey }: { refreshKey: number }) {
|
|||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Records</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Duration</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Records</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Triggered By</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => <HistoryRow key={i} row={row} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -253,45 +262,49 @@ function AgentsTab({ refreshKey }: { refreshKey: number }) {
|
|||
</div>
|
||||
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Name</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Platform</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Agent Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Version</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Mode</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Jobs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Platform</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Agent Status</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead>Jobs</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agents.map((a: any) => (
|
||||
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-medium">{a.name}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground text-xs">{a.organization_name ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs">{a.agent_platform ?? '—'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
a.status === 'Active' ? 'bg-green-500/15 text-green-700' : 'bg-muted text-muted-foreground'
|
||||
}`}>{a.status ?? '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
a.management_agent_status === 'Inaccessible' ? 'bg-red-500/15 text-red-600' :
|
||||
a.management_agent_status === 'Accessible' ? 'bg-green-500/15 text-green-700' :
|
||||
'bg-muted text-muted-foreground'
|
||||
}`}>{a.management_agent_status ?? '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
<TableRow key={a.instance_uid}>
|
||||
<TableCell className="font-medium">{a.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{a.organization_name ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs">{a.agent_platform ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge tone={a.status === 'Active' ? 'ok' : 'inactive'}>
|
||||
{a.status ?? '—'}
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
tone={
|
||||
a.management_agent_status === 'Inaccessible' ? 'error' :
|
||||
a.management_agent_status === 'Accessible' ? 'ok' :
|
||||
'inactive'
|
||||
}
|
||||
>
|
||||
{a.management_agent_status ?? '—'}
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<span className={a.version_status === 'Outdated' ? 'text-yellow-700 font-medium' : 'text-muted-foreground'}>
|
||||
{a.version ?? '—'}
|
||||
{a.version_status === 'Outdated' && ' ⚠'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{a.operation_mode ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{a.operation_mode ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs num">
|
||||
<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.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)}✗
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -342,41 +355,45 @@ function AlarmsTab({ refreshKey }: { refreshKey: number }) {
|
|||
</div>
|
||||
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Object</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Type</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Repeats</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Activation</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Object</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Repeats</TableHead>
|
||||
<TableHead>Last Activation</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alarms.map((a: any) => (
|
||||
<tr key={a.instance_uid} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-medium">{a.object_computer_name || a.object_name || '—'}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{a.object_type ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{a.organization_name ?? '—'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
a.last_activation_status === 'Active' ? 'bg-red-500/15 text-red-600' :
|
||||
a.last_activation_status === 'Warning' ? 'bg-yellow-500/15 text-yellow-700' :
|
||||
a.last_activation_status === 'Resolved' ? 'bg-green-500/15 text-green-700' :
|
||||
'bg-muted text-muted-foreground'
|
||||
}`}>{a.last_activation_status ?? '—'}</span>
|
||||
</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>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={a.last_activation_message ?? ''}>
|
||||
<TableRow key={a.instance_uid}>
|
||||
<TableCell className="font-medium">{a.object_computer_name || a.object_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{a.object_type ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{a.organization_name ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge
|
||||
tone={
|
||||
a.last_activation_status === 'Active' ? 'error' :
|
||||
a.last_activation_status === 'Warning' ? 'warn' :
|
||||
a.last_activation_status === 'Resolved' ? 'ok' :
|
||||
'inactive'
|
||||
}
|
||||
>
|
||||
{a.last_activation_status ?? '—'}
|
||||
</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() || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -463,47 +480,48 @@ function RpoTab({ refreshKey }: { refreshKey: number }) {
|
|||
<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>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Overdue</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Failure Reason</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Ticket</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Job</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Last Backup</TableHead>
|
||||
<TableHead>Overdue</TableHead>
|
||||
<TableHead>Failure Reason</TableHead>
|
||||
<TableHead>Ticket</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{breachedJobs.map((j: any) => {
|
||||
const hrs = j.hours_since_backup;
|
||||
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'
|
||||
: j.open_ticket?.priority_level === 'high' ? 'bg-orange-500/15 text-orange-700'
|
||||
: 'bg-yellow-500/15 text-yellow-700';
|
||||
const ticketTone =
|
||||
j.open_ticket?.priority_level === 'critical' ? 'error' :
|
||||
j.open_ticket?.priority_level === 'high' ? 'warn' :
|
||||
'pending';
|
||||
return (
|
||||
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td>
|
||||
<td className="px-4 py-2 tabular-nums text-xs font-semibold text-red-600">{display}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground max-w-xs truncate" title={j.failure_category ?? ''}>
|
||||
<TableRow key={j.job_instance_uid}>
|
||||
<TableCell className="font-medium text-xs">{j.job_name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{j.org_name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground num">{fmtDate(j.last_end_time)}</TableCell>
|
||||
<TableCell className="num text-xs font-semibold text-red-600">{display}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-xs truncate" title={j.failure_category ?? ''}>
|
||||
{j.failure_category ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{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}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No ticket yet</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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">
|
||||
<CheckCircle2 className="w-4 h-4" />Within RPO ({healthyJobs.length})
|
||||
</summary>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 border-b">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Job</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Organization</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Last Backup</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Hours Ago</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground">RPO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Job</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Last Backup</TableHead>
|
||||
<TableHead>Hours Ago</TableHead>
|
||||
<TableHead>RPO</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{healthyJobs.map((j: any) => (
|
||||
<tr key={j.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-2 font-medium text-xs">{j.job_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{j.org_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{fmtDate(j.last_end_time)}</td>
|
||||
<td className="px-4 py-2 tabular-nums text-xs text-green-700">
|
||||
<TableRow key={j.job_instance_uid}>
|
||||
<TableCell className="font-medium text-xs">{j.job_name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{j.org_name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground num">{fmtDate(j.last_end_time)}</TableCell>
|
||||
<TableCell className="num text-xs text-green-700">
|
||||
{j.hours_since_backup !== null ? `${j.hours_since_backup}h` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{j.rpo_hours}h</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground num">{j.rpo_hours}h</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
|
||||
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface DigestConfig {
|
||||
daily_enabled: boolean;
|
||||
|
|
@ -172,6 +173,18 @@ export default function TicketDigestPage() {
|
|||
);
|
||||
|
||||
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">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
|
|
@ -180,21 +193,6 @@ export default function TicketDigestPage() {
|
|||
</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 */}
|
||||
<div className="border rounded-lg p-5 space-y-4">
|
||||
<h2 className="font-semibold text-lg flex items-center gap-2">
|
||||
|
|
@ -452,5 +450,6 @@ export default function TicketDigestPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
import { Suspense } from "react";
|
||||
import { UserTable } from "@/components/admin/users/user-table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
export default function UsersPage() {
|
||||
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="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 />}>
|
||||
<UserTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
PauseCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
interface TicketWorkflow {
|
||||
id: number;
|
||||
|
|
@ -109,25 +110,22 @@ export default function WorkflowListPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Workflow className="w-6 h-6" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Ticket Workflows</h1>
|
||||
<p className="text-sm text-muted-foreground">Automated ticket triage and classification</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<PageHeader
|
||||
title="Ticket Workflows"
|
||||
description="Automated ticket triage and classification"
|
||||
breadcrumbs={[{ label: 'Admin', href: '/admin' }, { label: 'Ticket Workflows' }]}
|
||||
accent
|
||||
actions={
|
||||
<Link href="/admin/workflow/create">
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
}
|
||||
/>
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Master Control */}
|
||||
<Card className={globalEnabled ? 'border-green-500/50' : 'border-gray-300'}>
|
||||
<CardHeader>
|
||||
|
|
@ -285,5 +283,6 @@ export default function WorkflowListPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { HostManager } from '@/components/zabbix/host-manager';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
type SyncMode = 'all' | 'client' | 'site';
|
||||
|
||||
|
|
@ -433,19 +434,14 @@ export default function ZabbixWanPage() {
|
|||
};
|
||||
|
||||
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">
|
||||
{/* 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 */}
|
||||
<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]) => (
|
||||
|
|
@ -1423,5 +1419,6 @@ export default function ZabbixWanPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AnalysisView } from '@/components/analyzer/analysis-view';
|
|||
import { ItglueSuggestionsPanel } from '@/components/analyzer/itglue-suggestions-panel';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
|
||||
export default function AnalysisDetailPage({
|
||||
params,
|
||||
|
|
@ -36,7 +37,21 @@ export default function AnalysisDetailPage({
|
|||
};
|
||||
}, [id]);
|
||||
|
||||
const ticketNumber = analysis?.ticketNumber;
|
||||
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">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
|
|
@ -58,5 +73,6 @@ export default function AnalysisDetailPage({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
ProviderToggle,
|
||||
type AnalyzerProvider,
|
||||
} from '@/components/analyzer/provider-toggle';
|
||||
import { PageHeader } from '@/components/navigation/page-header';
|
||||
import { Sparkles, Zap } from 'lucide-react';
|
||||
import type { PersistedAnalysis } from '@/lib/types/analyzer';
|
||||
|
||||
|
|
@ -50,29 +51,24 @@ export default function TicketAnalyzerPage({
|
|||
const latest = analyses?.[0];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Ticket</p>
|
||||
<CardTitle className="font-mono">{ticketNumber}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<>
|
||||
<PageHeader
|
||||
title={ticketNumber}
|
||||
description="Run the analyzer pipeline against this ticket. Each provider keeps its own history; same-hash runs are instant."
|
||||
breadcrumbs={[
|
||||
{ label: 'Analyzer', href: '/analyzer/tickets' },
|
||||
{ label: 'Tickets', href: '/analyzer/tickets' },
|
||||
{ label: ticketNumber },
|
||||
]}
|
||||
accent
|
||||
actions={
|
||||
<>
|
||||
<ProviderToggle value={provider} onChange={setProvider} size="sm" />
|
||||
<AnalyzeButton ticketNumber={ticketNumber} provider={provider} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click <strong>Analyze</strong> to run the AI pipeline using the
|
||||
selected provider. Each provider keeps its own analysis history,
|
||||
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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="container mx-auto px-6 py-6 max-w-4xl space-y-6">
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
|
|
@ -163,5 +159,6 @@ export default function TicketAnalyzerPage({
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
* GET /api/dashboard/overview
|
||||
* 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
|
||||
* observations — recent device_observations (loglift et al.)
|
||||
* 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
|
||||
*/
|
||||
|
||||
|
|
@ -20,6 +21,9 @@ export async function GET() {
|
|||
type Counts = { count: string };
|
||||
|
||||
const [
|
||||
todayRes,
|
||||
yesterdayOpenedRes,
|
||||
last7AvgResolvedRes,
|
||||
linkConflictsRes,
|
||||
itglueUnlinkedRes,
|
||||
s1UnmappedRes,
|
||||
|
|
@ -31,6 +35,44 @@ export async function GET() {
|
|||
ciRes,
|
||||
xrefRes,
|
||||
] = 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>(
|
||||
`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({
|
||||
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: {
|
||||
linkConflicts: parseInt(linkConflictsRes.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 { Skeleton } from '@/components/ui/skeleton';
|
||||
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';
|
||||
|
||||
interface BackupStatusData {
|
||||
|
|
@ -284,28 +293,28 @@ export default function BackupStatusPage() {
|
|||
|
||||
{/* Job Table */}
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Job</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Organization</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Last Backup</th>
|
||||
<th className="px-4 py-3 text-left font-medium">RMM Device</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Ticket</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Failure Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Job</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Last Backup</TableHead>
|
||||
<TableHead>RMM Device</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Failure Reason</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rpo.jobs.map((job) => (
|
||||
<tr key={job.job_instance_uid} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{job.job_name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{job.org_name}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{timeAgoHours(job.hours_since_backup)}</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
<TableRow key={job.job_instance_uid}>
|
||||
<TableCell className="font-medium">{job.job_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{job.org_name}</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{timeAgoHours(job.hours_since_backup)}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{job.rmm_hostname ? (
|
||||
<div>
|
||||
<span className="font-mono">{job.rmm_hostname}</span>
|
||||
<span className="num">{job.rmm_hostname}</span>
|
||||
{job.is_offline_suppressed && (
|
||||
<div className="flex items-center gap-1 mt-0.5 text-muted-foreground">
|
||||
<WifiOff className="h-3 w-3" />
|
||||
|
|
@ -316,21 +325,21 @@ export default function BackupStatusPage() {
|
|||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{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
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
) : 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>
|
||||
<td className="px-4 py-3">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{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 === 'high' ? 'text-orange-500' : 'text-muted-foreground'
|
||||
}`}>
|
||||
|
|
@ -339,19 +348,19 @@ export default function BackupStatusPage() {
|
|||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate">
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-xs truncate">
|
||||
{job.failure_category ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{rpo.jobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</td>
|
||||
</tr>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -363,43 +372,43 @@ export default function BackupStatusPage() {
|
|||
No Autotask ticket is created while the device is offline.
|
||||
</p>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">Device</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Job</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Organization</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Type</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Last Seen</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Offline</th>
|
||||
<th className="px-4 py-3 text-left font-medium">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>Job</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Last Seen</TableHead>
|
||||
<TableHead>Offline</TableHead>
|
||||
<TableHead>Checked</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{offlineLog.map((row) => (
|
||||
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-mono text-xs">{row.rmm_hostname}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[180px] truncate">{row.job_name}</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{row.org_name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="num text-xs">{row.rmm_hostname}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[180px] truncate">{row.job_name}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.org_name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{row.device_type_category}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.rmm_last_seen)}</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground num">{timeAgo(row.rmm_last_seen)}</TableCell>
|
||||
<TableCell className="text-xs num">
|
||||
{row.hours_offline >= 48
|
||||
? `${Math.round(row.hours_offline / 24)}d`
|
||||
: `${Math.round(row.hours_offline)}h`}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.checked_at)}</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground num">{timeAgo(row.checked_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{offlineLog.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No offline suppressions logged yet</td>
|
||||
</tr>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No offline suppressions logged yet</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -585,9 +585,9 @@ function ConfigurationItemsContent() {
|
|||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{/* 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" />
|
||||
<div className="w-[500px]">
|
||||
<div className="w-full sm:flex-1 sm:min-w-[280px] sm:max-w-[500px]">
|
||||
<CompanySelectorEnhanced
|
||||
value={selectedCompany}
|
||||
onValueChange={handleCompanyChange}
|
||||
|
|
@ -595,10 +595,10 @@ function ConfigurationItemsContent() {
|
|||
/>
|
||||
</div>
|
||||
{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">
|
||||
<Server className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
PSA: {stats?.totalAutotask || 0} | RMM: {stats?.totalRmm || 0} | NMS: {stats?.totalAuvik || 0} | ARMM: {stats?.totalAddigy || 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-primary" />
|
||||
<span className="text-xs font-medium num">
|
||||
PSA {stats?.totalAutotask || 0} · RMM {stats?.totalRmm || 0} · NMS {stats?.totalAuvik || 0} · ARMM {stats?.totalAddigy || 0}
|
||||
</span>
|
||||
</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';
|
||||
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
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 { 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 {
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Shield,
|
||||
CalendarClock,
|
||||
RefreshCw,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Plug,
|
||||
KeyRound,
|
||||
Users,
|
||||
Layers,
|
||||
TrendingUp,
|
||||
Timer,
|
||||
} 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 {
|
||||
today: {
|
||||
openedToday: number;
|
||||
resolvedToday: number;
|
||||
openTotal: number;
|
||||
slaBreaches: number;
|
||||
yesterdayOpened: number;
|
||||
last7DayAvgResolved: number;
|
||||
};
|
||||
attention: {
|
||||
linkConflicts: number;
|
||||
itglueUnlinked: number;
|
||||
|
|
@ -78,16 +67,6 @@ interface Overview {
|
|||
fieldGapsCount: number;
|
||||
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: {
|
||||
activeCompanies: 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 {
|
||||
if (!iso) return 'never';
|
||||
|
|
@ -110,42 +104,26 @@ function relTime(iso: string | null): string {
|
|||
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() {
|
||||
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 [loading, setLoading] = useState(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [overviewRes, healthRes] = await Promise.all([
|
||||
fetch('/api/dashboard/overview'),
|
||||
fetch('/api/dashboard/integration-health'),
|
||||
const [overviewRes, trendsRes] = await Promise.all([
|
||||
fetch('/api/dashboard/overview', { cache: 'no-store' }),
|
||||
fetch('/api/dashboard/trends', { cache: 'no-store' }),
|
||||
]);
|
||||
if (!overviewRes.ok) {
|
||||
const body = (await overviewRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${overviewRes.status}`);
|
||||
}
|
||||
setData((await overviewRes.json()) as Overview);
|
||||
if (healthRes.ok) {
|
||||
setHealth((await healthRes.json()) as IntegrationHealthResponse);
|
||||
if (trendsRes.ok) {
|
||||
setTrends((await trendsRes.json()) as Trends);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
|
|
@ -159,16 +137,35 @@ export default function DashboardPage() {
|
|||
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 (
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<>
|
||||
<PageHeader
|
||||
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}>
|
||||
<RefreshCw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
|
|
@ -176,62 +173,187 @@ export default function DashboardPage() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* NEEDS ATTENTION ----------------------------------------------------- */}
|
||||
{/* TODAY SNAPSHOT ----------------------------------------------- */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Needs attention
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<AttentionCard
|
||||
icon={AlertTriangle}
|
||||
value={data?.attention.linkConflicts}
|
||||
label="Device-link conflicts"
|
||||
href="/admin/device-link-conflicts"
|
||||
tone={data && data.attention.linkConflicts > 0 ? 'warn' : 'ok'}
|
||||
<h2 className="metric-label mb-3">Today</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KpiCard
|
||||
label="Opened"
|
||||
value={today?.openedToday ?? null}
|
||||
delta={
|
||||
today
|
||||
? { value: openedDelta, label: 'vs yesterday' }
|
||||
: undefined
|
||||
}
|
||||
caption={today && `Yesterday: ${today.yesterdayOpened}`}
|
||||
loading={!data}
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={Database}
|
||||
value={data?.attention.itglueUnlinked}
|
||||
label="IT Glue ↛ Autotask"
|
||||
sub="unlinked configurations"
|
||||
href="/admin/device-link-conflicts"
|
||||
tone="info"
|
||||
<KpiCard
|
||||
label="Resolved"
|
||||
value={today?.resolvedToday ?? null}
|
||||
delta={
|
||||
today
|
||||
? {
|
||||
value: resolvedDelta,
|
||||
label: 'vs 7-day avg',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
caption={today && `7-day avg: ${today.last7DayAvgResolved}`}
|
||||
tone="accent"
|
||||
loading={!data}
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={Shield}
|
||||
value={data?.attention.s1Unmapped}
|
||||
label="S1 unmapped"
|
||||
sub="missing site → company mapping"
|
||||
href="/sentinelone/mappings"
|
||||
tone="info"
|
||||
<KpiCard
|
||||
label="Open total"
|
||||
value={today?.openTotal ?? null}
|
||||
loading={!data}
|
||||
/>
|
||||
<AttentionCard
|
||||
icon={CalendarClock}
|
||||
value={data?.attention.schedules.enabled}
|
||||
label={`Schedules on / ${data?.attention.schedules.total ?? '—'}`}
|
||||
href="/admin/sync/autotask"
|
||||
tone="info"
|
||||
<KpiCard
|
||||
label="SLA breaches"
|
||||
value={today?.slaBreaches ?? null}
|
||||
tone={
|
||||
today && today.slaBreaches > 0 ? 'attention' : 'default'
|
||||
}
|
||||
caption={
|
||||
today && today.slaBreaches === 0
|
||||
? 'All on track'
|
||||
: 'Past due, still open'
|
||||
}
|
||||
loading={!data}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<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
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
{!data ? (
|
||||
<RowSkeletons />
|
||||
) : data?.observations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No observations recorded yet.</p>
|
||||
) : data.observations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="No observations recorded"
|
||||
description="Device telemetry from LogLift and RMM will appear here."
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data?.observations.map((o) => (
|
||||
{data.observations.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
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="font-medium truncate">{o.hostname ?? '(unanchored)'}</div>
|
||||
<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>}
|
||||
</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)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -256,18 +378,23 @@ export default function DashboardPage() {
|
|||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="size-4" />
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Recent audits
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
{!data ? (
|
||||
<RowSkeletons />
|
||||
) : data?.audits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No endpoint audits yet.</p>
|
||||
) : data.audits.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="No endpoint audits yet"
|
||||
description="Asset-audit results from the analyzer will appear here."
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data?.audits.map((a) => (
|
||||
{data.audits.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
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="font-medium truncate">{a.hostname ?? '(unanchored)'}</div>
|
||||
<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>}
|
||||
</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)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -290,156 +419,19 @@ export default function DashboardPage() {
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
{/* INTEGRATION HEALTH -------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Plug className="size-4" />
|
||||
Integration health
|
||||
{health?.summary.hasIssues && (
|
||||
<Badge variant="destructive" className="text-[10px]">issues</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!health ? (
|
||||
<RowSkeletons />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{health.items
|
||||
.slice()
|
||||
.sort((a, b) => statusOrder(a.status) - statusOrder(b.status))
|
||||
.map((i) => (
|
||||
<IntegrationRow key={i.key} item={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SYNC HEALTH --------------------------------------------------------- */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Sync health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data === null && !error ? (
|
||||
<RowSkeletons />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
|
||||
{data?.syncHealth.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between py-1.5 text-sm border-b last:border-0">
|
||||
<div className="min-w-0 flex-1 truncate">{s.name}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0 ml-3">
|
||||
<span>{relTime(s.lastRun)}</span>
|
||||
{syncStatusIcon(s)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* STATS FOOTER -------------------------------------------------------- */}
|
||||
{/* STATS FOOTER ------------------------------------------------- */}
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.stats.activeCompanies} companies · {data.stats.configurationItems.toLocaleString()} CIs ·{' '}
|
||||
{data.stats.xref.total.toLocaleString()} xref rows (
|
||||
{data.stats.xref.total > 0
|
||||
<span className="num">{data.stats.activeCompanies}</span> active companies ·{' '}
|
||||
<span className="num">{data.stats.configurationItems.toLocaleString()}</span> configuration items ·{' '}
|
||||
<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)
|
||||
: 0}
|
||||
% linked)
|
||||
: 0}% linked)
|
||||
</p>
|
||||
)}
|
||||
</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';
|
||||
import { Users, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface UserOption {
|
||||
|
|
@ -579,66 +587,61 @@ export default function EngagementProfilePage() {
|
|||
<CardTitle className="text-sm font-medium">Monthly Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<th className="text-left px-4 py-2.5 font-medium">Month</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium">Hours</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium">Billable</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium">Bill %</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden sm:table-cell">Days</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Meetings</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden md:table-cell">Messages</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Emails</th>
|
||||
<th className="text-right px-3 py-2.5 font-medium hidden lg:table-cell">Calls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<TableHead>Month</TableHead>
|
||||
<TableHead className="text-right">Hours</TableHead>
|
||||
<TableHead className="text-right">Billable</TableHead>
|
||||
<TableHead className="text-right">Bill %</TableHead>
|
||||
<TableHead className="text-right hidden sm:table-cell">Days</TableHead>
|
||||
<TableHead className="text-right hidden md:table-cell">Meetings</TableHead>
|
||||
<TableHead className="text-right hidden md:table-cell">Messages</TableHead>
|
||||
<TableHead className="text-right hidden lg:table-cell">Emails</TableHead>
|
||||
<TableHead className="text-right hidden lg:table-cell">Calls</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...monthly].reverse().map(m => {
|
||||
const pct = m.hoursWorked > 0 ? Math.round((m.billableHours / m.hoursWorked) * 100) : 0;
|
||||
const isEmpty =
|
||||
m.hoursWorked === 0 && m.teamsMessages === 0 && m.emailsSent === 0;
|
||||
const totalCalls = m.zoomClientCalls + m.teamsCalls;
|
||||
return (
|
||||
<tr
|
||||
<TableRow
|
||||
key={m.month}
|
||||
className={cn(
|
||||
'border-b last:border-0 hover:bg-muted/30 transition-colors',
|
||||
isEmpty && 'opacity-40'
|
||||
)}
|
||||
className={cn(isEmpty && 'opacity-40')}
|
||||
>
|
||||
<td className="px-4 py-2 font-medium">{monthLabel(m.month)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
<TableCell className="font-medium">{monthLabel(m.month)}</TableCell>
|
||||
<TableCell className="text-right num">
|
||||
{m.hoursWorked > 0 ? m.hoursWorked.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num text-emerald-600 dark:text-emerald-400">
|
||||
{m.billableHours > 0 ? m.billableHours.toFixed(1) : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num">
|
||||
{m.hoursWorked > 0 ? `${pct}%` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden sm:table-cell">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num hidden sm:table-cell">
|
||||
{m.daysWorked > 0 ? m.daysWorked : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num hidden md:table-cell">
|
||||
{m.totalMeetings > 0 ? m.totalMeetings : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden md:table-cell">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num hidden md:table-cell">
|
||||
{m.teamsMessages > 0 ? m.teamsMessages : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num hidden lg:table-cell">
|
||||
{m.emailsSent > 0 ? m.emailsSent : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums hidden lg:table-cell">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num hidden lg:table-cell">
|
||||
{totalCalls > 0 ? totalCalls : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: var(--font-plex-sans), 'Helvetica Neue', Helvetica, Arial, 'Liberation Sans', sans-serif;
|
||||
--font-mono: var(--font-plex-mono), ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
@ -121,3 +121,5 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@import "./styles/brand.css";
|
||||
|
|
|
|||
|
|
@ -1,16 +1,32 @@
|
|||
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 { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AppNavigation } from "@/components/navigation/app-navigation";
|
||||
import { TaglineFooter } from "@/components/branding/tagline-footer";
|
||||
import { Toaster } from "sonner";
|
||||
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 = {
|
||||
title: "Pulse - PSA Management System",
|
||||
description: "Modern dashboard for Autotask PSA integration with RMM and NMS mapping",
|
||||
title: "Pulse · Operations console",
|
||||
description: "Wulf Consulting operations console — tickets, RMM, IT Glue, backups, and analytics in one place.",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", sizes: "any" },
|
||||
|
|
@ -27,8 +43,8 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<html lang="en" suppressHydrationWarning className={`${plexSans.variable} ${plexMono.variable}`}>
|
||||
<body className="font-sans antialiased">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
|
@ -36,9 +52,10 @@ export default function RootLayout({
|
|||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<AppNavigation />
|
||||
<main>{children}</main>
|
||||
<main className="flex-1">{children}</main>
|
||||
<TaglineFooter />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
<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 { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
|
||||
} from 'recharts';
|
||||
|
|
@ -144,19 +152,19 @@ function TicketRow({ t, categoryFilter, onFilter }: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b hover:bg-muted/10 cursor-pointer text-xs align-middle"
|
||||
<TableRow
|
||||
className="cursor-pointer text-xs align-middle"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
<td className="pl-3 pr-2 py-2 w-6">
|
||||
<TableCell className="w-6 pl-3">
|
||||
{open
|
||||
? <ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3 w-3 text-muted-foreground" />}
|
||||
</td>
|
||||
<td className="pr-3 py-2 font-mono font-medium">{t.ticket_number}</td>
|
||||
<td className="px-3 py-2 max-w-[140px] truncate">{t.company_name ?? '—'}</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px]">{t.device_hostname ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
</TableCell>
|
||||
<TableCell className="num font-medium">{t.ticket_number}</TableCell>
|
||||
<TableCell className="max-w-[140px] truncate">{t.company_name ?? '—'}</TableCell>
|
||||
<TableCell className="num text-[11px]">{t.device_hostname ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{ borderColor: catCfg?.color, color: catCfg?.color }}
|
||||
|
|
@ -164,15 +172,15 @@ function TicketRow({ t, categoryFilter, onFilter }: {
|
|||
>
|
||||
{catLabel(t.problem_category)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{resLabel(t.resolution_type)}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{resLabel(t.resolution_type)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{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>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{parseFloat(t.hours_worked).toFixed(2)}h</td>
|
||||
<td className="px-3 py-2">
|
||||
</TableCell>
|
||||
<TableCell className="text-right num">{parseFloat(t.hours_worked).toFixed(2)}h</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }}
|
||||
|
|
@ -180,12 +188,12 @@ function TicketRow({ t, categoryFilter, onFilter }: {
|
|||
>
|
||||
{t.complexity}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{timeAgo(t.ticket_created_at)}</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num">{timeAgo(t.ticket_created_at)}</TableCell>
|
||||
</TableRow>
|
||||
{open && (
|
||||
<tr className="border-b bg-muted/5">
|
||||
<td colSpan={10} className="px-8 pb-3 pt-2">
|
||||
<TableRow className="bg-muted/5">
|
||||
<TableCell colSpan={10} className="px-8 pb-3 pt-2">
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
{t.work_summary && (
|
||||
|
|
@ -222,8 +230,8 @@ function TicketRow({ t, categoryFilter, onFilter }: {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -668,23 +676,22 @@ export default function VeeamAnalysisPage() {
|
|||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-xs">
|
||||
<th className="w-6" />
|
||||
<th className="px-3 py-2.5 text-left font-medium">Ticket</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium">Client</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium">Device</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium">Category</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium">Resolution</th>
|
||||
<th className="px-3 py-2.5 text-center font-medium">Same-day</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium">Hours</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium">Complexity</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-6" />
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Resolution</TableHead>
|
||||
<TableHead className="text-center">Same-day</TableHead>
|
||||
<TableHead className="text-right">Hours</TableHead>
|
||||
<TableHead>Complexity</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.tickets.length > 0
|
||||
? data.tickets.map(t => (
|
||||
<TicketRow
|
||||
|
|
@ -695,15 +702,14 @@ export default function VeeamAnalysisPage() {
|
|||
/>
|
||||
))
|
||||
: (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No analyzed tickets yet — run the analysis above.
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
RefreshCw, CheckCircle2, AlertTriangle, WifiOff, GitCompare,
|
||||
Ticket, ChevronRight, ChevronDown, Sparkles, Loader2,
|
||||
|
|
@ -326,21 +334,21 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
|
|||
return (
|
||||
<>
|
||||
{/* Group summary header — columns align with the detail table below */}
|
||||
<tr
|
||||
className="border-b bg-muted/30 hover:bg-muted/50 cursor-pointer select-none"
|
||||
<TableRow
|
||||
className="bg-muted/30 cursor-pointer select-none"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{/* 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">
|
||||
{open
|
||||
? <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" />}
|
||||
<span className="font-semibold text-sm truncate">{group.org_name ?? 'Unknown'}</span>
|
||||
</div>
|
||||
</td>
|
||||
</TableCell>
|
||||
{/* Match col: status pills */}
|
||||
<td className="px-3 py-2.5 w-36">
|
||||
<TableCell className="w-36">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.counts.both > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</TableCell>
|
||||
{/* 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
|
||||
? <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>}
|
||||
</td>
|
||||
</TableCell>
|
||||
{/* 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} ticket{group.totalAtTickets !== 1 ? 's' : ''}{group.totalAtOpen > 0 && <span className="text-orange-500 ml-1">· {group.totalAtOpen} open</span>}</>
|
||||
: <span>—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Device detail rows */}
|
||||
{open && group.rows.map((row) => {
|
||||
const cfg = STATUS_CONFIG[row.status];
|
||||
return (
|
||||
<tr key={row.key} className={`border-b last:border-0 hover:bg-muted/10 align-top text-xs ${cfg.rowAccent}`}>
|
||||
<td className="pl-9 pr-3 py-2.5 font-mono font-medium w-44 text-[11px]">
|
||||
<TableRow key={row.key} className={`align-top text-xs ${cfg.rowAccent}`}>
|
||||
<TableCell className="pl-9 num font-medium w-44 text-[11px]">
|
||||
{row.hostname ?? <span className="italic text-muted-foreground">unknown</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 w-36">
|
||||
</TableCell>
|
||||
<TableCell className="w-36">
|
||||
<Badge variant={cfg.badgeVariant} className="text-[10px]">{cfg.label}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 space-y-0.5 max-w-[240px]">
|
||||
</TableCell>
|
||||
<TableCell className="space-y-0.5 max-w-[240px]">
|
||||
{row.pulse ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AtTicketCell
|
||||
tickets={row.at_tickets}
|
||||
ticketCount={row.at_ticket_count}
|
||||
|
|
@ -407,8 +415,8 @@ function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpe
|
|||
orgName={row.org_name}
|
||||
hoursOffline={row.offline?.hours_offline}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
@ -533,16 +541,16 @@ export default function VeeamComparisonPage() {
|
|||
|
||||
<TabsContent value={filter} className="mt-4">
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs w-44">Device</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs w-36">Match</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs">Pulse Shadow</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-xs">Autotask Tickets</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="text-xs w-44">Device</TableHead>
|
||||
<TableHead className="text-xs w-36">Match</TableHead>
|
||||
<TableHead className="text-xs">Pulse Shadow</TableHead>
|
||||
<TableHead className="text-xs">Autotask Tickets</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.length > 0 ? groups.map(group => (
|
||||
<ClientGroupRow
|
||||
key={group.org_name ?? 'unknown'}
|
||||
|
|
@ -550,16 +558,16 @@ export default function VeeamComparisonPage() {
|
|||
defaultOpen={(group.counts.both + group.counts.pulse_only) > 0}
|
||||
/>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
{data.matches.length === 0
|
||||
? 'No data yet — RPO check must run at least once.'
|
||||
: 'No rows match this filter.'}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{groups.length > 0 && (
|
||||
<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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useMemo, useState } from 'react';
|
||||
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 { 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 { 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;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (value: any, row: any) => React.ReactNode;
|
||||
render?: (value: any, row: TData) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: Column[];
|
||||
data: any[];
|
||||
export interface DataTableProps<TData = any> {
|
||||
columns: Column<TData>[];
|
||||
data: TData[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onSort?: (column: string, direction: 'asc' | 'desc') => void;
|
||||
onSearch?: (query: string) => void;
|
||||
onRowClick?: (row: any) => void;
|
||||
onRowClick?: (row: TData) => void;
|
||||
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,
|
||||
data,
|
||||
totalCount,
|
||||
|
|
@ -40,37 +105,97 @@ export default function DataTable({
|
|||
onSearch,
|
||||
onRowClick,
|
||||
isLoading = false,
|
||||
}: DataTableProps) {
|
||||
getRowCanExpand,
|
||||
renderSubRow,
|
||||
emptyTitle = 'No data found',
|
||||
emptyDescription = 'Try adjusting your search or filters.',
|
||||
}: DataTableProps<TData>) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
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) => {
|
||||
if (!onSort) return;
|
||||
// Translate the legacy Column shape into TanStack ColumnDef.
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(() => {
|
||||
const cols: ColumnDef<TData>[] = [];
|
||||
|
||||
const newDirection = sortColumn === columnKey && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
setSortColumn(columnKey);
|
||||
setSortDirection(newDirection);
|
||||
onSort(columnKey, newDirection);
|
||||
};
|
||||
// Lead expansion column when expansion is enabled.
|
||||
if (expandable) {
|
||||
cols.push({
|
||||
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 = () => {
|
||||
if (onSearch) {
|
||||
onSearch(searchQuery);
|
||||
}
|
||||
onSearch?.(searchQuery);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
{onSearch && (
|
||||
<div className="flex gap-2">
|
||||
<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
|
||||
placeholder="Search..."
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
|
|
@ -84,92 +209,82 @@ export default function DataTable({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="border rounded-lg overflow-hidden bg-card">
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key} className="font-semibold">
|
||||
{column.sortable ? (
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
|
||||
{headerGroup.headers.map((header) => {
|
||||
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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSort(column.key)}
|
||||
className="h-8 -ml-3 hover:bg-muted/80 transition-colors"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className="h-8 -ml-3"
|
||||
>
|
||||
{column.label}
|
||||
{sortColumn === column.key ? (
|
||||
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" />
|
||||
)}
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<SortIcon dir={sortDir === 'asc' ? 'asc' : sortDir === 'desc' ? 'desc' : null} />
|
||||
</Button>
|
||||
) : (
|
||||
column.label
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
renderLoadingRows(table)
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<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 colSpan={tanstackColumns.length} className="py-8">
|
||||
<EmptyState icon={Search} title={emptyTitle} description={emptyDescription} size="sm" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<TableRow
|
||||
key={row.id || index}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<ExpandableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
onRowClick={onRowClick}
|
||||
renderSubRow={renderSubRow}
|
||||
colSpan={tanstackColumns.length}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<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">
|
||||
Showing <span className="font-semibold text-foreground">{Math.min((page - 1) * pageSize + 1, totalCount)}</span> to{' '}
|
||||
<span className="font-semibold text-foreground">{Math.min(page * pageSize, totalCount)}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{totalCount}</span> results
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground num">
|
||||
{totalCount === 0 ? 0 : Math.min((page - 1) * pageSize + 1, totalCount)}
|
||||
</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 className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1 || isLoading}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="First page"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -177,22 +292,22 @@ export default function DataTable({
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1 || isLoading}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-3">
|
||||
<span className="text-sm font-medium">
|
||||
Page {page} of {totalPages || 1}
|
||||
<span className="px-3 text-sm font-medium num">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page === totalPages || isLoading}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -200,8 +315,9 @@ export default function DataTable({
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page === totalPages || isLoading}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="Last page"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -210,3 +326,57 @@ export default function DataTable({
|
|||
</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 { 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 { 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';
|
||||
|
||||
// ── 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) ───────────────────────────────────────
|
||||
|
||||
interface Lookups {
|
||||
|
|
@ -188,23 +132,24 @@ const COMPANY_GROUPS: FieldGroup[] = [
|
|||
|
||||
// ── 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 } {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return { display: <span className="text-muted-foreground/40 italic text-xs">—</span>, isEmpty: true };
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'bool':
|
||||
case 'bool': {
|
||||
const badge = yesNoBadge(Boolean(value));
|
||||
return {
|
||||
display: value
|
||||
? <ColorBadge cls="bg-green-500/15 text-green-600 border border-green-500/30"><Check className="w-3 h-3 mr-1" />Yes</ColorBadge>
|
||||
: <ColorBadge cls="bg-slate-500/15 text-slate-500 border border-slate-500/30"><X className="w-3 h-3 mr-1" />No</ColorBadge>,
|
||||
display: (
|
||||
<StatusBadge variantClass={badge.variantClass}>
|
||||
{value ? <Check className="w-3 h-3 mr-1" /> : <X className="w-3 h-3 mr-1" />}
|
||||
{badge.label}
|
||||
</StatusBadge>
|
||||
),
|
||||
isEmpty: false,
|
||||
};
|
||||
}
|
||||
case 'date': {
|
||||
try {
|
||||
const d = new Date(value);
|
||||
|
|
@ -221,28 +166,24 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look
|
|||
}
|
||||
case 'status': {
|
||||
const label = lookups.statuses[Number(value)] ?? `Status ${value}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return { display: <ColorBadge cls={cls}>{label}</ColorBadge>, isEmpty: false };
|
||||
const badge = ticketStatusBadge(label);
|
||||
return { display: <StatusBadge {...badge} />, isEmpty: false };
|
||||
}
|
||||
case 'priority': {
|
||||
const p = PRIORITY_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={p?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{p?.label ?? `Priority ${value}`}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...priorityBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'source': {
|
||||
const label = SOURCE_MAP[Number(value)] ?? `Source ${value}`;
|
||||
return { display: <ColorBadge cls="bg-violet-500/15 text-violet-600 border border-violet-500/30">{label}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...sourceBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'queue': {
|
||||
const qLabel = 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 };
|
||||
const label = lookups.queues[Number(value)] ?? `Queue ${value}`;
|
||||
return { display: <StatusBadge variantClass={paletteClass('indigo')}>{label}</StatusBadge>, isEmpty: false };
|
||||
}
|
||||
case 'company_type': {
|
||||
const ct = COMPANY_TYPE_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={ct?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{ct?.label ?? `Type ${value}`}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...companyTypeBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'classification': {
|
||||
const cl = CLASSIFICATION_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={cl?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{cl?.label ?? `Classification ${value}`}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...classificationBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'resource': {
|
||||
const name = lookups.resources[Number(value)];
|
||||
|
|
@ -264,11 +205,11 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look
|
|||
}
|
||||
case 'issue_type': {
|
||||
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': {
|
||||
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': {
|
||||
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">
|
||||
{(() => {
|
||||
const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return <ColorBadge cls={cls}>{label}</ColorBadge>;
|
||||
return <StatusBadge {...ticketStatusBadge(label)} />;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -427,9 +367,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</DialogDescription>
|
||||
</div>
|
||||
{'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'}>
|
||||
{data.is_active ? 'Active' : 'Inactive'}
|
||||
</ColorBadge>
|
||||
<StatusBadge {...activeBadge(Boolean(data.is_active))} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -652,10 +590,10 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</span>
|
||||
)}
|
||||
{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 && (
|
||||
<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>
|
||||
{entry.notes && (
|
||||
|
|
@ -695,14 +633,6 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div key={note.id} className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
|
@ -714,9 +644,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</span>
|
||||
)}
|
||||
{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'}`}>
|
||||
{publishLabel[note.publish] ?? `Publish ${note.publish}`}
|
||||
</span>
|
||||
<StatusBadge {...publishBadge(Number(note.publish))} />
|
||||
)}
|
||||
{note.title && (
|
||||
<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';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
interface NavItem {
|
||||
|
|
@ -132,9 +135,51 @@ const navigationItems: NavItem[] = [
|
|||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
icon: Activity,
|
||||
children: [
|
||||
{
|
||||
title: 'Admin home',
|
||||
href: '/admin',
|
||||
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 (
|
||||
<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">
|
||||
{/* Logo and App Name */}
|
||||
<Link href="/" className="flex items-center space-x-3 shrink-0">
|
||||
{/* Mobile hamburger + brand */}
|
||||
<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
|
||||
src="/wulff-logo.png"
|
||||
alt="Wulf Consulting"
|
||||
|
|
@ -172,124 +219,105 @@ export function AppNavigation() {
|
|||
/>
|
||||
<div className="hidden sm:block">
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation — centered */}
|
||||
<NavigationMenu>
|
||||
{/* Main Navigation — centered, desktop only */}
|
||||
<NavigationMenu className="hidden md:flex">
|
||||
<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}>
|
||||
{item.children ? (
|
||||
<>
|
||||
<NavigationMenuTrigger className={cn(
|
||||
"h-9 px-4 py-2",
|
||||
item.children.some(child => isActive(child.href)) && "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
<NavigationMenuTrigger
|
||||
className={cn(
|
||||
'h-9 px-4 py-2',
|
||||
childActive && cn(activeRule, 'text-foreground'),
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.title}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]">
|
||||
{item.children.map((child) => (
|
||||
<ul className="grid gap-1 p-2 min-w-[320px] max-w-[440px]">
|
||||
{item.children.map((child) => {
|
||||
const active = isActive(child.href);
|
||||
return (
|
||||
<li key={child.title}>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link
|
||||
href={child.href || '#'}
|
||||
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",
|
||||
isActive(child.href) && "bg-primary text-primary-foreground"
|
||||
'flex items-start gap-3 rounded-sm px-3 py-2 leading-none no-underline outline-none transition-colors',
|
||||
'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 className="w-4 h-4 mr-2" />}
|
||||
{child.icon && (
|
||||
<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}
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</>
|
||||
) : (
|
||||
<Link href={item.href || '#'} legacyBehavior passHref>
|
||||
<NavigationMenuLink className={cn(
|
||||
<NavigationMenuLink
|
||||
className={cn(
|
||||
navigationMenuTriggerStyle(),
|
||||
"h-9",
|
||||
isActive(item.href) && "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
'h-9',
|
||||
flatActive && cn(activeRule, 'text-foreground'),
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.title}
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
|
||||
{/* Right Side Actions */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<StatusIndicator />
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// Breadcrumb component for secondary navigation
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
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>
|
||||
);
|
||||
}
|
||||
// PageHeader moved to ./page-header.tsx; re-exported for backwards compatibility.
|
||||
export { PageHeader } from '@/components/navigation/page-header';
|
||||
export type { BreadcrumbItem, PageHeaderProps } from '@/components/navigation/page-header';
|
||||
|
|
|
|||
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>
|
||||
</PopoverTrigger>
|
||||
<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"
|
||||
collisionPadding={8}
|
||||
>
|
||||
{options.length >= searchThreshold && (
|
||||
<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="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: #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 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>
|
||||
|
|
@ -126,7 +126,7 @@ export async function sendInvitationEmail({
|
|||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<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: #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 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>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export type HealthStatus =
|
|||
| 'auth_failed' // configured, server returned 401/403
|
||||
| 'unreachable' // configured, network/DNS/TLS error
|
||||
| '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 {
|
||||
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[]> {
|
||||
if (!opts?.skipCache && cache && cache.expiresAt > Date.now()) {
|
||||
return cache.data;
|
||||
|
|
@ -278,8 +327,9 @@ export async function checkIntegrationHealth(opts?: { skipCache?: boolean }): Pr
|
|||
Promise.resolve(checkConfigOnly('anthropic', 'Anthropic', 'llm',
|
||||
['ANTHROPIC_API_KEY'])),
|
||||
]);
|
||||
cache = { expiresAt: Date.now() + CACHE_TTL_MS, data: results };
|
||||
return results;
|
||||
const overlaid = applyDisableOverlay(results);
|
||||
cache = { expiresAt: Date.now() + CACHE_TTL_MS, data: overlaid };
|
||||
return overlaid;
|
||||
}
|
||||
|
||||
export function clearIntegrationHealthCache(): void {
|
||||
|
|
@ -291,14 +341,20 @@ export interface HealthSummary {
|
|||
ok: number;
|
||||
failed: number;
|
||||
notConfigured: number;
|
||||
disabled: number;
|
||||
expiringWithin14Days: number;
|
||||
expired: number;
|
||||
hasIssues: boolean;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (i.status === 'disabled') {
|
||||
disabled += 1;
|
||||
continue;
|
||||
}
|
||||
if (i.status === 'ok' || i.status === 'unknown') ok += 1;
|
||||
else if (i.status === 'auth_failed' || i.status === 'unreachable') failed += 1;
|
||||
else if (i.status === 'not_configured') notConfigured += 1;
|
||||
|
|
@ -309,7 +365,7 @@ export function summarize(items: IntegrationHealth[]): HealthSummary {
|
|||
}
|
||||
return {
|
||||
total: items.length,
|
||||
ok, failed, notConfigured,
|
||||
ok, failed, notConfigured, disabled,
|
||||
expiringWithin14Days, expired,
|
||||
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",
|
||||
"nodemailer": "^7.0.12",
|
||||
"pg": "^8.11.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
|
|
@ -68,6 +69,7 @@
|
|||
"baseline-browser-mapping": "2.10.8",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"shadcn": "^4.6.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"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