feat(11-02): add SiteTable, site list page, and site detail page
- SiteTable with sortable columns, search, delete confirmation, unassigned row
- Site list page at /tenants/{tenantId}/sites with create/edit dialogs
- Site detail page at /tenants/{tenantId}/sites/{siteId} with health stats
- Route tree regenerated for new site routes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
268
frontend/src/components/sites/SiteTable.tsx
Normal file
268
frontend/src/components/sites/SiteTable.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, MapPin, Pencil, Trash2 } from 'lucide-react'
|
||||
import { sitesApi, type SiteResponse } from '@/lib/api'
|
||||
import { useAuth, canWrite } from '@/lib/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface SiteTableProps {
|
||||
tenantId: string
|
||||
search: string
|
||||
onCreateClick: () => void
|
||||
onEditClick: (site: SiteResponse) => void
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'device_count' | 'online_percent'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
interface SortHeaderProps {
|
||||
column: SortField
|
||||
label: string
|
||||
currentSort: SortField
|
||||
currentDir: SortDir
|
||||
onSort: (col: SortField) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function SortHeader({ column, label, currentSort, currentDir, onSort, className }: SortHeaderProps) {
|
||||
const isActive = currentSort === column
|
||||
const ariaSortValue: 'ascending' | 'descending' | 'none' = isActive
|
||||
? (currentDir === 'asc' ? 'ascending' : 'descending')
|
||||
: 'none'
|
||||
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={cn('px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted', className)}
|
||||
aria-sort={ariaSortValue}
|
||||
>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:text-text-primary transition-colors group"
|
||||
onClick={() => onSort(column)}
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentDir === 'asc' ? (
|
||||
<ChevronUp className="h-3 w-3 text-text-secondary" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-text-secondary" />
|
||||
)
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 text-text-muted group-hover:text-text-secondary" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteTable({ tenantId, search, onCreateClick, onEditClick }: SiteTableProps) {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const showActions = canWrite(user)
|
||||
|
||||
const [sortBy, setSortBy] = useState<SortField>('name')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const [deleteTarget, setDeleteTarget] = useState<SiteResponse | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['sites', tenantId],
|
||||
queryFn: () => sitesApi.list(tenantId),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (siteId: string) => sitesApi.delete(tenantId, siteId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['sites', tenantId] })
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
})
|
||||
|
||||
function handleSort(col: SortField) {
|
||||
if (col === sortBy) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortBy(col)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={4} />
|
||||
}
|
||||
|
||||
if (!data || data.sites.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
title="No sites yet"
|
||||
description="Create a site to organize your devices by physical location."
|
||||
action={{ label: 'Create Site', onClick: onCreateClick }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
const filtered = data.sites.filter((site) =>
|
||||
site.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
// Sort
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name) * dir
|
||||
if (sortBy === 'device_count') return (a.device_count - b.device_count) * dir
|
||||
if (sortBy === 'online_percent') return (a.online_percent - b.online_percent) * dir
|
||||
return 0
|
||||
})
|
||||
|
||||
const sortProps = { currentSort: sortBy, currentDir: sortDir, onSort: handleSort }
|
||||
const colCount = showActions ? 6 : 5
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<SortHeader column="name" label="Name" {...sortProps} className="text-left" />
|
||||
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">
|
||||
Address
|
||||
</th>
|
||||
<SortHeader column="device_count" label="Devices" {...sortProps} className="text-right" />
|
||||
<SortHeader column="online_percent" label="Online %" {...sortProps} className="text-right" />
|
||||
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||
Alerts
|
||||
</th>
|
||||
{showActions && (
|
||||
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((site) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
className="border-b border-border/50 hover:bg-elevated/50 transition-colors"
|
||||
>
|
||||
<td className="px-2 py-1.5">
|
||||
<Link
|
||||
to="/tenants/$tenantId/sites/$siteId"
|
||||
params={{ tenantId, siteId: site.id }}
|
||||
className="font-medium text-text-primary hover:text-accent transition-colors"
|
||||
>
|
||||
{site.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-text-secondary truncate max-w-[200px]">
|
||||
{site.address ?? '--'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||
{site.device_count}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
site.device_count === 0
|
||||
? 'text-text-muted'
|
||||
: site.online_percent >= 90
|
||||
? 'text-green-500'
|
||||
: site.online_percent >= 50
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500',
|
||||
)}
|
||||
>
|
||||
{site.device_count > 0 ? `${site.online_percent.toFixed(0)}%` : '--'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{site.alert_count > 0 ? (
|
||||
<span className="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium border border-red-500/40 bg-red-500/10 text-red-500">
|
||||
{site.alert_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted">0</span>
|
||||
)}
|
||||
</td>
|
||||
{showActions && (
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEditClick(site)}
|
||||
title="Edit site"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteTarget(site)}
|
||||
title="Delete site"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-error" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Unassigned devices row */}
|
||||
<tr className="bg-elevated/30">
|
||||
<td className="px-2 py-1.5 text-text-muted italic" colSpan={2}>
|
||||
Unassigned
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right text-text-muted">
|
||||
{data.unassigned_count}
|
||||
</td>
|
||||
<td className="px-2 py-1.5" colSpan={colCount - 3} />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete site?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{deleteTarget?.name}"?{' '}
|
||||
{deleteTarget?.device_count ?? 0} device(s) will become unassigned.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,10 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||
import { Route as AuthenticatedWirelessRouteImport } from './routes/_authenticated/wireless'
|
||||
import { Route as AuthenticatedVpnRouteImport } from './routes/_authenticated/vpn'
|
||||
import { Route as AuthenticatedTransparencyRouteImport } from './routes/_authenticated/transparency'
|
||||
import { Route as AuthenticatedTrafficRouteImport } from './routes/_authenticated/traffic'
|
||||
import { Route as AuthenticatedTopologyRouteImport } from './routes/_authenticated/topology'
|
||||
import { Route as AuthenticatedTemplatesRouteImport } from './routes/_authenticated/templates'
|
||||
import { Route as AuthenticatedSetupRouteImport } from './routes/_authenticated/setup'
|
||||
@@ -38,7 +40,9 @@ import { Route as AuthenticatedTenantsIndexRouteImport } from './routes/_authent
|
||||
import { Route as AuthenticatedSettingsApiKeysRouteImport } from './routes/_authenticated/settings.api-keys'
|
||||
import { Route as AuthenticatedTenantsTenantIdIndexRouteImport } from './routes/_authenticated/tenants/$tenantId/index'
|
||||
import { Route as AuthenticatedTenantsTenantIdUsersRouteImport } from './routes/_authenticated/tenants/$tenantId/users'
|
||||
import { Route as AuthenticatedTenantsTenantIdSitesIndexRouteImport } from './routes/_authenticated/tenants/$tenantId/sites/index'
|
||||
import { Route as AuthenticatedTenantsTenantIdDevicesIndexRouteImport } from './routes/_authenticated/tenants/$tenantId/devices/index'
|
||||
import { Route as AuthenticatedTenantsTenantIdSitesSiteIdRouteImport } from './routes/_authenticated/tenants/$tenantId/sites/$siteId'
|
||||
import { Route as AuthenticatedTenantsTenantIdDevicesScanRouteImport } from './routes/_authenticated/tenants/$tenantId/devices/scan'
|
||||
import { Route as AuthenticatedTenantsTenantIdDevicesAdoptRouteImport } from './routes/_authenticated/tenants/$tenantId/devices/adopt'
|
||||
import { Route as AuthenticatedTenantsTenantIdDevicesAddRouteImport } from './routes/_authenticated/tenants/$tenantId/devices/add'
|
||||
@@ -78,6 +82,11 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedWirelessRoute = AuthenticatedWirelessRouteImport.update({
|
||||
id: '/wireless',
|
||||
path: '/wireless',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedVpnRoute = AuthenticatedVpnRouteImport.update({
|
||||
id: '/vpn',
|
||||
path: '/vpn',
|
||||
@@ -89,6 +98,11 @@ const AuthenticatedTransparencyRoute =
|
||||
path: '/transparency',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedTrafficRoute = AuthenticatedTrafficRouteImport.update({
|
||||
id: '/traffic',
|
||||
path: '/traffic',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedTopologyRoute = AuthenticatedTopologyRouteImport.update({
|
||||
id: '/topology',
|
||||
path: '/topology',
|
||||
@@ -198,12 +212,24 @@ const AuthenticatedTenantsTenantIdUsersRoute =
|
||||
path: '/tenants/$tenantId/users',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedTenantsTenantIdSitesIndexRoute =
|
||||
AuthenticatedTenantsTenantIdSitesIndexRouteImport.update({
|
||||
id: '/tenants/$tenantId/sites/',
|
||||
path: '/tenants/$tenantId/sites/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedTenantsTenantIdDevicesIndexRoute =
|
||||
AuthenticatedTenantsTenantIdDevicesIndexRouteImport.update({
|
||||
id: '/tenants/$tenantId/devices/',
|
||||
path: '/tenants/$tenantId/devices/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedTenantsTenantIdSitesSiteIdRoute =
|
||||
AuthenticatedTenantsTenantIdSitesSiteIdRouteImport.update({
|
||||
id: '/tenants/$tenantId/sites/$siteId',
|
||||
path: '/tenants/$tenantId/sites/$siteId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedTenantsTenantIdDevicesScanRoute =
|
||||
AuthenticatedTenantsTenantIdDevicesScanRouteImport.update({
|
||||
id: '/tenants/$tenantId/devices/scan',
|
||||
@@ -252,8 +278,10 @@ export interface FileRoutesByFullPath {
|
||||
'/setup': typeof AuthenticatedSetupRoute
|
||||
'/templates': typeof AuthenticatedTemplatesRoute
|
||||
'/topology': typeof AuthenticatedTopologyRoute
|
||||
'/traffic': typeof AuthenticatedTrafficRoute
|
||||
'/transparency': typeof AuthenticatedTransparencyRoute
|
||||
'/vpn': typeof AuthenticatedVpnRoute
|
||||
'/wireless': typeof AuthenticatedWirelessRoute
|
||||
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||
'/tenants/': typeof AuthenticatedTenantsIndexRoute
|
||||
'/tenants/$tenantId/users': typeof AuthenticatedTenantsTenantIdUsersRoute
|
||||
@@ -262,7 +290,9 @@ export interface FileRoutesByFullPath {
|
||||
'/tenants/$tenantId/devices/add': typeof AuthenticatedTenantsTenantIdDevicesAddRoute
|
||||
'/tenants/$tenantId/devices/adopt': typeof AuthenticatedTenantsTenantIdDevicesAdoptRoute
|
||||
'/tenants/$tenantId/devices/scan': typeof AuthenticatedTenantsTenantIdDevicesScanRoute
|
||||
'/tenants/$tenantId/sites/$siteId': typeof AuthenticatedTenantsTenantIdSitesSiteIdRoute
|
||||
'/tenants/$tenantId/devices/': typeof AuthenticatedTenantsTenantIdDevicesIndexRoute
|
||||
'/tenants/$tenantId/sites/': typeof AuthenticatedTenantsTenantIdSitesIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
@@ -286,8 +316,10 @@ export interface FileRoutesByTo {
|
||||
'/setup': typeof AuthenticatedSetupRoute
|
||||
'/templates': typeof AuthenticatedTemplatesRoute
|
||||
'/topology': typeof AuthenticatedTopologyRoute
|
||||
'/traffic': typeof AuthenticatedTrafficRoute
|
||||
'/transparency': typeof AuthenticatedTransparencyRoute
|
||||
'/vpn': typeof AuthenticatedVpnRoute
|
||||
'/wireless': typeof AuthenticatedWirelessRoute
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||
'/tenants': typeof AuthenticatedTenantsIndexRoute
|
||||
@@ -297,7 +329,9 @@ export interface FileRoutesByTo {
|
||||
'/tenants/$tenantId/devices/add': typeof AuthenticatedTenantsTenantIdDevicesAddRoute
|
||||
'/tenants/$tenantId/devices/adopt': typeof AuthenticatedTenantsTenantIdDevicesAdoptRoute
|
||||
'/tenants/$tenantId/devices/scan': typeof AuthenticatedTenantsTenantIdDevicesScanRoute
|
||||
'/tenants/$tenantId/sites/$siteId': typeof AuthenticatedTenantsTenantIdSitesSiteIdRoute
|
||||
'/tenants/$tenantId/devices': typeof AuthenticatedTenantsTenantIdDevicesIndexRoute
|
||||
'/tenants/$tenantId/sites': typeof AuthenticatedTenantsTenantIdSitesIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -323,8 +357,10 @@ export interface FileRoutesById {
|
||||
'/_authenticated/setup': typeof AuthenticatedSetupRoute
|
||||
'/_authenticated/templates': typeof AuthenticatedTemplatesRoute
|
||||
'/_authenticated/topology': typeof AuthenticatedTopologyRoute
|
||||
'/_authenticated/traffic': typeof AuthenticatedTrafficRoute
|
||||
'/_authenticated/transparency': typeof AuthenticatedTransparencyRoute
|
||||
'/_authenticated/vpn': typeof AuthenticatedVpnRoute
|
||||
'/_authenticated/wireless': typeof AuthenticatedWirelessRoute
|
||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||
'/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
|
||||
'/_authenticated/tenants/': typeof AuthenticatedTenantsIndexRoute
|
||||
@@ -334,7 +370,9 @@ export interface FileRoutesById {
|
||||
'/_authenticated/tenants/$tenantId/devices/add': typeof AuthenticatedTenantsTenantIdDevicesAddRoute
|
||||
'/_authenticated/tenants/$tenantId/devices/adopt': typeof AuthenticatedTenantsTenantIdDevicesAdoptRoute
|
||||
'/_authenticated/tenants/$tenantId/devices/scan': typeof AuthenticatedTenantsTenantIdDevicesScanRoute
|
||||
'/_authenticated/tenants/$tenantId/sites/$siteId': typeof AuthenticatedTenantsTenantIdSitesSiteIdRoute
|
||||
'/_authenticated/tenants/$tenantId/devices/': typeof AuthenticatedTenantsTenantIdDevicesIndexRoute
|
||||
'/_authenticated/tenants/$tenantId/sites/': typeof AuthenticatedTenantsTenantIdSitesIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -361,8 +399,10 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/templates'
|
||||
| '/topology'
|
||||
| '/traffic'
|
||||
| '/transparency'
|
||||
| '/vpn'
|
||||
| '/wireless'
|
||||
| '/settings/api-keys'
|
||||
| '/tenants/'
|
||||
| '/tenants/$tenantId/users'
|
||||
@@ -371,7 +411,9 @@ export interface FileRouteTypes {
|
||||
| '/tenants/$tenantId/devices/add'
|
||||
| '/tenants/$tenantId/devices/adopt'
|
||||
| '/tenants/$tenantId/devices/scan'
|
||||
| '/tenants/$tenantId/sites/$siteId'
|
||||
| '/tenants/$tenantId/devices/'
|
||||
| '/tenants/$tenantId/sites/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/forgot-password'
|
||||
@@ -395,8 +437,10 @@ export interface FileRouteTypes {
|
||||
| '/setup'
|
||||
| '/templates'
|
||||
| '/topology'
|
||||
| '/traffic'
|
||||
| '/transparency'
|
||||
| '/vpn'
|
||||
| '/wireless'
|
||||
| '/'
|
||||
| '/settings/api-keys'
|
||||
| '/tenants'
|
||||
@@ -406,7 +450,9 @@ export interface FileRouteTypes {
|
||||
| '/tenants/$tenantId/devices/add'
|
||||
| '/tenants/$tenantId/devices/adopt'
|
||||
| '/tenants/$tenantId/devices/scan'
|
||||
| '/tenants/$tenantId/sites/$siteId'
|
||||
| '/tenants/$tenantId/devices'
|
||||
| '/tenants/$tenantId/sites'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
@@ -431,8 +477,10 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/setup'
|
||||
| '/_authenticated/templates'
|
||||
| '/_authenticated/topology'
|
||||
| '/_authenticated/traffic'
|
||||
| '/_authenticated/transparency'
|
||||
| '/_authenticated/vpn'
|
||||
| '/_authenticated/wireless'
|
||||
| '/_authenticated/'
|
||||
| '/_authenticated/settings/api-keys'
|
||||
| '/_authenticated/tenants/'
|
||||
@@ -442,7 +490,9 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/tenants/$tenantId/devices/add'
|
||||
| '/_authenticated/tenants/$tenantId/devices/adopt'
|
||||
| '/_authenticated/tenants/$tenantId/devices/scan'
|
||||
| '/_authenticated/tenants/$tenantId/sites/$siteId'
|
||||
| '/_authenticated/tenants/$tenantId/devices/'
|
||||
| '/_authenticated/tenants/$tenantId/sites/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -505,6 +555,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/wireless': {
|
||||
id: '/_authenticated/wireless'
|
||||
path: '/wireless'
|
||||
fullPath: '/wireless'
|
||||
preLoaderRoute: typeof AuthenticatedWirelessRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/vpn': {
|
||||
id: '/_authenticated/vpn'
|
||||
path: '/vpn'
|
||||
@@ -519,6 +576,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedTransparencyRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/traffic': {
|
||||
id: '/_authenticated/traffic'
|
||||
path: '/traffic'
|
||||
fullPath: '/traffic'
|
||||
preLoaderRoute: typeof AuthenticatedTrafficRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/topology': {
|
||||
id: '/_authenticated/topology'
|
||||
path: '/topology'
|
||||
@@ -659,6 +723,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedTenantsTenantIdUsersRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/tenants/$tenantId/sites/': {
|
||||
id: '/_authenticated/tenants/$tenantId/sites/'
|
||||
path: '/tenants/$tenantId/sites'
|
||||
fullPath: '/tenants/$tenantId/sites/'
|
||||
preLoaderRoute: typeof AuthenticatedTenantsTenantIdSitesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/tenants/$tenantId/devices/': {
|
||||
id: '/_authenticated/tenants/$tenantId/devices/'
|
||||
path: '/tenants/$tenantId/devices'
|
||||
@@ -666,6 +737,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedTenantsTenantIdDevicesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/tenants/$tenantId/sites/$siteId': {
|
||||
id: '/_authenticated/tenants/$tenantId/sites/$siteId'
|
||||
path: '/tenants/$tenantId/sites/$siteId'
|
||||
fullPath: '/tenants/$tenantId/sites/$siteId'
|
||||
preLoaderRoute: typeof AuthenticatedTenantsTenantIdSitesSiteIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/tenants/$tenantId/devices/scan': {
|
||||
id: '/_authenticated/tenants/$tenantId/devices/scan'
|
||||
path: '/tenants/$tenantId/devices/scan'
|
||||
@@ -727,8 +805,10 @@ interface AuthenticatedRouteChildren {
|
||||
AuthenticatedSetupRoute: typeof AuthenticatedSetupRoute
|
||||
AuthenticatedTemplatesRoute: typeof AuthenticatedTemplatesRoute
|
||||
AuthenticatedTopologyRoute: typeof AuthenticatedTopologyRoute
|
||||
AuthenticatedTrafficRoute: typeof AuthenticatedTrafficRoute
|
||||
AuthenticatedTransparencyRoute: typeof AuthenticatedTransparencyRoute
|
||||
AuthenticatedVpnRoute: typeof AuthenticatedVpnRoute
|
||||
AuthenticatedWirelessRoute: typeof AuthenticatedWirelessRoute
|
||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||
AuthenticatedTenantsIndexRoute: typeof AuthenticatedTenantsIndexRoute
|
||||
AuthenticatedTenantsTenantIdUsersRoute: typeof AuthenticatedTenantsTenantIdUsersRoute
|
||||
@@ -737,7 +817,9 @@ interface AuthenticatedRouteChildren {
|
||||
AuthenticatedTenantsTenantIdDevicesAddRoute: typeof AuthenticatedTenantsTenantIdDevicesAddRoute
|
||||
AuthenticatedTenantsTenantIdDevicesAdoptRoute: typeof AuthenticatedTenantsTenantIdDevicesAdoptRoute
|
||||
AuthenticatedTenantsTenantIdDevicesScanRoute: typeof AuthenticatedTenantsTenantIdDevicesScanRoute
|
||||
AuthenticatedTenantsTenantIdSitesSiteIdRoute: typeof AuthenticatedTenantsTenantIdSitesSiteIdRoute
|
||||
AuthenticatedTenantsTenantIdDevicesIndexRoute: typeof AuthenticatedTenantsTenantIdDevicesIndexRoute
|
||||
AuthenticatedTenantsTenantIdSitesIndexRoute: typeof AuthenticatedTenantsTenantIdSitesIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
@@ -757,8 +839,10 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedSetupRoute: AuthenticatedSetupRoute,
|
||||
AuthenticatedTemplatesRoute: AuthenticatedTemplatesRoute,
|
||||
AuthenticatedTopologyRoute: AuthenticatedTopologyRoute,
|
||||
AuthenticatedTrafficRoute: AuthenticatedTrafficRoute,
|
||||
AuthenticatedTransparencyRoute: AuthenticatedTransparencyRoute,
|
||||
AuthenticatedVpnRoute: AuthenticatedVpnRoute,
|
||||
AuthenticatedWirelessRoute: AuthenticatedWirelessRoute,
|
||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||
AuthenticatedTenantsIndexRoute: AuthenticatedTenantsIndexRoute,
|
||||
AuthenticatedTenantsTenantIdUsersRoute:
|
||||
@@ -773,8 +857,12 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedTenantsTenantIdDevicesAdoptRoute,
|
||||
AuthenticatedTenantsTenantIdDevicesScanRoute:
|
||||
AuthenticatedTenantsTenantIdDevicesScanRoute,
|
||||
AuthenticatedTenantsTenantIdSitesSiteIdRoute:
|
||||
AuthenticatedTenantsTenantIdSitesSiteIdRoute,
|
||||
AuthenticatedTenantsTenantIdDevicesIndexRoute:
|
||||
AuthenticatedTenantsTenantIdDevicesIndexRoute,
|
||||
AuthenticatedTenantsTenantIdSitesIndexRoute:
|
||||
AuthenticatedTenantsTenantIdSitesIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { sitesApi } from '@/lib/api'
|
||||
import { MapPin, ArrowLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/sites/$siteId')({
|
||||
component: SiteDetailPage,
|
||||
})
|
||||
|
||||
function SiteDetailPage() {
|
||||
const { tenantId, siteId } = Route.useParams()
|
||||
|
||||
const { data: site, isLoading } = useQuery({
|
||||
queryKey: ['sites', tenantId, siteId],
|
||||
queryFn: () => sitesApi.get(tenantId, siteId),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 w-48 bg-elevated rounded" />
|
||||
<div className="h-4 w-64 bg-elevated rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
return <div className="text-text-muted">Site not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with back link */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/tenants/$tenantId/sites" params={{ tenantId }}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" /> Sites
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Site info card */}
|
||||
<div className="rounded-lg border border-border bg-surface p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPin className="h-6 w-6 text-text-muted" />
|
||||
<h1 className="text-xl font-semibold">{site.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
{site.address && (
|
||||
<div>
|
||||
<span className="text-text-muted">Address:</span>
|
||||
<span className="ml-2 text-text-primary">{site.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{site.latitude != null && site.longitude != null && (
|
||||
<div>
|
||||
<span className="text-text-muted">Coordinates:</span>
|
||||
<span className="ml-2 text-text-primary">
|
||||
{site.latitude}, {site.longitude}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{site.elevation != null && (
|
||||
<div>
|
||||
<span className="text-text-muted">Elevation:</span>
|
||||
<span className="ml-2 text-text-primary">{site.elevation} m</span>
|
||||
</div>
|
||||
)}
|
||||
{site.notes && (
|
||||
<div className="col-span-full">
|
||||
<span className="text-text-muted">Notes:</span>
|
||||
<p className="mt-1 text-text-secondary">{site.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health stats summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="rounded-lg border border-border bg-surface p-4 text-center">
|
||||
<p className="text-2xl font-semibold">{site.device_count}</p>
|
||||
<p className="text-xs text-text-muted">Devices</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface p-4 text-center">
|
||||
<p className="text-2xl font-semibold">{site.online_count}</p>
|
||||
<p className="text-xs text-text-muted">Online</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface p-4 text-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-2xl font-semibold',
|
||||
site.online_percent >= 90
|
||||
? 'text-green-500'
|
||||
: site.online_percent >= 50
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500',
|
||||
)}
|
||||
>
|
||||
{site.online_percent.toFixed(0)}%
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">Online %</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface p-4 text-center">
|
||||
<p className={cn('text-2xl font-semibold', site.alert_count > 0 && 'text-red-500')}>
|
||||
{site.alert_count}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">Alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for device list -- Phase 14 will add full site dashboard */}
|
||||
<div className="rounded-lg border border-border bg-surface p-6">
|
||||
<h2 className="text-sm font-semibold mb-2">Assigned Devices</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{site.device_count > 0
|
||||
? `${site.device_count} device${site.device_count !== 1 ? 's' : ''} assigned to this site. Full device list coming in site dashboard.`
|
||||
: 'No devices assigned to this site yet. Assign devices from the fleet page.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { SiteTable } from '@/components/sites/SiteTable'
|
||||
import { SiteFormDialog } from '@/components/sites/SiteFormDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { SiteResponse } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/sites/')({
|
||||
component: SitesPage,
|
||||
})
|
||||
|
||||
function SitesPage() {
|
||||
const { tenantId } = Route.useParams()
|
||||
const [search, setSearch] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editSite, setEditSite] = useState<SiteResponse | null>(null)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Sites</h1>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> New Site
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search sites..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<SiteTable
|
||||
tenantId={tenantId}
|
||||
search={search}
|
||||
onCreateClick={() => setCreateOpen(true)}
|
||||
onEditClick={setEditSite}
|
||||
/>
|
||||
<SiteFormDialog open={createOpen} onOpenChange={setCreateOpen} tenantId={tenantId} />
|
||||
<SiteFormDialog
|
||||
open={!!editSite}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditSite(null)
|
||||
}}
|
||||
tenantId={tenantId}
|
||||
site={editSite}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user