feat: status popover + CSV export
Two follow-ons after the ⌘K palette:
StatusIndicator → Popover
- The top-bar status light is no longer a direct link to /status.
Clicking it opens a popover with grouped issues (failing
integrations, expired tokens, expiring tokens) so a quick glance
answers "what's broken" without leaving the current page. A "View
full status" link at the bottom routes to /status when needed.
- The trigger keeps the same color rollup so the visual hint is
visible without opening the popover.
DataTable → CSV export
- Optional `exportable` + `exportFilename` props add an "Export CSV"
button next to the search bar. Default behavior exports the current
page; pass `onExportAll` for server-side full-result downloads.
- Built client-side from column defs (label → header, raw value →
cell). BOM-prefixed UTF-8 so Excel decodes correctly. Quoting +
escape handled.
- Enabled on /admin/data-browser/{companies,tickets} as initial demos.
Other data-browser pages opt in by adding two props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0894fe946
commit
c97e5fc45c
5 changed files with 317 additions and 33 deletions
14
DESIGN.md
14
DESIGN.md
|
|
@ -349,6 +349,20 @@ below is the working backlog; expand as we go.
|
||||||
expandable-row patterns (useful for `/veeam-analysis`-style
|
expandable-row patterns (useful for `/veeam-analysis`-style
|
||||||
drill-downs).
|
drill-downs).
|
||||||
|
|
||||||
|
### Status indicator popover (2026-05-03)
|
||||||
|
- [x] Top-bar StatusIndicator now opens a Popover instead of routing
|
||||||
|
directly to `/status`. The popover groups inline issues by tone
|
||||||
|
(failing integrations, expired tokens, expiring tokens) so a quick
|
||||||
|
glance answers "what's broken." A "View full status" link at the
|
||||||
|
bottom routes to `/status`.
|
||||||
|
|
||||||
|
### CSV export on DataTable (2026-05-03)
|
||||||
|
- [x] Optional `exportable` + `exportFilename` props add an "Export CSV"
|
||||||
|
button next to the search bar. Defaults to current-page export;
|
||||||
|
provide `onExportAll` for server-side full-result downloads.
|
||||||
|
Enabled on `/admin/data-browser/companies` and
|
||||||
|
`/admin/data-browser/tickets`.
|
||||||
|
|
||||||
### Command palette (2026-05-03)
|
### Command palette (2026-05-03)
|
||||||
- [x] **Cmd+K / Ctrl+K** opens a global launcher
|
- [x] **Cmd+K / Ctrl+K** opens a global launcher
|
||||||
(`components/navigation/command-palette.tsx`). Three sections:
|
(`components/navigation/command-palette.tsx`). Three sections:
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,8 @@ export default function CompaniesBrowserPage() {
|
||||||
onSearch={(query) => fetchCompanies(1, query)}
|
onSearch={(query) => fetchCompanies(1, query)}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
exportable
|
||||||
|
exportFilename="companies"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,8 @@ export default function TicketsBrowserPage() {
|
||||||
onSearch={(query) => fetchTickets(1, query)}
|
onSearch={(query) => fetchTickets(1, query)}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
exportable
|
||||||
|
exportFilename="tickets"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
|
Download,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
@ -94,6 +95,14 @@ export interface DataTableProps<TData = any> {
|
||||||
emptyDescription?: string;
|
emptyDescription?: string;
|
||||||
/** Pin the first column when the table scrolls horizontally. Default true. */
|
/** Pin the first column when the table scrolls horizontally. Default true. */
|
||||||
stickyFirstColumn?: boolean;
|
stickyFirstColumn?: boolean;
|
||||||
|
/** When true, render an "Export CSV" button. Exports the current page only
|
||||||
|
* unless `onExportAll` is provided (which receives all records server-side). */
|
||||||
|
exportable?: boolean;
|
||||||
|
/** Filename prefix for the CSV download (e.g. "tickets" → "tickets-2026-05-03.csv"). */
|
||||||
|
exportFilename?: string;
|
||||||
|
/** Optional async callback for full-result export — called instead of building
|
||||||
|
* the CSV from `data`. Should resolve with the full row set. */
|
||||||
|
onExportAll?: () => Promise<TData[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataTable<TData = any>({
|
export default function DataTable<TData = any>({
|
||||||
|
|
@ -112,6 +121,9 @@ export default function DataTable<TData = any>({
|
||||||
emptyTitle = 'No data found',
|
emptyTitle = 'No data found',
|
||||||
emptyDescription = 'Try adjusting your search or filters.',
|
emptyDescription = 'Try adjusting your search or filters.',
|
||||||
stickyFirstColumn = true,
|
stickyFirstColumn = true,
|
||||||
|
exportable = false,
|
||||||
|
exportFilename = 'export',
|
||||||
|
onExportAll,
|
||||||
}: DataTableProps<TData>) {
|
}: DataTableProps<TData>) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
@ -191,10 +203,33 @@ export default function DataTable<TData = any>({
|
||||||
onSearch?.(searchQuery);
|
onSearch?.(searchQuery);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
async function handleExport() {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const rows = onExportAll ? await onExportAll() : data;
|
||||||
|
const csv = toCsv(columns, rows);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${exportFilename}-${today}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{onSearch && (
|
{(onSearch || exportable) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{onSearch && (
|
||||||
|
<>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -209,6 +244,18 @@ export default function DataTable<TData = any>({
|
||||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||||
<span className="ml-2">Search</span>
|
<span className="ml-2">Search</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{exportable && (
|
||||||
|
<Button variant="outline" onClick={handleExport} disabled={exporting || data.length === 0} className="ml-auto">
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="ml-2">Export CSV</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -383,3 +430,26 @@ function renderLoadingRows<TData>(table: ReturnType<typeof useReactTable<TData>>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a CSV string from columns + rows. Cell values come from
|
||||||
|
* `row[col.key]` (raw, not column.render — render returns ReactNode
|
||||||
|
* which doesn't serialize). Quotes are doubled, fields containing
|
||||||
|
* comma / quote / newline are wrapped in quotes; everything else is
|
||||||
|
* left bare. Booleans render true/false, null/undefined render empty.
|
||||||
|
*/
|
||||||
|
function toCsv<T = any>(columns: Column<T>[], rows: T[]): string {
|
||||||
|
const escape = (v: unknown): string => {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||||
|
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
};
|
||||||
|
const header = columns.map((c) => escape(c.label)).join(',');
|
||||||
|
const body = rows
|
||||||
|
.map((row) =>
|
||||||
|
columns.map((c) => escape((row as Record<string, unknown>)[c.key])).join(','),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
// BOM so Excel reads UTF-8 correctly.
|
||||||
|
return '' + header + '\n' + body + '\n';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,56 @@
|
||||||
/* StatusIndicator — top-bar entry point to /status.
|
/* StatusIndicator — top-bar status pill with inline issue summary.
|
||||||
*
|
*
|
||||||
* Polls /api/dashboard/integration-health every 60 s, rolls up overall
|
* Click to open a Popover that lists what's wrong (failed integrations,
|
||||||
* state into a single StatusLight, and links to /status. Title attribute
|
* expiring/expired tokens, recent sync failures) with deep-links to the
|
||||||
* gives a quick textual hint; click goes to the full page. */
|
* relevant tools. When everything is healthy the popover just confirms
|
||||||
|
* "All systems operational". A "View full status" link at the bottom
|
||||||
|
* routes to /status.
|
||||||
|
*
|
||||||
|
* Polls /api/dashboard/integration-health every 60 s. Rolls up the
|
||||||
|
* summary into a single StatusLight color in the trigger so the visual
|
||||||
|
* hint is visible without opening the popover. */
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight, KeyRound, Power, ShieldCheck, XCircle } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
|
import { StatusLight, type StatusLightState } from '@/components/ui/status-light';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
interface IntegrationHealthItem {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
status: 'ok' | 'auth_failed' | 'unreachable' | 'not_configured' | 'unknown' | 'disabled';
|
||||||
|
error?: string | null;
|
||||||
|
tokenExpiry?: { envVar: string; daysRemaining: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface HealthSummary {
|
interface HealthSummary {
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
expired: number;
|
notConfigured: number;
|
||||||
|
disabled: number;
|
||||||
expiringWithin14Days: number;
|
expiringWithin14Days: number;
|
||||||
|
expired: number;
|
||||||
hasIssues: boolean;
|
hasIssues: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
items: IntegrationHealthItem[];
|
||||||
|
summary: HealthSummary;
|
||||||
|
}
|
||||||
|
|
||||||
const POLL_MS = 60_000;
|
const POLL_MS = 60_000;
|
||||||
|
|
||||||
export function StatusIndicator() {
|
export function StatusIndicator() {
|
||||||
const [summary, setSummary] = useState<HealthSummary | null>(null);
|
const [data, setData] = useState<HealthResponse | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -28,10 +58,10 @@ export function StatusIndicator() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/dashboard/integration-health', { cache: 'no-store' });
|
const res = await fetch('/api/dashboard/integration-health', { cache: 'no-store' });
|
||||||
if (!res.ok || cancelled) return;
|
if (!res.ok || cancelled) return;
|
||||||
const j = (await res.json()) as { summary: HealthSummary };
|
const j = (await res.json()) as HealthResponse;
|
||||||
if (!cancelled) setSummary(j.summary);
|
if (!cancelled) setData(j);
|
||||||
} catch {
|
} catch {
|
||||||
/* leave summary null — light renders idle */
|
/* leave null — trigger renders idle */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
|
|
@ -42,6 +72,7 @@ export function StatusIndicator() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const summary = data?.summary;
|
||||||
const state: StatusLightState = !summary
|
const state: StatusLightState = !summary
|
||||||
? 'idle'
|
? 'idle'
|
||||||
: summary.failed > 0 || summary.expired > 0
|
: summary.failed > 0 || summary.expired > 0
|
||||||
|
|
@ -50,7 +81,7 @@ export function StatusIndicator() {
|
||||||
? 'warn'
|
? 'warn'
|
||||||
: 'ok';
|
: 'ok';
|
||||||
|
|
||||||
const title = !summary
|
const triggerTitle = !summary
|
||||||
? 'System status'
|
? 'System status'
|
||||||
: state === 'error'
|
: state === 'error'
|
||||||
? `${summary.failed + summary.expired} integration issue(s)`
|
? `${summary.failed + summary.expired} integration issue(s)`
|
||||||
|
|
@ -59,13 +90,178 @@ export function StatusIndicator() {
|
||||||
: 'All systems operational';
|
: 'All systems operational';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Popover>
|
||||||
href="/status"
|
<PopoverTrigger
|
||||||
title={title}
|
title={triggerTitle}
|
||||||
aria-label={title}
|
aria-label={triggerTitle}
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md hover:bg-accent/40 transition-colors"
|
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} />
|
<StatusLight state={state} size="md" label={triggerTitle} />
|
||||||
</Link>
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-80 p-0">
|
||||||
|
<StatusSummary data={data} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusSummary({ data }: { data: HealthResponse | null }) {
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">Loading system status…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failing = data.items.filter(
|
||||||
|
(i) => i.status === 'auth_failed' || i.status === 'unreachable',
|
||||||
|
);
|
||||||
|
const expired = data.items.filter(
|
||||||
|
(i) => i.tokenExpiry && i.tokenExpiry.daysRemaining <= 0,
|
||||||
|
);
|
||||||
|
const expiring = data.items.filter(
|
||||||
|
(i) =>
|
||||||
|
i.tokenExpiry &&
|
||||||
|
i.tokenExpiry.daysRemaining > 0 &&
|
||||||
|
i.tokenExpiry.daysRemaining <= 14,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allOk = failing.length === 0 && expired.length === 0 && expiring.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="px-4 pt-3 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{allOk ? (
|
||||||
|
<>
|
||||||
|
<ShieldCheck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
<p className="text-sm font-medium">All systems operational</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Power className="h-4 w-4 text-destructive" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{failing.length + expired.length + expiring.length} item(s) need attention
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
<span className="num">{data.summary.ok}</span> healthy ·{' '}
|
||||||
|
<span className="num">{data.summary.disabled}</span> disabled ·{' '}
|
||||||
|
<span className="num">{data.summary.notConfigured}</span> unconfigured
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failing.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<IssueGroup
|
||||||
|
title="Failing"
|
||||||
|
icon={<XCircle className="h-3.5 w-3.5 text-destructive" />}
|
||||||
|
>
|
||||||
|
{failing.map((i) => (
|
||||||
|
<IssueRow
|
||||||
|
key={i.key}
|
||||||
|
primary={i.name}
|
||||||
|
secondary={
|
||||||
|
i.status === 'auth_failed' ? 'authentication failed' : 'unreachable'
|
||||||
|
}
|
||||||
|
tone="error"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</IssueGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<IssueGroup
|
||||||
|
title="Expired tokens"
|
||||||
|
icon={<KeyRound className="h-3.5 w-3.5 text-destructive" />}
|
||||||
|
>
|
||||||
|
{expired.map((i) => (
|
||||||
|
<IssueRow
|
||||||
|
key={i.key}
|
||||||
|
primary={i.name}
|
||||||
|
secondary={`expired ${Math.abs(i.tokenExpiry!.daysRemaining).toFixed(0)} d ago`}
|
||||||
|
tone="error"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</IssueGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expiring.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<IssueGroup
|
||||||
|
title="Expiring soon"
|
||||||
|
icon={<KeyRound className="h-3.5 w-3.5 text-amber-500" />}
|
||||||
|
>
|
||||||
|
{expiring.map((i) => (
|
||||||
|
<IssueRow
|
||||||
|
key={i.key}
|
||||||
|
primary={i.name}
|
||||||
|
secondary={`expires in ${i.tokenExpiry!.daysRemaining.toFixed(0)} d`}
|
||||||
|
tone="warn"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</IssueGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<Link
|
||||||
|
href="/status"
|
||||||
|
className="flex items-center justify-between px-4 py-2.5 text-sm hover:bg-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
<span>View full status</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueGroup({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-2 space-y-1">
|
||||||
|
<p className="metric-label flex items-center gap-1.5">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5 mt-1">{children}</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueRow({
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
tone: 'error' | 'warn';
|
||||||
|
}) {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'error'
|
||||||
|
? 'text-destructive'
|
||||||
|
: 'text-amber-700 dark:text-amber-400';
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium truncate">{primary}</span>
|
||||||
|
<span className={`text-xs num ${toneClass}`}>{secondary}</span>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue