feat: Warm Precision UI redesign — new design system, task-based navigation, interaction system
30 commits delivering: - Warm Precision token system (warm olive/stone palette, no blue) - Task-based sidebar (Operate/Act replacing Fleet/Config/Admin) - ContextStrip removed, features moved to sidebar - Device workspace header with breadcrumb and metadata - 50ms interaction system (hover/focus/active/press states) - Skeleton loaders replaced with honest loading states - Needs Attention dashboard panel with device links - Joined metrics strip - UI scale selector (100%/110%/125%) - All hardcoded colors tokenized - 143 files changed, net -34 lines Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { useAuth } from './lib/auth'
|
||||
import { Skeleton } from './components/ui/skeleton'
|
||||
import { LoadingText } from './components/ui/skeleton'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
@@ -24,17 +24,13 @@ function AppInner() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Only show skeleton during initial auth check -- NOT on subsequent isLoading changes.
|
||||
// Only show loading text during initial auth check -- NOT on subsequent isLoading changes.
|
||||
// Reacting to isLoading here would unmount the entire router tree (including LoginPage)
|
||||
// every time an auth action sets isLoading, destroying all component local state.
|
||||
if (!hasChecked) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="space-y-4 w-64">
|
||||
<Skeleton className="h-8 w-48 mx-auto" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AnsiNfoModal({ open, onOpenChange }: AnsiNfoModalProps) {
|
||||
}}
|
||||
>
|
||||
{/* Retro title bar */}
|
||||
<div className="flex items-center justify-between px-3 pr-10 py-1.5 bg-surface border-b border-border font-mono text-xs">
|
||||
<div className="flex items-center justify-between px-3 pr-10 py-1.5 bg-panel border-b border-border font-mono text-xs">
|
||||
<DialogTitle id="ansi-nfo-title" className="text-text-muted text-xs font-normal font-mono">
|
||||
TOD.NFO — ACiD View v1.0
|
||||
</DialogTitle>
|
||||
|
||||
@@ -698,7 +698,7 @@ export function AlertRulesPage() {
|
||||
No alert rules configured.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-xs text-text-muted font-medium">
|
||||
<span className="flex-1">Name</span>
|
||||
@@ -710,7 +710,7 @@ export function AlertRulesPage() {
|
||||
{rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-surface text-sm"
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-panel text-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-text-primary truncate block">
|
||||
@@ -809,7 +809,7 @@ export function AlertRulesPage() {
|
||||
{channels.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="rounded-lg border border-border bg-surface p-4 space-y-2"
|
||||
className="rounded-lg border border-border bg-panel p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -89,7 +89,7 @@ function AlertRow({
|
||||
alert.silenced_until && new Date(alert.silenced_until) > new Date()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 hover:bg-surface transition-colors">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 hover:bg-panel transition-colors">
|
||||
<StatusIcon status={alert.status} />
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
|
||||
@@ -271,7 +271,7 @@ export function AlertsPage() {
|
||||
description="All clear! No alerts have been triggered."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
{alerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert.id}
|
||||
@@ -302,7 +302,7 @@ export function AlertsPage() {
|
||||
description="Alert events will appear here as they are triggered and resolved."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] uppercase tracking-wider text-text-muted font-semibold">
|
||||
<span className="w-5" />
|
||||
@@ -315,7 +315,7 @@ export function AlertsPage() {
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-surface text-sm"
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-panel text-sm"
|
||||
>
|
||||
<StatusIcon status={alert.status} />
|
||||
<span className="w-16">
|
||||
|
||||
@@ -80,7 +80,7 @@ function actionBadgeClasses(action: string): string {
|
||||
if (action.startsWith('device_')) return 'bg-info/10 text-info border-info/20'
|
||||
if (action.startsWith('alert_')) return 'bg-warning/10 text-warning border-warning/20'
|
||||
if (action === 'login' || action === 'logout') return 'bg-success/10 text-success border-success/20'
|
||||
if (action.startsWith('firmware')) return 'bg-purple-500/10 text-purple-400 border-purple-500/20'
|
||||
if (action.startsWith('firmware')) return 'bg-warning/10 text-warning border-warning/20'
|
||||
if (action.startsWith('bulk_')) return 'bg-error/10 text-error border-error/20'
|
||||
return 'bg-elevated text-text-secondary border-border'
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
|
||||
aria-label="Filter by action"
|
||||
className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
className="h-8 rounded-md border border-border bg-panel px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
{ACTION_TYPES.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
@@ -160,7 +160,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||||
aria-label="Filter from date"
|
||||
className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
className="h-8 rounded-md border border-border bg-panel px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||||
aria-label="Filter to date"
|
||||
className="h-8 rounded-md border border-border bg-surface px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
className="h-8 rounded-md border border-border bg-panel px-2 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -185,7 +185,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
|
||||
value={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
aria-label="Filter by user"
|
||||
className="h-8 rounded-md border border-border bg-surface pl-7 pr-2 text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent w-40"
|
||||
className="h-8 rounded-md border border-border bg-panel pl-7 pr-2 text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || !data?.total}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-elevated hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-panel px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-elevated hover:text-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{exporting ? 'Exporting...' : 'Export CSV'}
|
||||
@@ -204,7 +204,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||
@@ -278,7 +278,7 @@ export function AuditLogTable({ tenantId }: AuditLogTableProps) {
|
||||
setPage(1)
|
||||
}}
|
||||
aria-label="Rows per page"
|
||||
className="h-7 rounded border border-border bg-surface px-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
className="h-7 rounded border border-border bg-panel px-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function EmergencyKitDialog({
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-surface px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-panel px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-panel-hover"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
@@ -245,7 +245,7 @@ export function EmergencyKitDialog({
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-3 rounded-md bg-surface-secondary p-3 text-xs text-text-secondary leading-relaxed">
|
||||
<div className="mt-3 rounded-md bg-panel-secondary p-3 text-xs text-text-secondary leading-relaxed">
|
||||
The PDF includes your Secret Key. Print it or save it securely.
|
||||
You can also store the key in your password manager. Do NOT store it alongside your password.
|
||||
</div>
|
||||
|
||||
@@ -134,7 +134,7 @@ export function SrpUpgradeDialog({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md bg-surface-secondary p-4 text-sm text-text-secondary leading-relaxed space-y-3">
|
||||
<div className="rounded-md bg-panel-secondary p-4 text-sm text-text-secondary leading-relaxed space-y-3">
|
||||
<p>
|
||||
<strong>What happens:</strong>
|
||||
</p>
|
||||
|
||||
@@ -275,7 +275,7 @@ export function BulkDeployDialog({
|
||||
'rounded-lg border p-4 text-center',
|
||||
result.failed > 0
|
||||
? 'border-error/30 bg-error/5'
|
||||
: 'border-border bg-surface',
|
||||
: 'border-border bg-panel',
|
||||
)}
|
||||
>
|
||||
<XCircle
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardP
|
||||
if (!ca) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-6">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center space-y-6">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl bg-accent/10 flex items-center justify-center">
|
||||
<Shield className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
@@ -118,7 +118,7 @@ export function CAStatusCard({ ca, canWrite: writable, tenantId }: CAStatusCardP
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-surface p-6 space-y-4',
|
||||
'rounded-lg border bg-panel p-6 space-y-4',
|
||||
isExpired ? 'border-error/40' : 'border-success/30',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function CertificatesPage() {
|
||||
Certificate Authority
|
||||
</h1>
|
||||
</div>
|
||||
<TableSkeleton rows={3} />
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ export function DeviceCertTable({
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <TableSkeleton rows={4} />
|
||||
return <TableSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -204,7 +204,7 @@ export function CommandPalette() {
|
||||
const visiblePages = pageCommands.filter((p) => p.visible)
|
||||
|
||||
const itemClass =
|
||||
'flex items-center gap-3 px-2 py-2 rounded-lg text-sm text-text-secondary cursor-pointer data-[selected=true]:bg-accent-muted data-[selected=true]:text-accent'
|
||||
'flex items-center gap-3 px-2 py-2 rounded-lg text-sm text-text-secondary cursor-pointer data-[selected=true]:bg-accent-soft data-[selected=true]:text-accent'
|
||||
const groupHeadingClass =
|
||||
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-text-muted [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider'
|
||||
|
||||
@@ -216,7 +216,7 @@ export function CommandPalette() {
|
||||
overlayClassName="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
contentClassName="fixed left-1/2 top-[20%] -translate-x-1/2 z-50 w-full max-w-lg"
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 border-b border-border">
|
||||
<Search className="h-4 w-4 text-text-muted flex-shrink-0" />
|
||||
<Command.Input
|
||||
|
||||
@@ -136,7 +136,7 @@ export function CommandExecutor({ tenantId, deviceId, currentPath }: CommandExec
|
||||
{results.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||
{results.map((r, i) => (
|
||||
<div key={i} className="bg-surface border-border rounded-md font-mono text-xs p-2 border">
|
||||
<div key={i} className="bg-panel border-border rounded-md font-mono text-xs p-2 border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] text-text-muted">{r.timestamp}</span>
|
||||
<span className="text-xs font-mono text-text-secondary">{r.command}</span>
|
||||
|
||||
@@ -325,7 +325,7 @@ export function ConfigEditorPage() {
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteConfirmOpen} onOpenChange={(o) => !o && setDeleteConfirmOpen(false)}>
|
||||
<DialogContent className="max-w-sm bg-surface border-border text-text-primary">
|
||||
<DialogContent className="max-w-sm bg-panel border-border text-text-primary">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Confirm Delete</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function EntryForm({ open, onClose, mode, entry, columns, onSubmit }: Ent
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto bg-surface border-border text-text-primary">
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto bg-panel border-border text-text-primary">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{mode === 'add' ? 'Add New Entry' : 'Edit Entry'}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function EntryTable({
|
||||
className={cn(
|
||||
'rounded-lg border p-4 text-sm',
|
||||
isContainerPath
|
||||
? 'border-border bg-surface text-text-secondary'
|
||||
? 'border-border bg-panel text-text-secondary'
|
||||
: 'border-error/30 bg-error/10 text-error',
|
||||
)}
|
||||
>
|
||||
@@ -64,14 +64,8 @@ export function EntryTable({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="h-5 w-48 bg-elevated/50 rounded animate-pulse" />
|
||||
<div className="h-8 w-24 bg-elevated/50 rounded animate-pulse" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-surface rounded animate-pulse" />
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -128,7 +122,7 @@ export function EntryTable({
|
||||
<tr
|
||||
key={entry['.id'] || i}
|
||||
className={cn(
|
||||
'border-b border-border/50 hover:bg-surface transition-colors',
|
||||
'border-b border-border/50 hover:bg-panel transition-colors',
|
||||
entry['dynamic'] === 'true' && 'text-text-muted',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -265,7 +265,7 @@ function TreeItem({
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 w-full px-2 py-1 text-xs rounded transition-colors',
|
||||
isActive
|
||||
? 'bg-[hsl(var(--accent-muted))] text-accent'
|
||||
? 'bg-[hsl(var(--accent-soft))] text-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated/50',
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function AddressListPanel({ tenantId, deviceId, active }: ConfigPanelProp
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<List className="h-4 w-4" />
|
||||
|
||||
@@ -282,7 +282,7 @@ function AddressTable({
|
||||
return (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Network className="h-4 w-4" />
|
||||
|
||||
@@ -193,8 +193,8 @@ export function ArpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
filterTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
|
||||
? 'bg-panel text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -290,7 +290,7 @@ function ArpTable({
|
||||
return (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Network className="h-4 w-4" />
|
||||
|
||||
@@ -82,7 +82,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1 col-span-2 sm:col-span-1">
|
||||
<Label className="text-xs text-text-secondary">Target Address</Label>
|
||||
@@ -99,7 +99,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
<select
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="both">Both</option>
|
||||
<option value="send">Send</option>
|
||||
@@ -111,7 +111,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
<select
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
@@ -170,7 +170,7 @@ export function BandwidthTestTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
|
||||
<Gauge className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-text-secondary">Bandwidth Test Results</span>
|
||||
|
||||
@@ -180,11 +180,11 @@ function DeviceSelector({
|
||||
onSelectionChange(new Set())
|
||||
}
|
||||
|
||||
if (isLoading) return <TableSkeleton rows={5} />
|
||||
if (isLoading) return <TableSkeleton />
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center">
|
||||
<p className="text-text-muted text-sm">No devices found for this tenant.</p>
|
||||
</div>
|
||||
)
|
||||
@@ -208,7 +208,7 @@ function DeviceSelector({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/50">
|
||||
@@ -327,7 +327,7 @@ function ChangeDefiner({
|
||||
</div>
|
||||
|
||||
{operationType && (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
{operationType === 'add-firewall-rule' && (
|
||||
<>
|
||||
<h4 className="text-sm font-medium text-text-secondary">Firewall Rule</h4>
|
||||
@@ -605,7 +605,7 @@ function ExecutionPanel({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Change description */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-1">Change to Apply</h4>
|
||||
<p className="text-sm text-text-primary">{change.description}</p>
|
||||
<p className="text-xs text-text-muted mt-1 font-mono">
|
||||
@@ -626,7 +626,7 @@ function ExecutionPanel({
|
||||
|
||||
{/* Summary */}
|
||||
{isComplete && (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 flex items-center gap-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">{successCount} succeeded</span>
|
||||
@@ -641,7 +641,7 @@ function ExecutionPanel({
|
||||
)}
|
||||
|
||||
{/* Device status table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/50">
|
||||
|
||||
@@ -138,11 +138,11 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
</div>
|
||||
|
||||
{ports.entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
|
||||
No bridge ports configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
@@ -212,7 +212,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
<select
|
||||
value={formData['interface'] || ''}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, interface: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{ifaceNames.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||
@@ -223,7 +223,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
<select
|
||||
value={formData['bridge'] || ''}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, bridge: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
|
||||
>
|
||||
{bridgeNames.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
@@ -243,7 +243,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
<select
|
||||
value={formData['frame-types'] || 'admit-all'}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, 'frame-types': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
{FRAME_TYPES.map((ft) => <option key={ft} value={ft}>{ft}</option>)}
|
||||
</select>
|
||||
@@ -253,7 +253,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
<select
|
||||
value={formData['ingress-filtering'] || 'no'}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, 'ingress-filtering': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
@@ -264,7 +264,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
<select
|
||||
value={formData['hw'] || 'yes'}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, hw: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
@@ -297,7 +297,7 @@ export function BridgePortPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
<select
|
||||
value={formData['edge'] || 'auto'}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, edge: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="yes">Yes</option>
|
||||
|
||||
@@ -140,7 +140,7 @@ export function BridgeVlanPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
|
||||
{/* VLAN Filtering status per bridge */}
|
||||
{bridges.entries.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Network className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-text-secondary">Bridge VLAN Filtering</span>
|
||||
@@ -170,11 +170,11 @@ export function BridgeVlanPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
|
||||
{/* VLAN Table */}
|
||||
{vlans.entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
|
||||
No bridge VLAN entries configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
@@ -247,7 +247,7 @@ export function BridgeVlanPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
<select
|
||||
value={formData['bridge'] || ''}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, bridge: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
|
||||
>
|
||||
{bridgeNames.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
|
||||
@@ -81,9 +81,9 @@ export function ConfigDiffViewer({
|
||||
const isLoading = loadingOld || loadingNew
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-surface">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
{isEncrypted && (
|
||||
<span className="inline-flex items-center gap-1 text-info" title="Decrypted from encrypted backup">
|
||||
@@ -126,8 +126,8 @@ export function ConfigDiffViewer({
|
||||
|
||||
{/* Diff content */}
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-text-muted animate-pulse">
|
||||
Loading diff...
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
) : !diffFile ? (
|
||||
<div className="p-8 text-center text-sm text-text-muted">
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ConfigHistorySection({ tenantId, deviceId, deviceName }: ConfigH
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<History className="h-4 w-4 text-text-muted" />
|
||||
<h3 className="text-sm font-medium text-text-muted">Configuration History</h3>
|
||||
@@ -83,7 +83,7 @@ export function ConfigHistorySection({ tenantId, deviceId, deviceName }: ConfigH
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={3} />
|
||||
<TableSkeleton />
|
||||
) : !changes || changes.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<span className="text-xs text-text-muted">No configuration changes recorded yet.</span>
|
||||
|
||||
@@ -158,16 +158,11 @@ export function ConfigTab({
|
||||
{/* Timeline panel */}
|
||||
<div className="w-72 flex-shrink-0">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 rounded-lg border border-border bg-surface animate-pulse"
|
||||
/>
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
) : !backups || backups.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
|
||||
No backups yet. Click ‘Backup Now’ to create the first backup.
|
||||
</div>
|
||||
) : (
|
||||
@@ -201,7 +196,7 @@ export function ConfigTab({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted h-full flex items-center justify-center min-h-32">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted h-full flex items-center justify-center min-h-32">
|
||||
{selectedShas.length < 2
|
||||
? 'Select two backups from the timeline to compare'
|
||||
: 'Click "Compare selected" to view the diff'}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
|
||||
</div>
|
||||
|
||||
{/* Active connections count */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-accent" />
|
||||
<div>
|
||||
@@ -120,7 +120,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
|
||||
</div>
|
||||
|
||||
{/* Tracking settings */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">Connection Tracking Settings</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}>
|
||||
@@ -154,7 +154,7 @@ export function ConnTrackPanel({ tenantId, deviceId, active }: ConfigPanelProps)
|
||||
<select
|
||||
value={formData['enabled'] || 'auto'}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, enabled: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="yes">Yes</option>
|
||||
|
||||
@@ -206,7 +206,7 @@ export function DhcpClientPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
</div>
|
||||
|
||||
{/* DHCP Client table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Globe className="h-4 w-4" />
|
||||
|
||||
@@ -240,8 +240,8 @@ export function DhcpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
|
||||
? 'bg-panel text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -400,7 +400,7 @@ function ServersTab({
|
||||
}, [form, editing, panel])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">DHCP Servers</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
|
||||
@@ -651,7 +651,7 @@ function PoolsTab({
|
||||
}, [form, editing, panel])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">Address Pools</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
|
||||
@@ -860,7 +860,7 @@ function LeasesTab({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">DHCP Leases</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAddStatic} className="gap-1.5">
|
||||
@@ -1112,7 +1112,7 @@ function NetworksTab({
|
||||
}, [form, editing, panel])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-text-primary">DHCP Networks</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd} className="gap-1.5">
|
||||
|
||||
@@ -10,10 +10,10 @@ interface DiffViewerProps {
|
||||
}
|
||||
|
||||
function classifyLine(line: string): string {
|
||||
if (line.startsWith('@@')) return 'bg-blue-900/20 text-blue-300'
|
||||
if (line.startsWith('@@')) return 'bg-info/10 text-info'
|
||||
if (line.startsWith('+++') || line.startsWith('---')) return 'text-text-muted'
|
||||
if (line.startsWith('+')) return 'bg-green-900/30 text-green-300'
|
||||
if (line.startsWith('-')) return 'bg-red-900/30 text-red-300'
|
||||
if (line.startsWith('+')) return 'bg-success/10 text-success'
|
||||
if (line.startsWith('-')) return 'bg-error/10 text-error'
|
||||
return 'text-text-primary'
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function DiffViewer({ tenantId, deviceId, snapshotId, onClose }: DiffView
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -47,10 +47,8 @@ export function DiffViewer({ tenantId, deviceId, snapshotId, onClose }: DiffView
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[75, 90, 65, 85, 70, 80].map((w, i) => (
|
||||
<div key={i} className="h-4 bg-elevated rounded animate-pulse" style={{ width: `${w}%` }} />
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
) : isError || !diff ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
|
||||
@@ -302,7 +302,7 @@ export function DnsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Section 1: Resolver Settings */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-accent" />
|
||||
@@ -393,7 +393,7 @@ export function DnsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Section 2: Static DNS Entries */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-accent" />
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -643,7 +643,7 @@ function FilterRulesTable({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -836,7 +836,7 @@ function NatRulesTable({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -1340,16 +1340,10 @@ function CellEmpty() {
|
||||
|
||||
function LoadingRows({ cols }: { cols: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border/50">
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<td key={j} className="px-3 py-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
<tr>
|
||||
<td colSpan={cols} className="py-8 text-center">
|
||||
<LoadingText />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -71,14 +71,14 @@ const SUB_TABS: { key: SubTab; label: string; icon: React.ElementType }[] = [
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
ether: '#3B82F6',
|
||||
bridge: '#8B5CF6',
|
||||
vlan: '#F59E0B',
|
||||
bonding: '#10B981',
|
||||
pppoe: '#EF4444',
|
||||
l2tp: '#EC4899',
|
||||
ovpn: '#06B6D4',
|
||||
wlan: '#84CC16',
|
||||
ether: 'hsl(var(--accent))',
|
||||
bridge: 'hsl(var(--info))',
|
||||
vlan: 'hsl(var(--warning))',
|
||||
bonding: 'hsl(var(--success))',
|
||||
pppoe: 'hsl(var(--error))',
|
||||
l2tp: 'hsl(var(--error))',
|
||||
ovpn: 'hsl(var(--info))',
|
||||
wlan: 'hsl(var(--success))',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -228,23 +228,13 @@ export function InterfacesPanel({ tenantId, deviceId, active }: ConfigPanelProps
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading skeleton
|
||||
// Loading state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||
function TableLoading() {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface">
|
||||
<div className="p-3 border-b border-border">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-3 border-b border-border last:border-0">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -260,18 +250,18 @@ function InterfacesTable({
|
||||
entries: Record<string, string>[]
|
||||
isLoading: boolean
|
||||
}) {
|
||||
if (isLoading) return <TableSkeleton />
|
||||
if (isLoading) return <TableLoading />
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
|
||||
No interfaces found on this device.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/30">
|
||||
@@ -362,7 +352,7 @@ function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAdd
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) return <TableSkeleton />
|
||||
if (isLoading) return <TableLoading />
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -374,11 +364,11 @@ function IpAddressesTab({ entries, isLoading, interfaceNames, addChange }: IpAdd
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
|
||||
No IP addresses configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/30">
|
||||
@@ -621,7 +611,7 @@ function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabPro
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) return <TableSkeleton />
|
||||
if (isLoading) return <TableLoading />
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -633,11 +623,11 @@ function VlansTab({ entries, isLoading, interfaceNames, addChange }: VlansTabPro
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
|
||||
No VLANs configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/30">
|
||||
@@ -926,7 +916,7 @@ function BridgesTab({
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) return <TableSkeleton />
|
||||
if (isLoading) return <TableLoading />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -941,11 +931,11 @@ function BridgesTab({
|
||||
</div>
|
||||
|
||||
{bridges.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-text-secondary text-sm">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-text-secondary text-sm">
|
||||
No bridges configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/30">
|
||||
@@ -1015,11 +1005,11 @@ function BridgesTab({
|
||||
</div>
|
||||
|
||||
{bridgePorts.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-text-secondary text-sm">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-text-secondary text-sm">
|
||||
No bridge ports configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/30">
|
||||
|
||||
@@ -87,7 +87,7 @@ export function IpsecPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
{SUB_TABS.map((tab) => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
|
||||
className={cn('flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
activeTab === tab.key ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50')}>
|
||||
activeTab === tab.key ? 'bg-panel text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-panel/50')}>
|
||||
{tab.icon}{tab.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -147,11 +147,11 @@ function PeersTab({ entries, panel }: { entries: PeerEntry[]; panel: PanelHook }
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Address</Label><Input value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} placeholder="0.0.0.0/0" className="h-8 text-sm font-mono" /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Auth Method</Label>
|
||||
<select value={form['auth-method']} onChange={(e) => setForm((f) => ({ ...f, 'auth-method': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
<select value={form['auth-method']} onChange={(e) => setForm((f) => ({ ...f, 'auth-method': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
|
||||
<option value="pre-shared-key">Pre-Shared Key</option><option value="rsa-key">RSA Key</option><option value="rsa-signature">RSA Signature</option>
|
||||
</select></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Exchange Mode</Label>
|
||||
<select value={form['exchange-mode']} onChange={(e) => setForm((f) => ({ ...f, 'exchange-mode': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
<select value={form['exchange-mode']} onChange={(e) => setForm((f) => ({ ...f, 'exchange-mode': e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
|
||||
<option value="main">Main</option><option value="aggressive">Aggressive</option><option value="ike2">IKEv2</option>
|
||||
</select></div>
|
||||
</div>
|
||||
@@ -208,8 +208,8 @@ function PoliciesTab({ entries, panel }: { entries: PolicyEntry[]; panel: PanelH
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Dst Address</Label><Input value={form['dst-address']} onChange={(e) => setForm((f) => ({ ...f, 'dst-address': e.target.value }))} placeholder="10.0.0.0/24" className="h-8 text-sm font-mono" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Tunnel</Label><select value={form.tunnel} onChange={(e) => setForm((f) => ({ ...f, tunnel: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"><option value="yes">Yes</option><option value="no">No</option></select></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Action</Label><select value={form.action} onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"><option value="encrypt">Encrypt</option><option value="none">None</option></select></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Tunnel</Label><select value={form.tunnel} onChange={(e) => setForm((f) => ({ ...f, tunnel: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"><option value="yes">Yes</option><option value="no">No</option></select></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Action</Label><select value={form.action} onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"><option value="encrypt">Encrypt</option><option value="none">None</option></select></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Proposal</Label><Input value={form.proposal} onChange={(e) => setForm((f) => ({ ...f, proposal: e.target.value }))} className="h-8 text-sm" /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ function ProposalsTab({ entries, panel }: { entries: ProposalEntry[]; panel: Pan
|
||||
|
||||
function SasTab({ entries }: { entries: SaEntry[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50"><span className="text-sm font-medium text-text-secondary">Installed SAs ({entries.length})</span></div>
|
||||
{entries.length === 0 ? <div className="px-4 py-8 text-center text-sm text-text-muted">No active security associations.</div> : (
|
||||
<div className="overflow-x-auto"><table className="w-full text-sm"><thead><tr className="border-b border-border/50 text-text-secondary text-xs">
|
||||
@@ -301,7 +301,7 @@ function SasTab({ entries }: { entries: SaEntry[] }) {
|
||||
|
||||
function TableWrapper({ title, count, onAdd, children }: { title: string; count: number; onAdd: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">{title} ({count})</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={onAdd}><Plus className="h-3.5 w-3.5" />Add</Button>
|
||||
|
||||
@@ -127,7 +127,7 @@ export function ManglePanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
onClick={() => setChainFilter(chain)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors capitalize',
|
||||
chainFilter === chain ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
|
||||
chainFilter === chain ? 'bg-panel text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
|
||||
)}
|
||||
>
|
||||
{chain}
|
||||
@@ -193,7 +193,7 @@ function MangleTable({ entries, panel }: { entries: MangleEntry[]; panel: PanelH
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Filter className="h-4 w-4" />
|
||||
|
||||
@@ -90,7 +90,7 @@ export function PingTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Input form */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label className="text-xs text-text-secondary">Target IP / Hostname</Label>
|
||||
@@ -166,7 +166,7 @@ export function PingTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-text-secondary">Ping Results</span>
|
||||
@@ -189,7 +189,7 @@ export function PingTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
|
||||
{/* Stats summary */}
|
||||
{stats && (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4 text-center">
|
||||
<StatBox label="Sent" value={String(stats.sent)} />
|
||||
<StatBox label="Received" value={String(stats.received)} />
|
||||
|
||||
@@ -275,7 +275,7 @@ function PoolTable({
|
||||
return (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Layers className="h-4 w-4" />
|
||||
|
||||
@@ -98,7 +98,7 @@ export function PppPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
{SUB_TABS.map((tab) => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
|
||||
className={cn('flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
activeTab === tab.key ? 'bg-surface text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-surface/50')}>
|
||||
activeTab === tab.key ? 'bg-panel text-text-primary shadow-sm' : 'text-text-secondary hover:text-text-primary hover:bg-panel/50')}>
|
||||
{tab.icon}{tab.label}
|
||||
{tab.key === 'active' && activeConns.entries.length > 0 && (
|
||||
<span className="text-xs bg-accent/20 text-accent px-1 rounded">{activeConns.entries.length}</span>
|
||||
@@ -146,7 +146,7 @@ function ProfilesTab({ entries, panel }: { entries: ProfileEntry[]; panel: Panel
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">PPP Profiles ({entries.length})</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}><Plus className="h-3.5 w-3.5" />Add Profile</Button>
|
||||
@@ -219,7 +219,7 @@ function SecretsTab({ entries, panel, profileNames }: { entries: SecretEntry[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">PPP Secrets ({entries.length})</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleAdd}><Plus className="h-3.5 w-3.5" />Add Secret</Button>
|
||||
@@ -252,7 +252,7 @@ function SecretsTab({ entries, panel, profileNames }: { entries: SecretEntry[];
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Service</Label>
|
||||
<select value={form.service} onChange={(e) => setForm((f) => ({ ...f, service: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
<select value={form.service} onChange={(e) => setForm((f) => ({ ...f, service: e.target.value }))} className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
|
||||
{PPP_SERVICES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select></div>
|
||||
<div className="space-y-1"><Label className="text-xs text-text-secondary">Profile</Label><Input value={form.profile} onChange={(e) => setForm((f) => ({ ...f, profile: e.target.value }))} placeholder="default" className="h-8 text-sm" list="profile-names" />
|
||||
@@ -281,7 +281,7 @@ function ActiveTab({ entries, tenantId, deviceId, refetch }: { entries: ActiveEn
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">Active Connections ({entries.length})</span>
|
||||
</div>
|
||||
|
||||
@@ -52,10 +52,8 @@ export function RestorePreview({
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="h-12 rounded-lg bg-elevated animate-pulse" />
|
||||
<div className="h-32 rounded-lg bg-elevated animate-pulse" />
|
||||
<div className="h-16 rounded-lg bg-elevated animate-pulse" />
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -100,7 +98,7 @@ export function RestorePreview({
|
||||
)}
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className="rounded-lg border border-border bg-surface-raised p-3 flex items-center justify-between">
|
||||
<div className="rounded-lg border border-border bg-panel-raised p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-success font-mono">+{diff.added}</span>
|
||||
<span className="text-error font-mono">-{diff.removed}</span>
|
||||
@@ -125,7 +123,7 @@ export function RestorePreview({
|
||||
{changedCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.path}
|
||||
className="w-full px-3 py-2 flex items-center justify-between hover:bg-surface-raised/50 transition-colors"
|
||||
className="w-full px-3 py-2 flex items-center justify-between hover:bg-panel-raised/50 transition-colors"
|
||||
onClick={() => togglePath(cat.path)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
|
||||
@@ -170,8 +170,8 @@ export function RoutesPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
filterTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
|
||||
? 'bg-panel text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -313,7 +313,7 @@ function RoutesTable({
|
||||
return (
|
||||
<>
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Route className="h-4 w-4" />
|
||||
|
||||
@@ -148,8 +148,8 @@ export function ScriptsPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
|
||||
? 'bg-panel text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -275,7 +275,7 @@ function ScriptsTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Code className="h-4 w-4" />
|
||||
@@ -477,7 +477,7 @@ function SchedulerTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Clock className="h-4 w-4" />
|
||||
|
||||
@@ -230,7 +230,7 @@ function ServiceTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Shield className="h-4 w-4" />
|
||||
@@ -337,7 +337,7 @@ function ServiceTable({
|
||||
<select
|
||||
value={form.disabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, disabled: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="false">Enabled</option>
|
||||
<option value="true">Disabled</option>
|
||||
|
||||
@@ -158,7 +158,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* SNMP Status + Settings */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4 text-accent" />
|
||||
@@ -192,7 +192,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Communities */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">SNMP Communities</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleAddCommunity}>
|
||||
@@ -279,7 +279,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Trap Version</Label>
|
||||
<select value={settingsForm['trap-version'] || '1'} onChange={(e) => setSettingsForm((f) => ({ ...f, 'trap-version': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
|
||||
<option value="1">v1</option>
|
||||
<option value="2">v2c</option>
|
||||
<option value="3">v3</option>
|
||||
@@ -313,7 +313,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Read Access</Label>
|
||||
<select value={communityForm['read-access'] || 'yes'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'read-access': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
@@ -321,7 +321,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Write Access</Label>
|
||||
<select value={communityForm['write-access'] || 'no'} onChange={(e) => setCommunityForm((f) => ({ ...f, 'write-access': e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
@@ -329,7 +329,7 @@ export function SnmpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Security</Label>
|
||||
<select value={communityForm['security'] || 'none'} onChange={(e) => setCommunityForm((f) => ({ ...f, security: e.target.value }))}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary">
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary">
|
||||
<option value="none">None</option>
|
||||
<option value="authorized">Authorized</option>
|
||||
<option value="private">Private</option>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Zap, Network } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useConfigBrowse } from '@/hooks/useConfigPanel'
|
||||
import type { ConfigPanelProps } from '@/lib/configPanelTypes'
|
||||
@@ -134,19 +134,15 @@ export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelPro
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-16 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="py-8 text-center">
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (etherPorts.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-text-secondary text-sm">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-text-secondary text-sm">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No ethernet ports detected on this device.
|
||||
</div>
|
||||
@@ -156,7 +152,7 @@ export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelPro
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Port grid */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<h3 className="text-sm font-medium text-text-primary mb-3">Switch Ports</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{etherPorts.map((port) => {
|
||||
@@ -177,7 +173,7 @@ export function SwitchPortManager({ tenantId, deviceId, active }: ConfigPanelPro
|
||||
</div>
|
||||
|
||||
{/* VLAN Legend */}
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<h3 className="text-sm font-medium text-text-primary mb-2">VLAN Legend</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<LegendItem color={UNASSIGNED_COLOR} label="Unassigned" />
|
||||
|
||||
@@ -104,8 +104,8 @@ export function SystemPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-surface/50',
|
||||
? 'bg-panel text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-panel/50',
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -179,7 +179,7 @@ function IdentityTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">System Identity</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleEdit}>
|
||||
@@ -283,7 +283,7 @@ function ClockTab({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Clock info */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">Clock Settings</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditClock}>
|
||||
@@ -300,7 +300,7 @@ function ClockTab({
|
||||
</div>
|
||||
|
||||
{/* NTP info */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">NTP Client</span>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleEditNtp}>
|
||||
@@ -351,7 +351,7 @@ function ClockTab({
|
||||
<select
|
||||
value={ntpEnabled}
|
||||
onChange={(e) => setNtpEnabled(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
@@ -394,7 +394,7 @@ function ResourcesTab({ data }: { data: Record<string, string> }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50">
|
||||
<span className="text-sm font-medium text-text-secondary">System Resources</span>
|
||||
</div>
|
||||
|
||||
@@ -106,14 +106,14 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-text-secondary">Interface</Label>
|
||||
<select
|
||||
value={iface}
|
||||
onChange={(e) => setIface(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary font-mono"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary font-mono"
|
||||
>
|
||||
{ifaceNames.length > 0
|
||||
? ifaceNames.map((name) => (
|
||||
@@ -190,7 +190,7 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
)}
|
||||
|
||||
{entries.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
@@ -229,7 +229,7 @@ export function TorchTool({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
)}
|
||||
|
||||
{entries.length === 0 && !torchMutation.isPending && !torchMutation.isIdle && (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
|
||||
No traffic captured. Try a different interface or remove filters.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label className="text-xs text-text-secondary">Target IP / Hostname</Label>
|
||||
@@ -108,7 +108,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
<select
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-surface px-3 text-sm text-text-primary"
|
||||
className="h-8 w-full rounded-md border border-border bg-panel px-3 text-sm text-text-primary"
|
||||
>
|
||||
<option value="icmp">ICMP</option>
|
||||
<option value="udp">UDP</option>
|
||||
@@ -137,7 +137,7 @@ export function TracerouteTool({ tenantId, deviceId }: ConfigPanelProps) {
|
||||
)}
|
||||
|
||||
{hops.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
|
||||
<Route className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
|
||||
@@ -148,7 +148,7 @@ export function UsersPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
|
||||
{/* Groups overview */}
|
||||
{groups.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-border/50 flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-text-muted" />
|
||||
<span className="text-sm font-medium text-text-secondary">User Groups</span>
|
||||
@@ -266,7 +266,7 @@ function UsersTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Users className="h-4 w-4" />
|
||||
|
||||
@@ -69,7 +69,7 @@ export function AlertSummary({
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-border">
|
||||
<Card className="bg-panel border-border">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
||||
Alert Summary
|
||||
|
||||
@@ -45,7 +45,7 @@ function BwTooltip({ active, payload }: CustomTooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) return null
|
||||
const item = payload[0]
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface px-3 py-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-panel px-3 py-2 text-xs">
|
||||
<p className="font-medium text-text-primary">{item.payload.hostname}</p>
|
||||
<p className="text-text-secondary">{formatBw(item.value)}</p>
|
||||
</div>
|
||||
@@ -56,7 +56,7 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
|
||||
const chartHeight = Math.max(200, devices.length * 36)
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-border">
|
||||
<Card className="bg-panel border-border">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
||||
Top Bandwidth Consumers
|
||||
@@ -78,7 +78,7 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={formatAxisTick}
|
||||
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--text-muted))' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
@@ -86,19 +86,19 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
|
||||
type="category"
|
||||
dataKey="hostname"
|
||||
width={120}
|
||||
tick={{ fontSize: 11, fill: '#cbd5e1' }}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--text-secondary))' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
content={<BwTooltip />}
|
||||
cursor={{ fill: '#334155', opacity: 0.5 }}
|
||||
cursor={{ fill: 'hsl(var(--elevated))', opacity: 0.5 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="bandwidthBps"
|
||||
fill="#38BDF8"
|
||||
radius={[0, 4, 4, 0]}
|
||||
maxBarSize={24}
|
||||
fill="hsl(var(--accent))"
|
||||
radius={[0, 2, 2, 0]}
|
||||
maxBarSize={20}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Bell, Server, HardDrive } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { eventsApi, type DashboardEvent, type EventsParams } from '@/lib/eventsApi'
|
||||
import { DeviceLink } from '@/components/ui/device-link'
|
||||
@@ -83,22 +82,6 @@ function EventIcon({ event }: { event: DashboardEvent }) {
|
||||
}
|
||||
}
|
||||
|
||||
function TimelineSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 pl-3">
|
||||
<Skeleton className="h-4 w-4 rounded-full shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-12 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EventsTimeline({ tenantId, isSuperAdmin }: EventsTimelineProps) {
|
||||
const [filterType, setFilterType] = useState<EventFilter>(undefined)
|
||||
@@ -115,86 +98,79 @@ export function EventsTimeline({ tenantId, isSuperAdmin }: EventsTimelineProps)
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-border">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
||||
Recent Events
|
||||
</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.label}
|
||||
onClick={() => setFilterType(f.value)}
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded text-xs font-medium transition-colors',
|
||||
filterType === f.value
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-panel border border-border rounded-sm">
|
||||
<div className="px-3 py-2 border-b border-border-subtle bg-elevated flex items-center justify-between">
|
||||
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
|
||||
Recent Events
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.label}
|
||||
onClick={() => setFilterType(f.value)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-sm text-[10px] font-medium transition-[background-color,color] duration-[50ms]',
|
||||
filterType === f.value
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
<div>
|
||||
{isSuperAdmin && !tenantId ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
|
||||
<div className="py-5 text-center text-[9px] text-text-muted">
|
||||
Select a tenant to view events
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<TimelineSkeleton />
|
||||
<div className="py-5 text-center text-[9px] text-text-muted">
|
||||
Loading…
|
||||
</div>
|
||||
) : !events || events.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
|
||||
<div className="py-5 text-center text-[9px] text-text-muted">
|
||||
No recent events
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[400px] overflow-y-auto pr-1">
|
||||
<div className="relative border-l-2 border-border ml-2">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="relative flex items-start gap-3 pb-3 pl-4 last:pb-0"
|
||||
>
|
||||
{/* Icon positioned over the timeline line */}
|
||||
<div className="absolute -left-[9px] top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-surface">
|
||||
<EventIcon event={event} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 ml-1">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{event.title}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
{event.description}
|
||||
{event.device_hostname && (
|
||||
<span className="ml-1 text-text-secondary">
|
||||
—{' '}
|
||||
{event.device_id ? (
|
||||
<DeviceLink tenantId={tenantId} deviceId={event.device_id}>
|
||||
{event.device_hostname}
|
||||
</DeviceLink>
|
||||
) : (
|
||||
event.device_hostname
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-xs text-text-muted whitespace-nowrap shrink-0 mt-0.5">
|
||||
{formatRelativeTime(event.timestamp)}
|
||||
<div className="max-h-[400px] overflow-y-auto divide-y divide-border-subtle">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<EventIcon event={event} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-text-primary truncate block">
|
||||
{event.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted truncate block">
|
||||
{event.description}
|
||||
{event.device_hostname && (
|
||||
<span className="ml-1 text-text-secondary">
|
||||
—{' '}
|
||||
{event.device_id ? (
|
||||
<DeviceLink tenantId={tenantId} deviceId={event.device_id}>
|
||||
{event.device_hostname}
|
||||
</DeviceLink>
|
||||
) : (
|
||||
event.device_hostname
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-text-muted whitespace-nowrap shrink-0">
|
||||
{formatRelativeTime(event.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function HealthScore({
|
||||
const dashOffset = CIRCUMFERENCE * (1 - progress)
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-border">
|
||||
<Card className="bg-panel border-border">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
||||
Network Health
|
||||
|
||||
@@ -44,45 +44,31 @@ function KpiCard({
|
||||
const animatedValue = useAnimatedCounter(value, 800, decimals)
|
||||
|
||||
return (
|
||||
<Card
|
||||
<div
|
||||
className={cn(
|
||||
'bg-gradient-to-br from-[#f8f8ff] to-elevated dark:from-elevated dark:to-[#16162a] border-border transition-colors',
|
||||
highlight && 'border-warning/30',
|
||||
'bg-panel border border-border px-3 py-2.5 rounded-sm',
|
||||
highlight && 'border-l-2 border-l-warning',
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-2xl font-medium tabular-nums font-mono',
|
||||
colorClass,
|
||||
)}
|
||||
>
|
||||
{decimals > 0 ? animatedValue.toFixed(decimals) : animatedValue}
|
||||
</span>
|
||||
{suffix && (
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-elevated/50',
|
||||
colorClass,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px] mb-1">
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
'text-lg font-medium tabular-nums font-mono',
|
||||
colorClass,
|
||||
)}
|
||||
>
|
||||
{decimals > 0 ? animatedValue.toFixed(decimals) : animatedValue}
|
||||
</span>
|
||||
{suffix && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,34 +72,38 @@ export function QuickActions({ tenantId, isSuperAdmin }: QuickActionsProps) {
|
||||
const actions = getActions(tenantId, isSuperAdmin)
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-border">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
||||
<div className="bg-panel border border-border rounded-sm">
|
||||
<div className="px-3 py-2 border-b border-border-subtle bg-elevated">
|
||||
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{actions.map((action) => (
|
||||
<Link
|
||||
key={action.label}
|
||||
to={action.to}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 rounded-lg px-3 py-3',
|
||||
'text-text-secondary hover:bg-elevated/50 hover:text-text-primary',
|
||||
'transition-colors text-center',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-elevated/50">
|
||||
{action.icon}
|
||||
</div>
|
||||
<span className="text-xs font-medium leading-tight">
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{actions.map((action) => (
|
||||
<Link
|
||||
key={action.label}
|
||||
to={action.to}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border-l-2 border-transparent hover:border-accent',
|
||||
'transition-[border-color,color] duration-[50ms]',
|
||||
)}
|
||||
>
|
||||
<div className="text-text-muted">
|
||||
{action.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-medium block">
|
||||
{action.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<span className="text-[10px] text-text-muted block">
|
||||
{action.description}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function WirelessIssues({ tenantId }: WirelessIssuesProps) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-5">
|
||||
<div className="rounded-lg border border-border bg-panel p-5">
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-4 flex items-center gap-2">
|
||||
<Wifi className="h-4 w-4 text-text-muted" />
|
||||
APs Needing Attention
|
||||
|
||||
@@ -50,7 +50,7 @@ function StatCard({
|
||||
color: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<div className={cn('text-2xl font-bold', color)}>{value}</div>
|
||||
<div className="text-xs text-text-muted mt-1">{label}</div>
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ function UpgradeDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded border border-border bg-surface p-3 text-xs text-text-secondary">
|
||||
<div className="rounded border border-border bg-panel p-3 text-xs text-text-secondary">
|
||||
A mandatory config backup will be taken before upgrading each device.
|
||||
</div>
|
||||
|
||||
@@ -254,10 +254,10 @@ function VersionGroupCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-3 px-4 py-3 w-full text-left hover:bg-surface transition-colors"
|
||||
className="flex items-center gap-3 px-4 py-3 w-full text-left hover:bg-panel transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||
|
||||
@@ -256,7 +256,7 @@ function MassUpgradeProgress({
|
||||
)}
|
||||
|
||||
{/* Device list */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden max-h-48 overflow-y-auto">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden max-h-48 overflow-y-auto">
|
||||
{rollout.jobs.map((job) => {
|
||||
const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending
|
||||
const Icon = config.icon
|
||||
|
||||
@@ -231,7 +231,7 @@ export function AdoptionWizard({ tenantId }: AdoptionWizardProps) {
|
||||
|
||||
{/* Step 3: Configure Credentials */}
|
||||
{step === 3 && (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Configure Credentials</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
@@ -253,7 +253,7 @@ export function AdoptionWizard({ tenantId }: AdoptionWizardProps) {
|
||||
className={cn(
|
||||
'flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors',
|
||||
credMode === opt.value
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
? 'bg-panel text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
@@ -389,7 +389,7 @@ export function AdoptionWizard({ tenantId }: AdoptionWizardProps) {
|
||||
|
||||
{/* Step 4: Assign Groups & Tags */}
|
||||
{step === 4 && (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Assign Groups & Tags</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
@@ -584,7 +584,7 @@ function SubnetStep({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Enter Subnet</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
@@ -682,7 +682,7 @@ function ScanResultsStep({
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Scan Results</h3>
|
||||
@@ -703,7 +703,7 @@ function ScanResultsStep({
|
||||
<div className="rounded-md border border-border/50 overflow-hidden max-h-72 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0">
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<tr className="border-b border-border bg-panel">
|
||||
<th className="px-3 py-2 w-8">
|
||||
<Checkbox
|
||||
checked={allNewSelected}
|
||||
@@ -959,7 +959,7 @@ function ImportVerifyStep({
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Import & Verify</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
@@ -1013,7 +1013,7 @@ function ImportVerifyStep({
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<tr className="border-b border-border bg-panel">
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">
|
||||
Device
|
||||
</th>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { metricsApi, tenantsApi } from '@/lib/api'
|
||||
import { metricsApi, tenantsApi, type FleetDevice } from '@/lib/api'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { alertsApi } from '@/lib/alertsApi'
|
||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
import { LayoutDashboard, MapPin } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
|
||||
// ─── Dashboard Widgets ───────────────────────────────────────────────────────
|
||||
import { KpiCards } from '@/components/dashboard/KpiCards'
|
||||
import { HealthScore } from '@/components/dashboard/HealthScore'
|
||||
import { EventsTimeline } from '@/components/dashboard/EventsTimeline'
|
||||
import { BandwidthChart, type BandwidthDevice } from '@/components/dashboard/BandwidthChart'
|
||||
@@ -30,39 +30,122 @@ const REFRESH_OPTIONS: { label: string; value: RefreshInterval }[] = [
|
||||
{ label: 'Off', value: false },
|
||||
]
|
||||
|
||||
// ─── Dashboard Skeleton ──────────────────────────────────────────────────────
|
||||
// ─── Dashboard Loading ───────────────────────────────────────────────────────
|
||||
|
||||
function DashboardSkeleton() {
|
||||
function DashboardLoading() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* KPI cards skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-4">
|
||||
<Skeleton className="h-3 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Needs Attention (inline component) ─────────────────────────────────────
|
||||
|
||||
interface AttentionItem {
|
||||
id: string
|
||||
deviceId: string
|
||||
tenantId: string
|
||||
hostname: string
|
||||
model: string | null
|
||||
severity: 'error' | 'warning'
|
||||
reason: string
|
||||
hasCoords: boolean
|
||||
}
|
||||
|
||||
function NeedsAttention({ devices }: { devices: FleetDevice[] }) {
|
||||
const items = useMemo<AttentionItem[]>(() => {
|
||||
const result: AttentionItem[] = []
|
||||
|
||||
for (const d of devices) {
|
||||
const base = {
|
||||
deviceId: d.id,
|
||||
tenantId: d.tenant_id,
|
||||
hostname: d.hostname,
|
||||
model: d.model,
|
||||
hasCoords: d.latitude != null && d.longitude != null,
|
||||
}
|
||||
|
||||
if (d.status === 'offline') {
|
||||
result.push({ ...base, id: `${d.id}-offline`, severity: 'error', reason: 'Offline' })
|
||||
} else if (d.status === 'degraded') {
|
||||
result.push({ ...base, id: `${d.id}-degraded`, severity: 'warning', reason: 'Degraded' })
|
||||
}
|
||||
|
||||
if (d.last_cpu_load != null && d.last_cpu_load > 80) {
|
||||
result.push({ ...base, id: `${d.id}-cpu`, severity: 'warning', reason: `CPU ${d.last_cpu_load}%` })
|
||||
}
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (a.severity === b.severity) return 0
|
||||
return a.severity === 'error' ? -1 : 1
|
||||
})
|
||||
|
||||
return result.slice(0, 10)
|
||||
}, [devices])
|
||||
|
||||
const count = items.length
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border-default rounded-sm mb-3.5">
|
||||
<div className="px-3 py-2 border-b border-border-default bg-elevated">
|
||||
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
|
||||
Needs Attention
|
||||
</span>
|
||||
<span className="text-[7px] text-[hsl(var(--text-label))]"> · </span>
|
||||
<span className="text-[7px] text-text-secondary font-mono">{count}</span>
|
||||
</div>
|
||||
{/* Widget grid skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
{count > 0 ? (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between px-3 py-1.5 border-l-2"
|
||||
style={{
|
||||
borderLeftColor:
|
||||
item.severity === 'error'
|
||||
? 'hsl(var(--error))'
|
||||
: 'hsl(var(--warning))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices/$deviceId"
|
||||
params={{ tenantId: item.tenantId, deviceId: item.deviceId }}
|
||||
className="text-xs text-text-primary font-medium truncate hover:text-accent transition-[color] duration-[50ms]"
|
||||
>
|
||||
{item.hostname}
|
||||
</Link>
|
||||
<span className="text-[10px] text-text-secondary flex-shrink-0">
|
||||
{item.model}
|
||||
</span>
|
||||
{item.hasCoords && (
|
||||
<Link
|
||||
to="/map"
|
||||
className="text-text-muted hover:text-accent transition-[color] duration-[50ms] flex-shrink-0"
|
||||
title="View on map"
|
||||
>
|
||||
<MapPin className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-medium flex-shrink-0 ml-2',
|
||||
item.severity === 'error' ? 'text-error' : 'text-warning',
|
||||
)}
|
||||
>
|
||||
{item.reason}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : (
|
||||
<div className="py-5 text-center">
|
||||
<span className="text-[9px] text-text-muted">No issues detected</span>
|
||||
</div>
|
||||
<div className="lg:col-span-2 rounded-lg border border-border p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -96,9 +179,9 @@ export function FleetDashboard() {
|
||||
isFetching: fleetFetching,
|
||||
dataUpdatedAt,
|
||||
} = useQuery({
|
||||
queryKey: ['fleet-summary', isSuperAdmin ? 'all' : tenantId],
|
||||
queryKey: ['fleet-summary', isSuperAdmin && !selectedTenantId ? 'all' : tenantId],
|
||||
queryFn: () =>
|
||||
isSuperAdmin
|
||||
isSuperAdmin && !selectedTenantId
|
||||
? metricsApi.fleetSummaryAll()
|
||||
: metricsApi.fleetSummary(tenantId),
|
||||
// Disable polling when SSE is connected (events update cache directly)
|
||||
@@ -128,6 +211,15 @@ export function FleetDashboard() {
|
||||
const onlinePercent =
|
||||
totalDevices > 0 ? (onlineDevices.length / totalDevices) * 100 : 0
|
||||
|
||||
const degradedCount = useMemo(
|
||||
() => fleetDevices?.filter((d) => d.status === 'degraded').length ?? 0,
|
||||
[fleetDevices],
|
||||
)
|
||||
const offlineCount = useMemo(
|
||||
() => fleetDevices?.filter((d) => d.status === 'offline').length ?? 0,
|
||||
[fleetDevices],
|
||||
)
|
||||
|
||||
// Alert counts
|
||||
const alerts = alertsData?.items ?? []
|
||||
const criticalCount = alerts.filter((a) => a.severity === 'critical').length
|
||||
@@ -183,10 +275,10 @@ export function FleetDashboard() {
|
||||
return (
|
||||
<div className="space-y-6" data-testid="dashboard">
|
||||
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-4 pb-2.5 mb-3.5 border-b border-border-default">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Dashboard</h1>
|
||||
<p className="text-sm text-text-muted mt-0.5">
|
||||
<h1 className="text-sm font-semibold text-text-primary">Overview</h1>
|
||||
<p className="text-[9px] text-text-muted mt-0.5">
|
||||
Fleet overview across{' '}
|
||||
{isSuperAdmin
|
||||
? selectedTenantId && selectedTenantName
|
||||
@@ -213,7 +305,7 @@ export function FleetDashboard() {
|
||||
</span>
|
||||
)}
|
||||
{/* Refresh interval selector */}
|
||||
<div className="flex items-center rounded-md border border-border bg-surface">
|
||||
<div className="flex items-center rounded-md border border-border bg-panel">
|
||||
{REFRESH_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
@@ -223,7 +315,7 @@ export function FleetDashboard() {
|
||||
'px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'first:rounded-l-md last:rounded-r-md',
|
||||
refreshInterval === opt.value
|
||||
? 'bg-accent/15 text-accent'
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
@@ -236,7 +328,7 @@ export function FleetDashboard() {
|
||||
|
||||
{/* ── Dashboard Content ───────────────────────────────────────────── */}
|
||||
{fleetLoading ? (
|
||||
<DashboardSkeleton />
|
||||
<DashboardLoading />
|
||||
) : totalDevices === 0 ? (
|
||||
<EmptyState
|
||||
icon={LayoutDashboard}
|
||||
@@ -245,13 +337,54 @@ export function FleetDashboard() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* KPI Cards — full width, 4 columns */}
|
||||
<KpiCards
|
||||
totalDevices={totalDevices}
|
||||
onlinePercent={onlinePercent}
|
||||
activeAlerts={totalAlerts}
|
||||
totalBandwidthBps={totalBandwidthBps}
|
||||
/>
|
||||
{/* Metrics Strip — joined 4-column bar */}
|
||||
<div className="flex gap-px mb-3.5 bg-border-default rounded-sm overflow-hidden">
|
||||
<div className="flex-1 bg-panel px-3 py-2">
|
||||
<div className="text-lg font-medium font-mono text-text-primary">
|
||||
{totalDevices}
|
||||
</div>
|
||||
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
||||
Devices
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-panel px-3 py-2">
|
||||
<div className="text-lg font-medium font-mono text-success">
|
||||
{onlineDevices.length}
|
||||
</div>
|
||||
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
||||
Online
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-panel px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
'text-lg font-medium font-mono',
|
||||
degradedCount > 0 ? 'text-warning' : 'text-text-primary',
|
||||
)}
|
||||
>
|
||||
{degradedCount}
|
||||
</div>
|
||||
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
||||
Degraded
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-panel px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
'text-lg font-medium font-mono',
|
||||
offlineCount > 0 ? 'text-error' : 'text-text-primary',
|
||||
)}
|
||||
>
|
||||
{offlineCount}
|
||||
</div>
|
||||
<div className="text-[7px] text-text-muted uppercase tracking-[1.5px] font-medium mt-0.5">
|
||||
Offline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Needs Attention — full width */}
|
||||
<NeedsAttention devices={fleetDevices ?? []} />
|
||||
|
||||
{/* Widget Grid — responsive 3 columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
@@ -92,7 +92,7 @@ function SortHeader({ column, label, currentSort, currentDir, onSort, className
|
||||
function DeviceCard({ device, tenantId }: { device: DeviceResponse; tenantId: string }) {
|
||||
return (
|
||||
<div
|
||||
className="w-full text-left rounded-lg border border-border bg-surface p-3 hover:bg-elevated/50 transition-colors min-h-[44px]"
|
||||
className="w-full text-left rounded-lg border border-border bg-panel p-3 hover:bg-elevated/30 transition-[background-color] duration-[50ms] min-h-[44px]"
|
||||
data-testid={`device-card-${device.hostname}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -345,7 +345,7 @@ export function FleetTable({
|
||||
isFetching && !isLoading && 'opacity-70',
|
||||
)}>
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={3} />
|
||||
<TableSkeleton />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Monitor}
|
||||
@@ -402,7 +402,7 @@ export function FleetTable({
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className={cn(
|
||||
'border-b border-border/50 hover:bg-elevated/50 transition-colors',
|
||||
'border-b border-border/50 hover:bg-elevated/30 transition-[background-color] duration-[50ms]',
|
||||
selectedIndex === virtualRow.index && 'bg-elevated/50',
|
||||
)}
|
||||
style={{
|
||||
@@ -460,7 +460,7 @@ export function FleetTable({
|
||||
key={device.id}
|
||||
data-testid={`device-row-${device.hostname}`}
|
||||
className={cn(
|
||||
'border-b border-border/50 hover:bg-elevated/50 transition-colors',
|
||||
'border-b border-border/50 hover:bg-elevated/30 transition-[background-color] duration-[50ms]',
|
||||
selectedIndex === idx && 'bg-elevated/50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -162,26 +162,26 @@ export function RemoteWinBoxButton({ tenantId, deviceId }: RemoteWinBoxButtonPro
|
||||
if (state === 'idle' || state === 'failed' || state === 'terminated') {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
disabled={createMutation.isPending}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-[var(--radius-control)] text-[10px] text-text-secondary border border-border-default hover:border-accent transition-[border-color,color] duration-[50ms] disabled:opacity-50"
|
||||
title="Open Remote WinBox (browser)"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
<Globe className="h-3 w-3" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Starting...' : 'Remote WinBox'}
|
||||
{createMutation.isPending ? 'Starting' : 'Remote'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
|
||||
title="Reset all remote WinBox sessions for this device"
|
||||
className="inline-flex items-center px-1.5 py-1 rounded-[var(--radius-control)] text-text-muted border border-border-default hover:border-accent transition-[border-color] duration-[50ms]"
|
||||
title="Reset remote sessions"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reset
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'failed' && error && (
|
||||
|
||||
@@ -162,10 +162,11 @@ export function SSHTerminal({ tenantId, deviceId, deviceName }: SSHTerminalProps
|
||||
return (
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-white hover:bg-accent/90"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-[var(--radius-control)] text-[10px] text-text-secondary border border-border-default hover:border-accent transition-[border-color,color] duration-[50ms]"
|
||||
title="Open SSH Terminal"
|
||||
>
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
SSH Terminal
|
||||
<TerminalIcon className="h-3 w-3" />
|
||||
SSH
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function ScanResultsList({ tenantId, results, onDone }: Props) {
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<tr className="border-b border-border bg-panel">
|
||||
<th className="px-3 py-2 w-8">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
@@ -121,7 +121,7 @@ export function ScanResultsList({ tenantId, results, onDone }: Props) {
|
||||
{results.discovered.map((device) => (
|
||||
<tr
|
||||
key={device.ip_address}
|
||||
className="border-b border-border/50 hover:bg-surface cursor-pointer"
|
||||
className="border-b border-border/50 hover:bg-panel cursor-pointer"
|
||||
onClick={() => toggleSelect(device.ip_address)}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -154,7 +154,7 @@ export function ScanResultsList({ tenantId, results, onDone }: Props) {
|
||||
|
||||
{/* Credentials */}
|
||||
{selected.size > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
Credentials for {selected.size} selected device{selected.size !== 1 ? 's' : ''}
|
||||
|
||||
@@ -78,14 +78,15 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
|
||||
openMutation.mutate()
|
||||
}}
|
||||
disabled={openMutation.isPending}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-[var(--radius-control)] text-[10px] text-text-secondary border border-border-default hover:border-accent transition-[border-color,color] duration-[50ms] disabled:opacity-50"
|
||||
title="Open WinBox tunnel"
|
||||
>
|
||||
{openMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4" />
|
||||
<Monitor className="h-3 w-3" />
|
||||
)}
|
||||
{openMutation.isPending ? 'Connecting...' : 'Open WinBox'}
|
||||
{openMutation.isPending ? 'Connecting' : 'WinBox'}
|
||||
</button>
|
||||
{error && <p className="mt-2 text-sm text-error">{error}</p>}
|
||||
</div>
|
||||
@@ -94,18 +95,17 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
|
||||
|
||||
if (state === 'ready' && tunnelInfo) {
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<p className="font-medium text-sm">WinBox tunnel ready</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Connect to: <code className="font-mono">{tunnelInfo.host}:{tunnelInfo.port}</code>
|
||||
<div className="rounded-sm border border-border-default bg-panel p-2.5 space-y-2">
|
||||
<p className="text-xs text-text-primary">
|
||||
Tunnel ready: <code className="font-mono text-[10px] text-text-secondary">{tunnelInfo.host}:{tunnelInfo.port}</code>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={copyAddress}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border hover:bg-accent"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-[var(--radius-control)] border border-border-default text-text-secondary hover:border-accent transition-[border-color] duration-[50ms]"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
{copied ? 'Copied!' : 'Copy Address'}
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -113,15 +113,13 @@ export function WinBoxButton({ tenantId, deviceId }: WinBoxButtonProps) {
|
||||
closeMutation.mutate()
|
||||
}}
|
||||
disabled={closeMutation.isPending}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border hover:bg-accent disabled:opacity-50"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded-[var(--radius-control)] border border-border-default text-text-muted hover:border-accent disabled:opacity-50 transition-[border-color] duration-[50ms]"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Close Tunnel
|
||||
<X className="h-2.5 w-2.5" />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Tunnel closes after 5 min of inactivity
|
||||
</p>
|
||||
<p className="text-[9px] text-text-muted">Closes after 5 min idle</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { ContextStrip } from './ContextStrip'
|
||||
import { ShortcutsDialog } from './ShortcutsDialog'
|
||||
import { CommandPalette } from '@/components/command-palette/CommandPalette'
|
||||
import { Toaster } from '@/components/ui/toast'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
// Apply persisted UI scale on mount
|
||||
const uiScale = useUIStore((s) => s.uiScale)
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = `${uiScale}%`
|
||||
}, [uiScale])
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<a
|
||||
@@ -20,7 +25,6 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
</a>
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||
<ContextStrip />
|
||||
<main id="main-content" tabIndex={-1} className="flex-1 overflow-auto p-5">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, Link } from '@tanstack/react-router'
|
||||
import { ChevronDown, Sun, Moon, LogOut, Settings, Menu } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { tenantsApi, metricsApi } from '@/lib/api'
|
||||
import { getLicenseStatus } from '@/lib/settingsApi'
|
||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||
import type { ConnectionState } from '@/hooks/useEventStream'
|
||||
import { NotificationBell } from '@/components/alerts/NotificationBell'
|
||||
|
||||
const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
const CONNECTION_COLORS: Record<ConnectionState, string> = {
|
||||
connected: 'bg-success',
|
||||
connecting: 'bg-warning animate-pulse',
|
||||
reconnecting: 'bg-warning animate-pulse',
|
||||
disconnected: 'bg-error',
|
||||
}
|
||||
|
||||
const CONNECTION_LABELS: Record<ConnectionState, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
reconnecting: 'Reconnecting',
|
||||
disconnected: 'Disconnected',
|
||||
}
|
||||
|
||||
// Generate a deterministic color from a string
|
||||
function tenantColor(name: string): string {
|
||||
const colors = [
|
||||
'bg-info', 'bg-success', 'bg-accent', 'bg-warning',
|
||||
'bg-error', 'bg-info', 'bg-accent', 'bg-success',
|
||||
]
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
export function ContextStrip() {
|
||||
const { user, logout } = useAuth()
|
||||
const { selectedTenantId, setSelectedTenantId, theme, setTheme, setMobileSidebarOpen } = useUIStore()
|
||||
const { connectionState } = useEventStreamContext()
|
||||
const navigate = useNavigate()
|
||||
const superAdmin = isSuperAdmin(user)
|
||||
|
||||
// Tenant list (super_admin only)
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: tenantsApi.list,
|
||||
enabled: superAdmin,
|
||||
select: (data) => data.filter((t) => t.id !== SYSTEM_TENANT_ID),
|
||||
})
|
||||
|
||||
const selectedTenant = tenants?.find((t) => t.id === selectedTenantId)
|
||||
|
||||
// Auto-select when there's exactly one tenant and nothing selected
|
||||
useEffect(() => {
|
||||
if (superAdmin && tenants && tenants.length === 1 && !selectedTenantId) {
|
||||
setSelectedTenantId(tenants[0].id)
|
||||
}
|
||||
}, [tenants, selectedTenantId, superAdmin, setSelectedTenantId])
|
||||
|
||||
// Fleet summary for status indicators
|
||||
const tenantId = superAdmin ? selectedTenantId : user?.tenant_id
|
||||
const { data: fleet } = useQuery({
|
||||
queryKey: ['fleet-summary', superAdmin ? 'all' : tenantId],
|
||||
queryFn: () =>
|
||||
superAdmin && !selectedTenantId
|
||||
? metricsApi.fleetSummaryAll()
|
||||
: tenantId
|
||||
? metricsApi.fleetSummary(tenantId)
|
||||
: Promise.resolve([]),
|
||||
enabled: !!tenantId || superAdmin,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const offlineCount = fleet?.filter((d) => d.status === 'offline').length ?? 0
|
||||
const degradedCount = fleet?.filter((d) => d.status === 'degraded').length ?? 0
|
||||
|
||||
// License status (super_admin only)
|
||||
const { data: license } = useQuery({
|
||||
queryKey: ['license-status'],
|
||||
queryFn: getLicenseStatus,
|
||||
enabled: superAdmin,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
void navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
// User initials for avatar
|
||||
const initials = user?.name
|
||||
? user.name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
: user?.email?.slice(0, 2).toUpperCase() ?? '?'
|
||||
|
||||
// Tenant display name for non-super_admin
|
||||
const tenantName = superAdmin
|
||||
? (selectedTenant?.name ?? 'All Orgs')
|
||||
: user?.name ?? 'Tenant'
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-9 bg-background/80 border-b border-border px-4 gap-4 flex-shrink-0">
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="lg:hidden p-1 rounded-md text-text-muted hover:text-text-primary hover:bg-elevated transition-colors -ml-1"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Left: Org switcher */}
|
||||
<div className="flex items-center border-r border-border pr-4">
|
||||
{superAdmin && tenants && tenants.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors">
|
||||
<div
|
||||
className={`w-4 h-4 rounded flex items-center justify-center text-[8px] font-bold text-white ${tenantColor(tenantName)}`}
|
||||
>
|
||||
{tenantName[0]?.toUpperCase()}
|
||||
</div>
|
||||
<span className="truncate max-w-[120px]">{tenantName}</span>
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuLabel className="text-xs">Organization</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setSelectedTenantId(null)} className="text-xs">
|
||||
All Orgs
|
||||
</DropdownMenuItem>
|
||||
{tenants.map((tenant) => (
|
||||
<DropdownMenuItem
|
||||
key={tenant.id}
|
||||
onClick={() => setSelectedTenantId(tenant.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded flex items-center justify-center text-[7px] font-bold text-white mr-1.5 ${tenantColor(tenant.name)}`}
|
||||
>
|
||||
{tenant.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
{tenant.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-xs text-text-secondary truncate max-w-[120px]">
|
||||
{superAdmin ? 'No orgs' : (user?.name ?? 'Tenant')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Status indicators */}
|
||||
<div className="flex-1 hidden sm:flex items-center gap-3">
|
||||
{fleet ? (
|
||||
<>
|
||||
{offlineCount > 0 && (
|
||||
<button
|
||||
onClick={() => void navigate({ to: '/' })}
|
||||
className="flex items-center gap-1 text-xs text-error hover:text-error/80 transition-colors"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-error" />
|
||||
{offlineCount} down
|
||||
</button>
|
||||
)}
|
||||
{degradedCount > 0 && (
|
||||
<button
|
||||
onClick={() => void navigate({ to: '/' })}
|
||||
className="flex items-center gap-1 text-xs text-warning hover:text-warning/80 transition-colors"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-warning" />
|
||||
{degradedCount} degraded
|
||||
</button>
|
||||
)}
|
||||
{offlineCount === 0 && degradedCount === 0 && fleet.length > 0 && (
|
||||
<span className="text-xs text-text-muted">All systems nominal</span>
|
||||
)}
|
||||
{fleet.length === 0 && (
|
||||
<span className="text-xs text-text-muted">No devices</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">Status loading...</span>
|
||||
)}
|
||||
{license?.over_limit && (
|
||||
<span className="text-xs font-mono text-error animate-pulse">
|
||||
{license.actual_devices}/{license.licensed_devices} licensed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* Notification bell */}
|
||||
{tenantId && <NotificationBell tenantId={tenantId} />}
|
||||
|
||||
{/* Command palette shortcut */}
|
||||
<button
|
||||
onClick={() => useCommandPalette.getState().setOpen(true)}
|
||||
className="text-[10px] font-mono text-text-muted hover:text-text-secondary border border-border rounded px-1.5 py-0.5 transition-colors"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
⌘K
|
||||
</button>
|
||||
|
||||
{/* Connection status dot */}
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${CONNECTION_COLORS[connectionState]}`}
|
||||
role="status"
|
||||
aria-label={`Connection: ${CONNECTION_LABELS[connectionState]}`}
|
||||
title={CONNECTION_LABELS[connectionState]}
|
||||
/>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="p-1 rounded text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* User avatar dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center" aria-label="User menu">
|
||||
<div className="w-[22px] h-[22px] rounded-full bg-elevated flex items-center justify-center text-[9px] font-semibold text-text-secondary">
|
||||
{initials}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="text-xs font-normal text-text-secondary">{user?.email}</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings" className="flex items-center gap-2 text-xs">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void handleLogout()} className="text-error text-xs">
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +1,197 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { APP_VERSION } from '@/lib/version'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { Link, useRouterState, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Monitor,
|
||||
Building2,
|
||||
Users,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
LayoutDashboard,
|
||||
Wifi,
|
||||
MapPin,
|
||||
Bell,
|
||||
Map,
|
||||
Terminal,
|
||||
FileCode,
|
||||
LayoutDashboard,
|
||||
ClipboardList,
|
||||
Wifi,
|
||||
BarChart3,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
Info,
|
||||
Bell,
|
||||
Network,
|
||||
Map,
|
||||
Layers,
|
||||
Download,
|
||||
Wrench,
|
||||
ClipboardList,
|
||||
BellRing,
|
||||
Calendar,
|
||||
FileBarChart,
|
||||
Eye,
|
||||
BellRing,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||
import { alertEventsApi, tenantsApi } from '@/lib/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RugLogo } from '@/components/brand/RugLogo'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { ConnectionState } from '@/hooks/useEventStream'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.FC<{ className?: string }>
|
||||
exact?: boolean
|
||||
badge?: number
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
visible: boolean
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
const CONNECTION_LABELS: Record<ConnectionState, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
reconnecting: 'Reconnecting',
|
||||
disconnected: 'Disconnected',
|
||||
}
|
||||
|
||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const navItemBase =
|
||||
'flex items-center gap-2 text-[13px] py-[5px] px-2 pl-[10px] border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear'
|
||||
|
||||
const navItemInactive =
|
||||
'text-text-secondary hover:border-accent'
|
||||
|
||||
const navItemActive =
|
||||
'text-text-primary font-medium border-accent bg-accent-soft rounded-r-sm'
|
||||
|
||||
const lowFreqBase =
|
||||
'flex items-center gap-2 text-[13px] text-text-muted py-[3px] px-2 pl-[10px] border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent'
|
||||
|
||||
const iconClass = 'h-4 w-4 text-text-muted flex-shrink-0'
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function Sidebar() {
|
||||
const { user } = useAuth()
|
||||
const { sidebarCollapsed, toggleSidebar, mobileSidebarOpen, setMobileSidebarOpen } = useUIStore()
|
||||
const { user, logout } = useAuth()
|
||||
const {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
mobileSidebarOpen,
|
||||
setMobileSidebarOpen,
|
||||
selectedTenantId,
|
||||
setSelectedTenantId,
|
||||
theme,
|
||||
setTheme,
|
||||
uiScale,
|
||||
setUIScale,
|
||||
} = useUIStore()
|
||||
const { connectionState } = useEventStreamContext()
|
||||
const routerState = useRouterState()
|
||||
const currentPath = routerState.location.pathname
|
||||
const navigate = useNavigate()
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
|
||||
const superAdmin = isSuperAdmin(user)
|
||||
const tenantAdmin = isTenantAdmin(user)
|
||||
const tenantId = superAdmin ? selectedTenantId : user?.tenant_id
|
||||
|
||||
// ─── Queries ────────────────────────────────────────────────────────────
|
||||
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: tenantsApi.list,
|
||||
enabled: superAdmin,
|
||||
select: (data) => data.filter((t) => t.id !== SYSTEM_TENANT_ID),
|
||||
})
|
||||
|
||||
const selectedTenant = tenants?.find((t) => t.id === selectedTenantId)
|
||||
|
||||
// Auto-select when there's exactly one tenant and nothing selected
|
||||
useEffect(() => {
|
||||
if (superAdmin && tenants && tenants.length === 1 && !selectedTenantId) {
|
||||
setSelectedTenantId(tenants[0].id)
|
||||
}
|
||||
}, [tenants, selectedTenantId, superAdmin, setSelectedTenantId])
|
||||
|
||||
const { data: alertCount } = useQuery({
|
||||
queryKey: ['alert-active-count', tenantId],
|
||||
queryFn: () => alertEventsApi.activeCount(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
// ─── Tenant display name ───────────────────────────────────────────────
|
||||
|
||||
const tenantName = superAdmin
|
||||
? (selectedTenant?.name ?? 'All Orgs')
|
||||
: (user?.name ?? user?.email ?? 'Tenant')
|
||||
|
||||
// ─── Nav items ────────────────────────────────────────────────────────
|
||||
|
||||
const operateItems: NavItem[] = [
|
||||
{ label: 'Overview', href: '/', icon: LayoutDashboard, exact: true },
|
||||
...(!superAdmin && user?.tenant_id
|
||||
? [
|
||||
{ label: 'Devices', href: `/tenants/${user.tenant_id}/devices`, icon: Monitor },
|
||||
{ label: 'Sites', href: `/tenants/${user.tenant_id}/sites`, icon: MapPin },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Alerts',
|
||||
href: '/alerts',
|
||||
icon: Bell,
|
||||
badge: alertCount && alertCount > 0 ? alertCount : undefined,
|
||||
},
|
||||
...(!superAdmin && user?.tenant_id
|
||||
? [{ label: 'Wireless', href: `/tenants/${user.tenant_id}/wireless-links`, icon: Wifi }]
|
||||
: [{ label: 'Wireless', href: '/wireless', icon: Wifi }]
|
||||
),
|
||||
{ label: 'Map', href: '/map', icon: Map },
|
||||
]
|
||||
|
||||
const actItems: NavItem[] = [
|
||||
{ label: 'Config', href: '/config-editor', icon: Terminal },
|
||||
{ label: 'Templates', href: '/templates', icon: FileCode },
|
||||
{ label: 'Firmware', href: '/firmware', icon: Download },
|
||||
{ label: 'Commands', href: '/bulk-commands', icon: Wrench },
|
||||
]
|
||||
|
||||
const lowFreqItems: NavItem[] = [
|
||||
...(superAdmin || tenantAdmin
|
||||
? [{ label: 'Organizations', href: '/tenants', icon: Building2 }]
|
||||
: []),
|
||||
...(tenantAdmin && user?.tenant_id
|
||||
? [{ label: 'Users', href: `/tenants/${user.tenant_id}/users`, icon: Users }]
|
||||
: []),
|
||||
{ label: 'Certificates', href: '/certificates', icon: ShieldCheck },
|
||||
{ label: 'VPN', href: '/vpn', icon: KeyRound },
|
||||
{ label: 'Alert Rules', href: '/alert-rules', icon: BellRing },
|
||||
{ label: 'Maintenance', href: '/maintenance', icon: Calendar },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings },
|
||||
{ label: 'Audit Log', href: '/audit', icon: ClipboardList },
|
||||
{ label: 'Reports', href: '/reports', icon: FileBarChart },
|
||||
]
|
||||
|
||||
// ─── Active state ─────────────────────────────────────────────────────
|
||||
|
||||
const isActive = (item: NavItem) => {
|
||||
if (item.exact) return currentPath === item.href
|
||||
if (item.href === '/settings')
|
||||
return currentPath === '/settings' || currentPath.startsWith('/settings/')
|
||||
return currentPath.startsWith(item.href) && item.href.length > 1
|
||||
}
|
||||
|
||||
// ─── Focus trap for mobile ───────────────────────────────────────────
|
||||
|
||||
// Mobile sidebar focus trap
|
||||
useEffect(() => {
|
||||
if (!mobileSidebarOpen) return
|
||||
|
||||
@@ -86,9 +224,8 @@ export function Sidebar() {
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [mobileSidebarOpen, setMobileSidebarOpen])
|
||||
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
// ─── Keyboard shortcut: [ to toggle ───────────────────────────────────
|
||||
|
||||
// Keyboard toggle: [ key collapses/expands sidebar
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -105,262 +242,253 @@ export function Sidebar() {
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
label: 'Fleet',
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Overview',
|
||||
href: '/',
|
||||
icon: LayoutDashboard,
|
||||
exact: true,
|
||||
},
|
||||
// Only show Devices for non-super_admin with a tenant_id
|
||||
...(!isSuperAdmin(user) && user?.tenant_id
|
||||
? [
|
||||
{
|
||||
label: 'Devices',
|
||||
href: `/tenants/${user.tenant_id}/devices`,
|
||||
icon: Monitor,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isSuperAdmin(user) && user?.tenant_id
|
||||
? [
|
||||
{
|
||||
label: 'Sites',
|
||||
href: `/tenants/${user.tenant_id}/sites`,
|
||||
icon: MapPin,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isSuperAdmin(user) && user?.tenant_id
|
||||
? [{
|
||||
label: 'Wireless Links',
|
||||
href: `/tenants/${user.tenant_id}/wireless-links`,
|
||||
icon: Wifi,
|
||||
}]
|
||||
: [{
|
||||
label: 'Wireless Links',
|
||||
href: '/wireless',
|
||||
icon: Wifi,
|
||||
}]
|
||||
),
|
||||
{
|
||||
label: 'Traffic',
|
||||
href: '/traffic',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
label: 'Alerts',
|
||||
href: '/alerts',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
label: 'Topology',
|
||||
href: '/topology',
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
label: 'Map',
|
||||
href: '/map',
|
||||
icon: Map,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Config',
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
label: 'Editor',
|
||||
href: '/config-editor',
|
||||
icon: Terminal,
|
||||
},
|
||||
{
|
||||
label: 'Templates',
|
||||
href: '/templates',
|
||||
icon: FileCode,
|
||||
},
|
||||
{
|
||||
label: 'Firmware',
|
||||
href: '/firmware',
|
||||
icon: Download,
|
||||
},
|
||||
{
|
||||
label: 'Certificates',
|
||||
href: '/certificates',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: 'VPN',
|
||||
href: '/vpn',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
label: 'Batch Config',
|
||||
href: '/batch-config',
|
||||
icon: Layers,
|
||||
},
|
||||
{
|
||||
label: 'Bulk Commands',
|
||||
href: '/bulk-commands',
|
||||
icon: Wrench,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
visible: isSuperAdmin(user) || isTenantAdmin(user),
|
||||
items: [
|
||||
...(isTenantAdmin(user) && user?.tenant_id
|
||||
? [
|
||||
{
|
||||
label: 'Users',
|
||||
href: `/tenants/${user.tenant_id}/users`,
|
||||
icon: Users,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSuperAdmin(user) || isTenantAdmin(user)
|
||||
? [
|
||||
{
|
||||
label: 'Organizations',
|
||||
href: '/tenants',
|
||||
icon: Building2,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Audit Log',
|
||||
href: '/audit',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
label: 'Alert Rules',
|
||||
href: '/alert-rules',
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
label: 'Maintenance',
|
||||
href: '/maintenance',
|
||||
icon: Calendar,
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
href: '/reports',
|
||||
icon: FileBarChart,
|
||||
},
|
||||
{
|
||||
label: 'Transparency',
|
||||
href: '/transparency',
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'About',
|
||||
href: '/about',
|
||||
icon: Info,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
// ─── Logout handler ───────────────────────────────────────────────────
|
||||
|
||||
const visibleSections = sections.filter((s) => s.visible)
|
||||
|
||||
const isActive = (item: NavItem) => {
|
||||
if (item.exact) return currentPath === item.href
|
||||
// Settings should only match exact to avoid catching everything
|
||||
if (item.href === '/settings') return currentPath === '/settings' || currentPath.startsWith('/settings/')
|
||||
return currentPath.startsWith(item.href) && item.href.length > 1
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
void navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
const sidebarContent = (showCollapsed: boolean) => (
|
||||
// ─── Render helpers ───────────────────────────────────────────────────
|
||||
|
||||
const renderNavItem = (item: NavItem, collapsed: boolean) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className={cn(
|
||||
collapsed
|
||||
? 'flex items-center justify-center py-[5px] px-2 border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent'
|
||||
: navItemBase,
|
||||
active
|
||||
? navItemActive
|
||||
: collapsed
|
||||
? 'text-text-secondary'
|
||||
: navItemInactive,
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
aria-label={collapsed ? item.label : undefined}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon className={iconClass} aria-hidden="true" />
|
||||
{!collapsed && (
|
||||
<span className="truncate flex-1">{item.label}</span>
|
||||
)}
|
||||
{!collapsed && item.badge !== undefined && item.badge > 0 && (
|
||||
<span className="text-[8px] font-semibold font-mono bg-alert-badge text-background px-1.5 rounded-sm leading-4">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const renderLowFreqItem = (item: NavItem, collapsed: boolean) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className={cn(
|
||||
collapsed
|
||||
? 'flex items-center justify-center py-[3px] px-2 border-l-2 border-transparent transition-[border-color,color] duration-[50ms] linear hover:border-accent'
|
||||
: lowFreqBase,
|
||||
active && navItemActive,
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
aria-label={collapsed ? item.label : undefined}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<Icon className={iconClass} aria-hidden="true" />
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sidebar content ─────────────────────────────────────────────────
|
||||
|
||||
const sidebarContent = (collapsed: boolean) => (
|
||||
<>
|
||||
{/* Logo */}
|
||||
{/* Logo area */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-12 px-3 border-b border-border',
|
||||
showCollapsed ? 'justify-center' : 'gap-2',
|
||||
'flex items-center border-b border-border-subtle px-3 py-2',
|
||||
collapsed ? 'justify-center h-12' : 'gap-2 min-h-[48px]',
|
||||
)}
|
||||
>
|
||||
<RugLogo size={showCollapsed ? 24 : 28} className="flex-shrink-0" />
|
||||
{!showCollapsed && (
|
||||
<span className="text-sm font-semibold text-text-primary truncate">
|
||||
TOD
|
||||
</span>
|
||||
<RugLogo size={collapsed ? 24 : 28} className="flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-semibold text-text-primary">TOD</span>
|
||||
<div className="text-[8px] text-text-muted truncate">{tenantName}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav ref={navRef} data-slot="fleet-nav" className="flex-1 py-2 overflow-y-auto">
|
||||
{visibleSections.map((section, sectionIdx) => (
|
||||
<div key={section.label}>
|
||||
{showCollapsed && sectionIdx > 0 && (
|
||||
<div className="mx-2 my-1 border-t border-border" />
|
||||
)}
|
||||
{!showCollapsed && (
|
||||
<div className="px-3 pt-4 pb-1 text-[10px] font-semibold uppercase tracking-wider text-text-muted">
|
||||
{section.label}
|
||||
</div>
|
||||
)}
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={`${section.label}-${item.label}`}
|
||||
to={item.href}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
data-testid={`nav-${item.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-3 py-2 mx-1 rounded-md text-sm transition-colors min-h-[44px]',
|
||||
active
|
||||
? 'bg-[hsl(var(--accent-muted))] text-accent rounded-md'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-elevated/50 rounded-md',
|
||||
showCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
title={showCollapsed ? item.label : undefined}
|
||||
aria-label={showCollapsed ? item.label : undefined}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
{/* Tenant selector (super_admin only) */}
|
||||
{superAdmin && !collapsed && tenants && tenants.length > 0 && (
|
||||
<div className="px-3 py-2 border-b border-border-subtle">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 w-full text-[11px] text-text-secondary hover:text-text-primary transition-[color] duration-[50ms] linear">
|
||||
<span className="truncate flex-1 text-left">{tenantName}</span>
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0 text-text-muted" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedTenantId(null)}
|
||||
className="text-xs"
|
||||
>
|
||||
All Orgs
|
||||
</DropdownMenuItem>
|
||||
{tenants.map((tenant) => (
|
||||
<DropdownMenuItem
|
||||
key={tenant.id}
|
||||
onClick={() => setSelectedTenantId(tenant.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
||||
{!showCollapsed && (
|
||||
<span className="truncate">{item.label}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Version identifier */}
|
||||
{!showCollapsed && (
|
||||
<div className="px-3 py-1 text-center">
|
||||
<span className="font-mono text-[9px] text-text-muted">TOD {APP_VERSION}</span>
|
||||
{tenant.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
{superAdmin && collapsed && (
|
||||
<div className="border-b border-border-subtle" />
|
||||
)}
|
||||
|
||||
{/* Collapse toggle (hidden on mobile) */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="hidden lg:flex items-center justify-center h-10 border-t border-border text-text-muted hover:text-text-secondary transition-colors"
|
||||
title={showCollapsed ? 'Expand sidebar ([)' : 'Collapse sidebar ([)'}
|
||||
aria-label={showCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
data-testid="sidebar-toggle"
|
||||
>
|
||||
{showCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{/* Main navigation */}
|
||||
<nav ref={navRef} data-slot="fleet-nav" className="flex-1 overflow-y-auto py-2">
|
||||
{/* operate section */}
|
||||
{!collapsed && (
|
||||
<div className="text-[7px] uppercase tracking-[3px] text-text-label pl-[10px] mb-2">
|
||||
operate
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{operateItems.map((item) => renderNavItem(item, collapsed))}
|
||||
|
||||
{/* Hairline separator */}
|
||||
<div className="mx-2 my-2 border-t border-border-subtle" />
|
||||
|
||||
{/* act section */}
|
||||
{!collapsed && (
|
||||
<div className="text-[7px] uppercase tracking-[3px] text-text-label pl-[10px] mb-2">
|
||||
act
|
||||
</div>
|
||||
)}
|
||||
{actItems.map((item) => renderNavItem(item, collapsed))}
|
||||
|
||||
{/* Low-frequency items separator */}
|
||||
<div className="mx-2 my-2 border-t border-border-subtle" />
|
||||
|
||||
{/* Low-frequency items */}
|
||||
{lowFreqItems.map((item) => renderLowFreqItem(item, collapsed))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border-subtle px-3 py-2">
|
||||
{!collapsed ? (
|
||||
<>
|
||||
{/* User row: email + theme + logout */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[8px] text-text-muted truncate flex-1">
|
||||
{user?.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-3 w-3" />
|
||||
) : (
|
||||
<Moon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleLogout()}
|
||||
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Scale selector */}
|
||||
<div className="flex items-center gap-px rounded-[var(--radius-control)] border border-border-subtle overflow-hidden mb-1">
|
||||
{([100, 110, 125] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setUIScale(s)}
|
||||
className={cn(
|
||||
'flex-1 text-[8px] py-px text-center transition-[background-color,color] duration-[50ms]',
|
||||
uiScale === s
|
||||
? 'bg-accent-soft text-text-primary font-medium'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{s}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Connection + version row */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'w-[5px] h-[5px] rounded-full flex-shrink-0',
|
||||
connectionState === 'connected' ? 'bg-online' : 'bg-offline',
|
||||
(connectionState === 'connecting' || connectionState === 'reconnecting') && 'animate-pulse',
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`Connection: ${CONNECTION_LABELS[connectionState]}`}
|
||||
/>
|
||||
<span className="text-[8px] text-text-muted">
|
||||
{CONNECTION_LABELS[connectionState]}
|
||||
</span>
|
||||
<span className="text-[8px] text-text-muted font-mono ml-auto">
|
||||
{APP_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Collapsed footer: just connection dot and theme toggle */
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-3 w-3" />
|
||||
) : (
|
||||
<Moon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleLogout()}
|
||||
className="p-0.5 text-text-muted hover:text-text-secondary transition-[color] duration-[50ms] linear"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
'w-[5px] h-[5px] rounded-full',
|
||||
connectionState === 'connected' ? 'bg-online' : 'bg-offline',
|
||||
(connectionState === 'connecting' || connectionState === 'reconnecting') && 'animate-pulse',
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`Connection: ${CONNECTION_LABELS[connectionState]}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -371,13 +499,15 @@ export function Sidebar() {
|
||||
data-testid="sidebar"
|
||||
data-sidebar
|
||||
className={cn(
|
||||
'hidden lg:flex flex-col border-r border-border bg-sidebar transition-all duration-200',
|
||||
sidebarCollapsed ? 'w-14' : 'w-[180px]',
|
||||
'hidden lg:flex flex-col border-r border-border-default bg-sidebar transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-14' : 'w-[172px]',
|
||||
)}
|
||||
>
|
||||
{sidebarContent(sidebarCollapsed)}
|
||||
</aside>
|
||||
|
||||
{/* Mobile hamburger (rendered outside sidebar for AppLayout) */}
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileSidebarOpen && (
|
||||
<>
|
||||
@@ -390,7 +520,7 @@ export function Sidebar() {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation"
|
||||
className="lg:hidden fixed inset-y-0 left-0 z-50 w-[180px] flex flex-col bg-sidebar border-r border-border"
|
||||
className="lg:hidden fixed inset-y-0 left-0 z-50 w-[172px] flex flex-col bg-sidebar border-r border-border-default"
|
||||
>
|
||||
{sidebarContent(false)}
|
||||
</aside>
|
||||
|
||||
@@ -99,13 +99,8 @@ export function MaintenanceList({ tenantId }: MaintenanceListProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-20 rounded-lg border border-border bg-surface animate-pulse"
|
||||
/>
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -266,7 +261,7 @@ function WindowCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-border bg-surface p-3 ${
|
||||
className={`rounded-lg border border-border bg-panel p-3 ${
|
||||
isPast ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { metricsApi, tenantsApi } from '@/lib/api'
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
import { FleetMap } from './FleetMap'
|
||||
|
||||
export function MapPage() {
|
||||
@@ -55,7 +55,11 @@ export function MapPage() {
|
||||
}, [superAdmin, selectedTenant, user])
|
||||
|
||||
if (devicesLoading) {
|
||||
return <Skeleton className="h-[calc(100vh-8rem)] w-full rounded-lg" />
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-8rem)]">
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (devicesError) {
|
||||
@@ -88,7 +92,7 @@ export function MapPage() {
|
||||
<select
|
||||
value={selectedTenant}
|
||||
onChange={(e) => setSelectedTenant(e.target.value)}
|
||||
className="text-xs bg-elevated/50 border border-border text-text-primary rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-border-bright"
|
||||
className="text-xs bg-elevated/50 border border-border text-text-primary rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-border-default"
|
||||
>
|
||||
<option value="all">All Organizations</option>
|
||||
{tenants.map((t) => (
|
||||
|
||||
@@ -33,7 +33,7 @@ function CustomTooltip({
|
||||
}: { active?: boolean; payload?: Array<{ value?: number }>; label?: string; unit: string }) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded border border-border bg-surface px-2 py-1.5 text-xs text-text-primary">
|
||||
<div className="rounded border border-border bg-panel px-2 py-1.5 text-xs text-text-primary">
|
||||
<div className="mb-1 text-text-muted">{label}</div>
|
||||
<div>
|
||||
{(payload[0].value ?? 0).toFixed(1)}
|
||||
@@ -64,10 +64,10 @@ export function HealthChart({ data, metric, label, color, unit, maxY }: HealthCh
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border-default))" />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||
tick={{ fontSize: 9, fill: 'hsl(var(--text-muted))' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
@@ -75,7 +75,7 @@ export function HealthChart({ data, metric, label, color, unit, maxY }: HealthCh
|
||||
<YAxis
|
||||
domain={domain}
|
||||
tickFormatter={(v: number) => `${v}${unit}`}
|
||||
tick={{ fontSize: 9, fill: '#94a3b8' }}
|
||||
tick={{ fontSize: 9, fill: 'hsl(var(--text-muted))' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
|
||||
@@ -41,28 +41,26 @@ export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps)
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-44 animate-pulse" />
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
||||
No health metrics data available for the selected time range.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<HealthChart
|
||||
data={data}
|
||||
metric="avg_cpu"
|
||||
label="CPU Load"
|
||||
color="#38BDF8"
|
||||
color="hsl(var(--accent))"
|
||||
unit="%"
|
||||
maxY={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<HealthChart
|
||||
data={data}
|
||||
metric="avg_mem_pct"
|
||||
@@ -72,7 +70,7 @@ export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps)
|
||||
maxY={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<HealthChart
|
||||
data={data}
|
||||
metric="avg_disk_pct"
|
||||
@@ -82,7 +80,7 @@ export function HealthTab({ tenantId, deviceId, active = true }: HealthTabProps)
|
||||
maxY={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4">
|
||||
<HealthChart
|
||||
data={data}
|
||||
metric="avg_temp"
|
||||
|
||||
@@ -87,13 +87,11 @@ export function InterfacesTab({ tenantId, deviceId, active = true }: InterfacesT
|
||||
|
||||
{/* Charts */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-56 animate-pulse" />
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
) : !trafficData || trafficData.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
||||
{interfaces && interfaces.length === 0
|
||||
? 'No interfaces discovered for this device.'
|
||||
: 'No traffic data available for the selected time range.'}
|
||||
@@ -103,7 +101,7 @@ export function InterfacesTab({ tenantId, deviceId, active = true }: InterfacesT
|
||||
{interfaceNames.map((ifaceName) => {
|
||||
const ifaceData = byInterface.get(ifaceName) ?? []
|
||||
return (
|
||||
<div key={ifaceName} className="rounded-lg border border-border bg-surface p-4">
|
||||
<div key={ifaceName} className="rounded-lg border border-border bg-panel p-4">
|
||||
<TrafficChart data={ifaceData} interfaceName={ifaceName} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ interface SparklineProps {
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function Sparkline({ data, color = '#38BDF8', width = 60, height = 24 }: SparklineProps) {
|
||||
export function Sparkline({ data, color = 'hsl(var(--accent))', width = 60, height = 24 }: SparklineProps) {
|
||||
const chartData = data.map((v, i) => ({ v, i }))
|
||||
return (
|
||||
<LineChart width={width} height={height} data={chartData}>
|
||||
|
||||
@@ -101,7 +101,7 @@ export function TimeRangeSelector({
|
||||
className={cn(
|
||||
'px-2.5 py-1 text-xs rounded border transition-colors',
|
||||
value === preset
|
||||
? 'bg-elevated border-border-bright text-text-primary'
|
||||
? 'bg-elevated border-border-default text-text-primary'
|
||||
: 'bg-transparent border-border/50 text-text-primary/40 hover:text-text-primary/60 hover:border-border',
|
||||
)}
|
||||
>
|
||||
@@ -113,7 +113,7 @@ export function TimeRangeSelector({
|
||||
className={cn(
|
||||
'px-2.5 py-1 text-xs rounded border transition-colors',
|
||||
value === 'custom'
|
||||
? 'bg-elevated border-border-bright text-text-primary'
|
||||
? 'bg-elevated border-border-default text-text-primary'
|
||||
: 'bg-transparent border-border/50 text-text-primary/40 hover:text-text-primary/60 hover:border-border',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ function formatBucket(bucket: string, useDate: boolean): string {
|
||||
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ value?: number; dataKey?: string; name?: string; color?: string }>; label?: string }) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="rounded border border-border bg-surface px-2 py-1.5 text-xs text-text-primary">
|
||||
<div className="rounded border border-border bg-panel px-2 py-1.5 text-xs text-text-primary">
|
||||
<div className="mb-1 text-text-muted">{label}</div>
|
||||
{payload.map((entry) => (
|
||||
<div key={entry.dataKey} className="flex items-center gap-2">
|
||||
@@ -70,25 +70,25 @@ export function TrafficChart({ data, interfaceName }: TrafficChartProps) {
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={`rx-grad-${interfaceName}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#38BDF8" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#38BDF8" stopOpacity={0} />
|
||||
<stop offset="0%" stopColor="hsl(var(--accent))" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="hsl(var(--accent))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id={`tx-grad-${interfaceName}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#4ADE80" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="#4ADE80" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border-default))" />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--text-muted))' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatBps}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--text-muted))' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={60}
|
||||
@@ -98,7 +98,7 @@ export function TrafficChart({ data, interfaceName }: TrafficChartProps) {
|
||||
type="monotone"
|
||||
dataKey="avg_rx_bps"
|
||||
name="avg_rx_bps"
|
||||
stroke="#38BDF8"
|
||||
stroke="hsl(var(--accent))"
|
||||
strokeWidth={1.5}
|
||||
fill={`url(#rx-grad-${interfaceName})`}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ function ClientCountMiniChart({ data }: { data: WirelessMetricPoint[] }) {
|
||||
<stop offset="100%" stopColor="#A78BFA" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border-default))" />
|
||||
<XAxis dataKey="bucket" hide />
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -57,7 +57,7 @@ function WirelessInterfaceCard({ section }: { section: WirelessInterfaceSection
|
||||
const { interfaceName, latest, history } = section
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-3">
|
||||
{/* Interface name header */}
|
||||
<h3 className="text-sm font-medium text-text-primary">{interfaceName}</h3>
|
||||
|
||||
@@ -159,13 +159,11 @@ export function WirelessTab({ tenantId, deviceId, active = true }: WirelessTabPr
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-surface p-4 h-48 animate-pulse" />
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
) : hasNoWireless ? (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center text-sm text-text-muted">
|
||||
No wireless interfaces detected on this device.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -171,7 +171,7 @@ export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) {
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />
|
||||
return <TableSkeleton />
|
||||
}
|
||||
|
||||
// Error state
|
||||
@@ -246,7 +246,7 @@ export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) {
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface text-text-secondary text-left">
|
||||
<tr className="bg-panel text-text-secondary text-left">
|
||||
{/* Expand chevron column */}
|
||||
<th className="w-8 px-3 py-2.5" />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { metricsApi, type InterfaceMetricPoint } from '@/lib/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
|
||||
interface InterfaceGaugesProps {
|
||||
tenantId: string
|
||||
@@ -101,21 +101,15 @@ export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesP
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-surface p-3">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-3 w-full mb-1" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!interfaces || interfaces.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 text-center text-sm text-text-muted">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 text-center text-sm text-text-muted">
|
||||
No interface data available.
|
||||
</div>
|
||||
)
|
||||
@@ -149,7 +143,7 @@ export function InterfaceGauges({ tenantId, deviceId, active }: InterfaceGaugesP
|
||||
const values = latestByIface.get(ifaceName) ?? { rx: 0, tx: 0 }
|
||||
|
||||
return (
|
||||
<div key={ifaceName} className="rounded-lg border border-border bg-surface p-3">
|
||||
<div key={ifaceName} className="rounded-lg border border-border bg-panel p-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-sm font-medium text-text-primary">{ifaceName}</span>
|
||||
<span className="text-[10px] text-text-muted">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search, RefreshCw } from 'lucide-react'
|
||||
import { networkApi, type LogEntry } from '@/lib/networkApi'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
|
||||
interface LogsTabProps {
|
||||
tenantId: string
|
||||
@@ -62,16 +62,10 @@ function TopicBadge({ topics }: { topics: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
function TableLoading() {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3 py-2 px-3">
|
||||
<Skeleton className="h-4 w-32 shrink-0" />
|
||||
<Skeleton className="h-4 w-20 shrink-0" />
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
</div>
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -187,9 +181,9 @@ export function LogsTab({ tenantId, deviceId, active }: LogsTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Log table */}
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
<TableLoading />
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-sm text-error">
|
||||
Failed to fetch device logs. The device may be offline or unreachable.
|
||||
|
||||
@@ -99,7 +99,7 @@ function DeviceNode({ data }: NodeProps<DeviceNodeData>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-surface px-3 py-2 min-w-[180px]',
|
||||
'rounded-lg border bg-panel px-3 py-2 min-w-[180px]',
|
||||
'transition-colors',
|
||||
isOnline ? 'border-border' : 'border-error/30',
|
||||
)}
|
||||
@@ -339,7 +339,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
|
||||
>
|
||||
<Background color="hsl(var(--muted))" gap={20} size={1} />
|
||||
<Controls
|
||||
className="!bg-surface !border-border [&>button]:!bg-surface [&>button]:!border-border [&>button]:!text-text-secondary [&>button:hover]:!bg-elevated"
|
||||
className="!bg-panel !border-border [&>button]:!bg-panel [&>button]:!border-border [&>button]:!text-text-secondary [&>button:hover]:!bg-elevated"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
@@ -349,7 +349,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
|
||||
: 'hsl(var(--error))'
|
||||
}}
|
||||
maskColor="hsl(var(--background) / 0.7)"
|
||||
className="!bg-surface !border-border"
|
||||
className="!bg-panel !border-border"
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
@@ -357,7 +357,7 @@ export function TopologyMap({ tenantId }: TopologyMapProps) {
|
||||
{tooltip && <NodeTooltip data={tooltip} onClose={() => setTooltip(null)} />}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 left-4 rounded-lg border border-border bg-surface/90 backdrop-blur-sm px-3 py-2 text-xs text-text-muted">
|
||||
<div className="absolute bottom-4 left-4 rounded-lg border border-border bg-panel/90 backdrop-blur-sm px-3 py-2 text-xs text-text-muted">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-success" /> Online
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Shield, Lock, Globe } from 'lucide-react'
|
||||
import { networkApi, type VpnTunnel } from '@/lib/networkApi'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LoadingText } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface VpnTabProps {
|
||||
@@ -26,17 +26,17 @@ const VPN_TYPE_CONFIG = {
|
||||
wireguard: {
|
||||
icon: Shield,
|
||||
label: 'WireGuard',
|
||||
color: '#a855f7', // purple
|
||||
color: 'hsl(var(--accent))',
|
||||
},
|
||||
ipsec: {
|
||||
icon: Lock,
|
||||
label: 'IPsec',
|
||||
color: '#3b82f6', // blue
|
||||
color: 'hsl(var(--info))',
|
||||
},
|
||||
l2tp: {
|
||||
icon: Globe,
|
||||
label: 'L2TP',
|
||||
color: '#22c55e', // green
|
||||
color: 'hsl(var(--success))',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -102,17 +102,15 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="mt-4 py-8 text-center">
|
||||
<LoadingText />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface p-6 text-center text-sm text-error">
|
||||
<div className="mt-4 rounded-lg border border-border bg-panel p-6 text-center text-sm text-error">
|
||||
Failed to load VPN tunnels. The device may not support this feature.
|
||||
</div>
|
||||
)
|
||||
@@ -120,7 +118,7 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
|
||||
|
||||
if (!data || data.tunnels.length === 0) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<div className="mt-4 rounded-lg border border-border bg-panel p-8 text-center">
|
||||
<Shield className="w-10 h-10 mx-auto mb-3 text-text-muted opacity-40" />
|
||||
<p className="text-sm font-medium text-text-primary mb-1">
|
||||
No active VPN tunnels
|
||||
@@ -134,7 +132,7 @@ export function VpnTab({ tenantId, deviceId, active }: VpnTabProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="mt-4 rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/50">
|
||||
|
||||
@@ -251,7 +251,7 @@ export function BulkCommandWizard({ tenantId }: BulkCommandWizardProps) {
|
||||
|
||||
{/* Step 2: Enter Command */}
|
||||
{step === 2 && (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Enter RouterOS Command</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
@@ -294,7 +294,7 @@ export function BulkCommandWizard({ tenantId }: BulkCommandWizardProps) {
|
||||
|
||||
{/* Step 3: Review & Execute */}
|
||||
{step === 3 && (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Review & Execute</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
@@ -336,7 +336,7 @@ export function BulkCommandWizard({ tenantId }: BulkCommandWizardProps) {
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<tr className="border-b border-border bg-panel">
|
||||
<th className="text-left px-3 py-2 text-xs font-medium text-text-muted">
|
||||
Device
|
||||
</th>
|
||||
@@ -611,11 +611,11 @@ function DeviceSelectionStep({
|
||||
}
|
||||
|
||||
if (devicesLoading) {
|
||||
return <TableSkeleton rows={5} />
|
||||
return <TableSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Select Target Devices</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
@@ -641,7 +641,7 @@ function DeviceSelectionStep({
|
||||
className={cn(
|
||||
'flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors',
|
||||
mode === opt.value
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
? 'bg-panel text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
@@ -699,7 +699,7 @@ function DeviceSelectionStep({
|
||||
<div className="rounded-md border border-border/50 overflow-hidden max-h-72 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0">
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<tr className="border-b border-border bg-panel">
|
||||
<th className="px-3 py-2 w-8">
|
||||
<Checkbox
|
||||
checked={
|
||||
|
||||
@@ -122,8 +122,8 @@ export function ReportsPage({ tenantId }: ReportsPageProps) {
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-4 rounded-lg border text-left transition-all',
|
||||
isSelected
|
||||
? 'border-accent bg-accent-muted/30 ring-1 ring-accent'
|
||||
: 'border-border bg-surface hover:border-text-muted',
|
||||
? 'border-accent bg-accent-soft/30 ring-1 ring-accent'
|
||||
: 'border-border bg-panel hover:border-text-muted',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -172,7 +172,7 @@ export function ReportsPage({ tenantId }: ReportsPageProps) {
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-surface px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
className="w-full h-9 rounded-md border border-border bg-panel px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
@@ -187,7 +187,7 @@ export function ReportsPage({ tenantId }: ReportsPageProps) {
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full h-9 rounded-md border border-border bg-surface px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
className="w-full h-9 rounded-md border border-border bg-panel px-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
|
||||
action={{ label: 'Create API Key', onClick: () => setShowCreateDialog(true) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/30">
|
||||
@@ -264,7 +264,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-md border border-border-bright bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
||||
className="w-full rounded-md border border-border-default bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
||||
placeholder="e.g. Monitoring Integration"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -300,7 +300,7 @@ export function ApiKeysPage({ tenantId }: ApiKeysPageProps) {
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className="w-full rounded-md border border-border-bright bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
||||
className="w-full rounded-md border border-border-default bg-elevated/50 px-3 py-2 text-sm focus:border-accent focus:outline-none"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Account section */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
|
||||
<SectionHeader icon={User} title="Account" />
|
||||
<InfoRow label="Email" value={user?.email} />
|
||||
<InfoRow label="Role" value={
|
||||
@@ -94,13 +94,13 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Password & Security section */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
|
||||
<SectionHeader icon={Lock} title="Password & Security" />
|
||||
<ChangePasswordForm />
|
||||
</div>
|
||||
|
||||
{/* Permissions section */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
|
||||
<SectionHeader icon={Shield} title="Permissions" />
|
||||
<InfoRow label="Read devices" value="Yes" />
|
||||
<InfoRow
|
||||
@@ -118,7 +118,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* System info section */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
|
||||
<SectionHeader icon={Info} title="System" />
|
||||
<InfoRow label="API" value={
|
||||
<a
|
||||
@@ -135,7 +135,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Quick links */}
|
||||
{isTenantAdmin(user) && (
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
|
||||
<SectionHeader icon={Key} title="Integrations" />
|
||||
<Link
|
||||
to="/settings/api-keys"
|
||||
@@ -152,7 +152,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Maintenance — super_admin only */}
|
||||
{isSuperAdmin(user) && (
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-1">
|
||||
<SectionHeader icon={Monitor} title="Maintenance" />
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
@@ -182,7 +182,7 @@ export function SettingsPage() {
|
||||
{isSuperAdmin(user) && <SMTPSettingsSection />}
|
||||
|
||||
{/* Data & Privacy section */}
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-3">
|
||||
<SectionHeader icon={Shield} title="Data & Privacy" />
|
||||
|
||||
{/* Export Data */}
|
||||
@@ -377,7 +377,7 @@ function SMTPSettingsSection() {
|
||||
if (isLoading) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionHeader icon={Mail} title="System Email (SMTP)" />
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
|
||||
|
||||
@@ -484,7 +484,7 @@ export function SetupWizard() {
|
||||
<StepIndicator currentStep={step} />
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-surface border border-border rounded-lg p-8">
|
||||
<div className="bg-panel border border-border rounded-lg p-8">
|
||||
{step === 1 && (
|
||||
<CreateTenantStep
|
||||
onComplete={(tenant) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export function SimpleFormSection({
|
||||
children,
|
||||
}: SimpleFormSectionProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-4 space-y-4">
|
||||
<div className="rounded-lg border border-border bg-panel p-4 space-y-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="h-4.5 w-4.5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
|
||||
@@ -14,31 +14,31 @@ interface SimpleModeToggleProps {
|
||||
|
||||
export function SimpleModeToggle({ mode, onModeChange }: SimpleModeToggleProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-elevated/50 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<div className="flex items-center gap-px rounded-[var(--radius-control)] border border-border-default overflow-hidden">
|
||||
<button
|
||||
onClick={() => onModeChange('simple')}
|
||||
className={cn(
|
||||
'gap-1.5 h-7 px-2.5 text-xs',
|
||||
mode === 'simple' && 'bg-accent/20 text-accent',
|
||||
'flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition-[background-color,color] duration-[50ms]',
|
||||
mode === 'simple'
|
||||
? 'bg-accent-soft text-text-primary font-medium'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
<LayoutGrid className="h-2.5 w-2.5" />
|
||||
Simple
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeChange('standard')}
|
||||
className={cn(
|
||||
'gap-1.5 h-7 px-2.5 text-xs',
|
||||
mode === 'standard' && 'bg-accent/20 text-accent',
|
||||
'flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition-[background-color,color] duration-[50ms]',
|
||||
mode === 'standard'
|
||||
? 'bg-accent-soft text-text-primary font-medium'
|
||||
: 'text-text-muted hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<Sliders className="h-3.5 w-3.5" />
|
||||
<Sliders className="h-2.5 w-2.5" />
|
||||
Standard
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function SimpleStatusBanner({ items, isLoading }: SimpleStatusBannerProps
|
||||
<div key={i} className="flex flex-col">
|
||||
<span className="text-xs text-text-muted">{item.label}</span>
|
||||
{isLoading ? (
|
||||
<div className="h-5 w-24 mt-0.5 rounded bg-elevated animate-shimmer" />
|
||||
<span className="text-[9px] text-text-muted mt-0.5">Loading…</span>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{item.value || '\u2014'}
|
||||
|
||||
@@ -133,17 +133,17 @@ export function StandardConfigSidebar({
|
||||
onSwitchToSimple,
|
||||
}: StandardConfigSidebarProps) {
|
||||
return (
|
||||
<div className="w-48 flex-shrink-0 flex flex-col min-h-[400px]">
|
||||
<nav className="space-y-3 overflow-y-auto flex-1">
|
||||
<div className="w-44 flex-shrink-0 flex flex-col min-h-[400px]">
|
||||
<nav className="space-y-2 overflow-y-auto flex-1">
|
||||
{STANDARD_GROUPS.map((group) => {
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium text-text-muted uppercase tracking-wider mb-1 px-3">
|
||||
<p className="flex items-center gap-1.5 text-[7px] text-text-label uppercase tracking-[2px] mb-1 pl-2">
|
||||
<GroupIcon className="h-3 w-3" />
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
<div>
|
||||
{group.items.map((item) => {
|
||||
const isActive = activeTab === item.id
|
||||
return (
|
||||
@@ -152,10 +152,10 @@ export function StandardConfigSidebar({
|
||||
onClick={() => onTabChange(item.id)}
|
||||
data-testid={`tab-${item.id}`}
|
||||
className={cn(
|
||||
'flex items-center w-full text-left pl-7 pr-3 py-1.5 rounded-r-lg text-sm transition-colors',
|
||||
'flex items-center w-full text-left pl-6 pr-2 py-[3px] text-xs border-l-2 transition-[border-color,color] duration-[50ms]',
|
||||
isActive
|
||||
? 'bg-accent/10 text-accent border-l-2 border-accent'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-elevated/50 border-l-2 border-transparent',
|
||||
? 'bg-accent-soft text-text-primary font-medium border-accent rounded-r-sm'
|
||||
: 'text-text-secondary hover:border-accent border-transparent',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{item.label}</span>
|
||||
@@ -169,13 +169,13 @@ export function StandardConfigSidebar({
|
||||
</nav>
|
||||
|
||||
{onSwitchToSimple && (
|
||||
<div className="mt-auto pt-4 border-t border-border/50">
|
||||
<div className="mt-auto pt-2 border-t border-border-subtle">
|
||||
<button
|
||||
onClick={onSwitchToSimple}
|
||||
className="flex items-center gap-2 w-full text-left px-3 py-2 text-xs text-text-muted hover:text-text-secondary transition-colors"
|
||||
className="flex items-center gap-1.5 w-full text-left px-2 py-1.5 text-[10px] text-text-muted hover:text-text-secondary transition-[color] duration-[50ms]"
|
||||
>
|
||||
<Sliders className="h-3.5 w-3.5" />
|
||||
Switch to Simple mode
|
||||
<Sliders className="h-3 w-3" />
|
||||
Simple mode
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -180,7 +180,7 @@ export function LanDhcpPanel({ tenantId, deviceId, active }: ConfigPanelProps) {
|
||||
{activeLeases.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">No active DHCP leases</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-elevated/30">
|
||||
|
||||
@@ -126,7 +126,7 @@ export function WifiSimplePanel({ tenantId, deviceId, active, routerosVersion }:
|
||||
// No wireless hardware
|
||||
if (wireless.entries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-12 text-center">
|
||||
<div className="rounded-lg border border-border bg-panel p-12 text-center">
|
||||
<Wifi className="h-8 w-8 text-text-muted/50 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-text-secondary">
|
||||
This device does not have wireless hardware
|
||||
|
||||
@@ -57,15 +57,8 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
|
||||
|
||||
if (devicesLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-surface p-4 space-y-3 animate-pulse">
|
||||
<div className="h-4 w-24 bg-elevated rounded" />
|
||||
<div className="h-1.5 w-full bg-elevated rounded-full" />
|
||||
<div className="h-1.5 w-full bg-elevated rounded-full" />
|
||||
<div className="h-3 w-16 bg-elevated rounded" />
|
||||
</div>
|
||||
))}
|
||||
<div className="py-8 text-center">
|
||||
<span className="text-[9px] text-text-muted">Loading…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -74,7 +67,7 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
No devices assigned to this site. Assign devices from the fleet page.
|
||||
</p>
|
||||
@@ -103,7 +96,7 @@ export function SiteHealthGrid({ tenantId, siteId }: SiteHealthGridProps) {
|
||||
to="/tenants/$tenantId/devices/$deviceId"
|
||||
params={{ tenantId, deviceId: device.id }}
|
||||
className={cn(
|
||||
'rounded-lg border bg-surface p-4 space-y-2 hover:bg-elevated/50 transition-colors block',
|
||||
'rounded-lg border bg-panel p-4 space-y-2 hover:bg-elevated/50 transition-colors block',
|
||||
borderColor(device.status),
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -145,7 +145,7 @@ export function SiteSectorView({ tenantId, siteId }: SiteSectorViewProps) {
|
||||
}, [linksData])
|
||||
|
||||
if (sectorsLoading || devicesLoading) {
|
||||
return <TableSkeleton rows={6} />
|
||||
return <TableSkeleton />
|
||||
}
|
||||
|
||||
const sectors = sectorData?.items ?? []
|
||||
@@ -153,7 +153,7 @@ export function SiteSectorView({ tenantId, siteId }: SiteSectorViewProps) {
|
||||
|
||||
if (sectors.length === 0 && unassignedDevices.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface p-8 text-center space-y-3">
|
||||
<div className="rounded-lg border border-border bg-panel p-8 text-center space-y-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
No sectors defined. Create sectors to organize APs by direction.
|
||||
</p>
|
||||
@@ -315,7 +315,7 @@ function SectorSection({
|
||||
const isUnassigned = !sector
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-panel overflow-hidden">
|
||||
{/* Section header */}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-4 py-3 hover:bg-elevated/50 transition-colors text-left"
|
||||
|
||||
@@ -99,7 +99,7 @@ export function SiteTable({ tenantId, search, onCreateClick, onEditClick }: Site
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={4} />
|
||||
return <TableSkeleton />
|
||||
}
|
||||
|
||||
if (!data || data.sites.length === 0) {
|
||||
|
||||
@@ -101,7 +101,7 @@ export function PushProgressPanel({ tenantId, rolloutId, onClose }: PushProgress
|
||||
return (
|
||||
<div
|
||||
key={job.device_id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/50 bg-surface/50 px-3 py-2"
|
||||
className="flex items-center gap-3 rounded-lg border border-border/50 bg-panel/50 px-3 py-2"
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
|
||||
@@ -164,7 +164,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What does this template configure?"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-elevated/50 border border-border text-text-primary placeholder:text-text-muted resize-none focus:outline-none focus:ring-1 focus:ring-border-bright"
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-elevated/50 border border-border text-text-primary placeholder:text-text-muted resize-none focus:outline-none focus:ring-1 focus:ring-border-default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -176,7 +176,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`# Example: Set system identity\n/system identity set name={{ device.hostname }}-{{ site_name }}\n\n# Add IP address\n/ip address add address={{ mgmt_ip }}/24 interface=ether1`}
|
||||
rows={16}
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-success placeholder:text-text-muted font-mono resize-y focus:outline-none focus:ring-1 focus:ring-border-bright leading-relaxed"
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-success placeholder:text-text-muted font-mono resize-y focus:outline-none focus:ring-1 focus:ring-border-default leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +247,7 @@ export function TemplateEditor({ template, onSave, onCancel }: TemplateEditorPro
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-surface border-b border-border">
|
||||
<tr className="bg-panel border-b border-border">
|
||||
<th className="text-left px-3 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Name</th>
|
||||
<th className="text-left px-3 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted w-28">Type</th>
|
||||
<th className="text-left px-3 py-1.5 text-[10px] uppercase tracking-wider font-semibold text-text-muted">Default</th>
|
||||
|
||||
@@ -150,7 +150,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-surface border-border text-text-primary">
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-panel border-border text-text-primary">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm flex items-center gap-2">
|
||||
Push Template: {template.name}
|
||||
@@ -193,7 +193,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
|
||||
{devices?.map((device) => (
|
||||
<label
|
||||
key={device.id}
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-surface cursor-pointer"
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-panel cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedDeviceIds.has(device.id)}
|
||||
@@ -237,7 +237,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
|
||||
Provide values for template variables. Built-in device variables are auto-populated per device.
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-text-muted bg-surface rounded px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted bg-panel rounded px-3 py-2">
|
||||
Auto-populated: {'{{ device.hostname }}'}, {'{{ device.ip }}'}, {'{{ device.model }}'}
|
||||
</div>
|
||||
|
||||
@@ -321,7 +321,7 @@ export function TemplatePushWizard({ open, onClose, tenantId, template }: Templa
|
||||
'text-xs px-2 py-1 rounded transition-colors',
|
||||
previewDevice === d.id
|
||||
? 'bg-elevated text-text-primary'
|
||||
: 'bg-surface text-text-secondary hover:text-text-secondary',
|
||||
: 'bg-panel text-text-secondary hover:text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{d.hostname}
|
||||
|
||||
@@ -229,7 +229,7 @@ export function TemplatesPage() {
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="rounded-lg border border-border bg-surface/50 p-4 hover:bg-surface transition-colors"
|
||||
className="rounded-lg border border-border bg-panel/50 p-4 hover:bg-panel transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -313,7 +313,7 @@ export function TemplatesPage() {
|
||||
open={!!deleteConfirmId}
|
||||
onOpenChange={(o) => !o && setDeleteConfirmId(null)}
|
||||
>
|
||||
<DialogContent className="max-w-sm bg-surface border-border text-text-primary">
|
||||
<DialogContent className="max-w-sm bg-panel border-border text-text-primary">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Delete Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user