feat(sites): one install, many networks (v0.24.0-beta)

Closes #130. Multi-site, not multi-tenancy: a site is a named collection of
devices whose clients, events, topology and dashboards are viewed separately.
Sites separate what you see, not who you are — the admin/manage/read-only
roles are unchanged and every user sees every site. Per-site permissions
would have multiplied the work for a need nobody stated.

Two measurements decided the design.

Every device-derived table hangs off devices(id) — 25 of 50 tables, 35
foreign keys — so a single site_id column on devices scopes clients, events,
topology, backups and the rest transitively. No other table needed one.

The frontend has 162 useQuery call sites across 57 files. Threading a site
through each would be a large change that leaves a permanent trap: every
future query another chance to forget. The site is injected in the existing
axios interceptor instead, beside the auth token, so a new query is scoped by
default. Only 23 collection routes needed filtering; the 131 per-device
routes are already scoped by their device.

Existing installs are untouched. Migration creates one "Default Site", adopts
every device into it, and a single-site install never meets the concept. An
absent X-Site-Id header means unscoped, so existing API tokens and scripts
behave exactly as before.

Polling stays fleet-wide. Sites are a view over data already gathered, not a
change to how it is gathered — a device does not stop being monitored because
you are looking elsewhere. Rogue-AP detection scopes the scans but keeps the
"these are ours" BSSID set fleet-wide, or your own AP in another site reads
as an evil twin. Bulk actions do obey the selector: an operation that
silently spanned two customers is the blast radius the wave guards exist to
contain.

Four rounds of defects came out of testing the UI rather than the API, and
the first is the one worth recording. Switching sites called
queryClient.clear(), which empties the cache and reviews as correct. It
isn't: clear() destroys each query but a *mounted* observer keeps its own
reference and last result, so every rendered page went on showing the
previous site's data. The QueryClient is now keyed on the site, which
remounts the tree against an empty cache. Three tests pin the old behaviour
down so the shortcut cannot return.

The others: an unscoped fallback path behind the Connected Clients chart;
Config Health, TX retries and the activity feed reading fleet-wide; and
site_id added to the device list query but none of the other four that build
a device payload, so the Site field could not read its own value and appeared
to reject every change while the writes were landing. That last one is now
structural — DEVICE_BASE_COLUMNS is shared by all five, with a test that
fails when a device payload is hand-rolled, and which found two further
instances while being written.

Frontend tests were never run by CI despite vitest being configured and six
suites existing; they now run in both the workflow and ci-preflight.

Verified against a live four-device fleet: the migration left every figure
identical, an empty site returns zero from every read endpoint, moving a
device carries its history with it, and deleting a site with devices is
refused.

Minor bump rather than patch: sites change the shape of the product rather
than adding to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-09-07 12:02:00 -07:00
co-authored by Claude Opus 5
parent e47819e232
commit 7b994ad935
50 changed files with 2074 additions and 151 deletions
+4 -1
View File
@@ -3,6 +3,8 @@ import { useAuthStore } from './store/authStore';
import AppLayout from './components/layout/AppLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import SitesPage from './pages/SitesPage';
import SiteLanding from './components/SiteLanding';
import DevicesPage from './pages/DevicesPage';
import DeviceDetailPage from './pages/DeviceDetailPage';
import ClientsPage from './pages/ClientsPage';
@@ -48,7 +50,7 @@ export default function App() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route index element={<SiteLanding />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="devices" element={<DevicesPage />} />
<Route path="devices/:id" element={<DeviceDetailPage />} />
@@ -59,6 +61,7 @@ export default function App() {
<Route path="backups" element={<BackupsPage />} />
<Route path="firmware" element={<FirmwarePage />} />
<Route path="commands" element={<CommandsPage />} />
<Route path="sites" element={<SitesPage />} />
<Route path="settings" element={<SettingsPage />} />
{/* Legacy routes — consolidated in the v0.16.4 UI reorganization */}
<Route path="switches" element={<Navigate to="/devices?type=SW" replace />} />
+26
View File
@@ -0,0 +1,26 @@
import { Navigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { sitesApi } from '../services/api';
import { useSiteStore } from '../store/siteStore';
/**
* Where "/" goes (issue #130).
*
* One site means a single-network install, which must never meet the concept:
* straight to the dashboard. More than one, with no site chosen, means the
* all-sites overview is the honest landing page — a dashboard aggregating
* several customers' networks would be a number nobody asked for.
*/
export default function SiteLanding() {
const { currentSiteId } = useSiteStore();
const { data: sites, isLoading } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
staleTime: 60_000,
});
// Don't bounce through /sites while the list is still in flight.
if (isLoading) return null;
if (currentSiteId == null && (sites?.length ?? 0) > 1) return <Navigate to="/sites" replace />;
return <Navigate to="/dashboard" replace />;
}
@@ -5,9 +5,11 @@ import {
} from 'lucide-react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { devicesApi, settingsApi } from '../../services/api';
import { devicesApi, settingsApi, sitesApi } from '../../services/api';
import type { Device } from '../../types';
import { useCanWrite } from '../../hooks/useCanWrite';
import { geocodeAddress, OSM_TILE_URL, OSM_ATTRIBUTION } from '../../utils/geocode';
import { Network } from 'lucide-react';
interface Props {
device: Device;
@@ -20,22 +22,6 @@ interface LocationForm {
notes: string;
}
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
{ headers: { Accept: 'application/json' } }
);
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
}
} catch {
// ignore geocoding failures
}
return null;
}
function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | string; address: string }) {
const latN = Number(lat);
const lngN = Number(lng);
@@ -44,9 +30,7 @@ function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | s
useEffect(() => {
if (!containerRef.current) return;
const map = L.map(containerRef.current, { scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
L.tileLayer(OSM_TILE_URL, { attribution: OSM_ATTRIBUTION }).addTo(map);
L.circleMarker([latN, lngN], {
radius: 9, color: '#fff', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.9,
}).bindPopup(`<b>${address}</b>`).addTo(map);
@@ -87,6 +71,26 @@ export default function DeviceLocationSection({ device }: Props) {
staleTime: 300_000,
});
const mapsEnabled = settings?.['maps_enabled'] !== false;
// Which site this device belongs to (issue #130). Shown only when more than
// one site exists — on a single-network install it is noise.
const { data: sites = [] } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
staleTime: 60_000,
});
const showSitePicker = sites.length > 1;
const currentSite = sites.find((s) => s.id === device.site_id) ?? null;
const moveSite = useMutation({
mutationFn: (siteId: number) => sitesApi.assignDevices(siteId, [device.id]),
onSuccess: () => {
// The device may have just left the site being viewed, so every scoped
// figure on screen is now potentially wrong. Invalidate everything and
// let the active queries refetch.
queryClient.invalidateQueries();
},
});
const [editing, setEditing] = useState(false);
const [geocoding, setGeocoding] = useState(false);
const [saveError, setSaveError] = useState('');
@@ -173,6 +177,28 @@ export default function DeviceLocationSection({ device }: Props) {
{editing ? (
<div className="space-y-4">
{showSitePicker && (
<div>
<label className="label flex items-center gap-1.5">
<Network className="w-3.5 h-3.5 text-blue-500" /> Site
</label>
<select
className="input"
value={device.site_id ?? ''}
disabled={moveSite.isPending}
onChange={(e) => {
const id = parseInt(e.target.value, 10);
if (Number.isInteger(id)) moveSite.mutate(id);
}}
>
{device.site_id == null && <option value="">Unassigned</option>}
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<p className="text-xs text-gray-400 mt-1">
Moving a device takes its clients, events and history with it. Applies immediately.
</p>
</div>
)}
<div>
<label className="label flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-blue-500" /> Physical Location
@@ -247,6 +273,11 @@ export default function DeviceLocationSection({ device }: Props) {
</div>
) : (
<div className="space-y-3 text-sm">
{showSitePicker && (
<InfoRow icon={<Network className="w-3.5 h-3.5 text-blue-500" />} label="Site"
value={currentSite?.name}
emptyText="Unassigned" />
)}
<InfoRow icon={<MapPin className="w-3.5 h-3.5 text-blue-500" />} label="Location"
value={device.location_address}
emptyText="No location set" />
@@ -8,6 +8,7 @@ import {
LayoutGrid, TerminalSquare,
} from 'lucide-react';
import clsx from 'clsx';
import SiteSelector from './SiteSelector';
import { APP_VERSION } from '../../version';
import { devicesApi } from '../../services/api';
@@ -243,6 +244,9 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
</button>
</div>
{/* Site selector — under the brand block, deliberately separate from it */}
<SiteSelector isCollapsed={isCollapsed} onNavigate={handleNavClick} />
{/* Navigation */}
<nav className="flex-1 px-[14px] py-3 space-y-[1px] overflow-y-auto">
{/* ── Monitor ── */}
@@ -0,0 +1,251 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { Building2, ChevronDown, Check, Plus, Pencil, Globe, X } from 'lucide-react';
import clsx from 'clsx';
import { sitesApi } from '../../services/api';
import { useSiteStore, type Site } from '../../store/siteStore';
import { useCanWrite } from '../../hooks/useCanWrite';
interface Props {
isCollapsed: boolean;
onNavigate?: () => void;
}
/**
* Site picker, mounted under the brand block (issue #130).
*
* Hidden entirely when only one site exists: a single-network install should
* never have to learn what a site is.
*/
export default function SiteSelector({ isCollapsed, onNavigate }: Props) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const canWrite = useCanWrite();
const { currentSiteId, setCurrentSite } = useSiteStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<Site | null>(null);
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [error, setError] = useState('');
const wrapRef = useRef<HTMLDivElement>(null);
const { data: sites = [] } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
staleTime: 60_000,
});
function closeAll() {
setOpen(false);
setCreating(false);
setRenaming(null);
setError('');
}
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) closeAll();
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const saveMutation = useMutation({
mutationFn: async () => {
const trimmed = name.trim();
if (!trimmed) throw new Error('Site name is required');
const payload = { name: trimmed, address: address.trim() };
if (renaming) return sitesApi.update(renaming.id, payload).then((r) => r.data);
return sitesApi.create(payload).then((r) => r.data);
},
onSuccess: (site) => {
queryClient.invalidateQueries({ queryKey: ['sites'] });
// A newly created site is empty, so switching to it immediately is the
// useful move: the next thing you do is add devices to it.
if (!renaming) switchTo(site.id);
closeAll();
},
onError: (e: unknown) => {
const msg = (e as { response?: { data?: { error?: string } }; message?: string });
setError(msg.response?.data?.error ?? msg.message ?? 'Could not save site');
},
});
/**
* Changing the site changes the QueryClient's key in main.tsx, which remounts
* the tree against an empty cache. Nothing to clear here -- and clearing was
* in fact the bug: queryClient.clear() drops queries but leaves mounted
* observers holding their last result, so the pages never updated.
*/
function switchTo(id: number | null) {
setCurrentSite(id);
closeAll();
onNavigate?.();
}
function openAllSites() {
closeAll();
onNavigate?.();
// Navigate first: setting the site remounts the tree, and the route must
// already be /sites when it comes back.
navigate('/sites');
setCurrentSite(null);
}
// Nothing to show until the site list arrives.
if (sites.length === 0) return null;
const current = currentSiteId == null ? null : sites.find((s) => s.id === currentSiteId) ?? null;
// With one site there is nothing to choose between, so "all sites" and "that
// site" describe the same set. Name it rather than saying "All sites", so a
// single-network install sees "Default Site" and not a concept it never asked
// for. Deliberately a display rule and not a state change: selecting it for
// real would rekey the QueryClient and remount the app on every page load.
const soleSite = sites.length === 1 ? sites[0] : null;
const shown = current ?? soleSite;
const label = shown?.name ?? 'All sites';
return (
<div ref={wrapRef} className="relative px-[14px] pt-3">
<button
onClick={() => (open ? closeAll() : setOpen(true))}
className={clsx(
'w-full flex items-center gap-2 rounded-lg px-2 py-2 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5',
isCollapsed && 'md:justify-center md:px-0'
)}
style={{ border: '1px solid var(--line)' }}
title={label}
>
{shown
? <Building2 className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
: <Globe className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />}
<span
className={clsx('flex-1 text-left text-[12.5px] font-medium truncate', isCollapsed && 'md:hidden')}
style={{ color: 'var(--ink)' }}
>
{label}
</span>
<ChevronDown
className={clsx('w-3.5 h-3.5 flex-shrink-0 transition-transform', open && 'rotate-180', isCollapsed && 'md:hidden')}
style={{ color: 'var(--ink-4)' }}
/>
</button>
{open && (
<div
className="absolute left-[14px] right-[14px] mt-1 z-50 rounded-lg shadow-lg overflow-hidden"
style={{ background: 'var(--surface)', border: '1px solid var(--line)', minWidth: 200 }}
>
{creating || renaming ? (
<div className="p-3 space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-wide" style={{ color: 'var(--ink-4)' }}>
{renaming ? 'Rename site' : 'New site'}
</div>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveMutation.mutate(); if (e.key === 'Escape') closeAll(); }}
placeholder="Site name"
className="w-full px-2 py-1.5 text-[12.5px] rounded border bg-transparent"
style={{ borderColor: 'var(--line)', color: 'var(--ink)' }}
/>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveMutation.mutate(); if (e.key === 'Escape') closeAll(); }}
placeholder="Address (optional)"
className="w-full px-2 py-1.5 text-[12.5px] rounded border bg-transparent"
style={{ borderColor: 'var(--line)', color: 'var(--ink)' }}
/>
{error && <div className="text-[11px] text-red-500">{error}</div>}
<div className="flex gap-2">
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="flex-1 px-2 py-1.5 text-[12px] rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
onClick={closeAll}
className="px-2 py-1.5 text-[12px] rounded"
style={{ color: 'var(--ink-3)', border: '1px solid var(--line)' }}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-[10.5px] leading-snug" style={{ color: 'var(--ink-4)' }}>
The address is geocoded for the all-sites map. Devices are moved between
sites from the device list.
</p>
</div>
) : (
<div className="py-1 max-h-[320px] overflow-y-auto">
{sites.length > 1 && (
<>
<button
onClick={openAllSites}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12.5px] hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--ink)' }}
>
<Globe className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
<span className="flex-1 text-left">All sites</span>
{currentSiteId == null && <Check className="w-3.5 h-3.5 text-blue-500" />}
</button>
<div className="my-1 h-px" style={{ background: 'var(--line)' }} />
</>
)}
{sites.map((s) => (
<div key={s.id} className="group flex items-center hover:bg-black/5 dark:hover:bg-white/5">
<button
onClick={() => switchTo(s.id)}
className="flex-1 flex items-center gap-2 px-3 py-1.5 text-[12.5px] min-w-0"
style={{ color: 'var(--ink)' }}
>
<Building2 className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--ink-3)' }} />
<span className="flex-1 text-left truncate">{s.name}</span>
<span className="text-[10.5px] flex-shrink-0" style={{ color: 'var(--ink-4)' }}>
{s.device_count}
</span>
{shown?.id === s.id && <Check className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />}
</button>
{canWrite && (
<button
onClick={() => { setRenaming(s); setName(s.name); setAddress(s.address ?? ''); setError(''); }}
className="px-2 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ color: 'var(--ink-4)' }}
title={`Rename ${s.name}`}
>
<Pencil className="w-3 h-3" />
</button>
)}
</div>
))}
{canWrite && (
<>
<div className="my-1 h-px" style={{ background: 'var(--line)' }} />
<button
onClick={() => { setCreating(true); setName(''); setAddress(''); setError(''); }}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12.5px] hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: 'var(--ink-3)' }}
>
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
<span className="flex-1 text-left">New site</span>
</button>
</>
)}
</div>
)}
</div>
)}
</div>
);
}
+43 -10
View File
@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { useSiteStore } from './store/siteStore';
import './index.css';
// Initialize theme from storage immediately
@@ -21,20 +22,52 @@ if (storedTheme === 'dark') {
document.documentElement.classList.add('dark');
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
},
});
});
}
/**
* Scopes the whole React Query cache to the active site (issue #130).
*
* The obvious move -- queryClient.clear() on switch -- does not work, and the
* reason is worth recording. clear() destroys each Query and drops it from the
* cache, but a *mounted* observer keeps its own reference and last result, and
* nothing re-subscribes it. Every already-rendered page therefore went on
* displaying the previous site's data.
*
* Keying the provider on the site is what actually holds: a changed key
* unmounts the entire subtree and remounts it against a brand-new, empty cache,
* so every query refetches with the new X-Site-Id header. There is no path by
* which one site's data can survive into another's view.
*
* The cost is a full remount on switch. That is the honest price of changing
* context, and it only happens when the operator deliberately switches.
*/
function SiteScopedQueryProvider({ children }: { children: React.ReactNode }) {
const siteId = useSiteStore((s) => s.currentSiteId);
const key = siteId == null ? 'all' : String(siteId);
// A new cache per site; `key` is the dependency on purpose.
// eslint-disable-next-line react-hooks/exhaustive-deps
const client = React.useMemo(() => makeQueryClient(), [key]);
return (
<QueryClientProvider key={key} client={client}>
{children}
</QueryClientProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<SiteScopedQueryProvider>
<App />
</QueryClientProvider>
</SiteScopedQueryProvider>
</React.StrictMode>
);
+10 -6
View File
@@ -20,6 +20,7 @@ import { useQueryClient, useMutation } from '@tanstack/react-query';
import { format, formatDistanceToNow } from 'date-fns';
import type { Device, DeviceEvent } from '../types';
import clsx from 'clsx';
import { fleetStatus } from '../utils/fleetStatus';
// ─── Primitives ───────────────────────────────────────────────────────────────
@@ -167,7 +168,8 @@ function HealthBar({
wirelessCount: number;
clientSparkline: number[];
}) {
const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0;
const status = fleetStatus(summary?.devices);
const allOnline = status.kind === 'healthy';
const reachFraction = summary?.devices.total
? (summary.devices.online / summary.devices.total)
: 1;
@@ -182,13 +184,14 @@ function HealthBar({
style={{ background: 'var(--accent-soft)' }}
>
<StatusDot
color={allOnline ? 'var(--accent)' : 'var(--warn)'}
color={status.tone === 'good' ? 'var(--accent)'
: status.tone === 'warn' ? 'var(--warn)' : 'var(--ink-4)'}
glow size={10}
/>
</div>
<div>
<div className="text-[14px] font-semibold" style={{ color: 'var(--ink)' }}>
{allOnline ? 'All systems nominal' : `${summary?.devices.offline ?? 0} device(s) unreachable`}
{status.headline}
</div>
<div className="text-[12px]" style={{ color: 'var(--ink-3)' }}>
{summary?.devices.online ?? 0} of {summary?.devices.total ?? 0} reachable
@@ -594,9 +597,10 @@ function OperationsView({
}) {
const qc = useQueryClient();
const canWrite = useCanWrite();
const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0;
const statusText = allOnline ? "Everything's running." : `${summary?.devices.offline} device${summary?.devices.offline !== 1 ? 's' : ''} unreachable.`;
const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)';
const status = fleetStatus(summary?.devices);
const statusText = status.sentence;
const statusColor = status.tone === 'good' ? 'var(--accent)'
: status.tone === 'warn' ? 'var(--warn)' : 'var(--ink-4)';
const deviceList = devices as Device[];
const onlineDevices = deviceList.filter(d => d.status === 'online');
+292
View File
@@ -0,0 +1,292 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { Building2, MapPin, Plus, Pencil, Trash2, Check, X, AlertCircle, RefreshCw } from 'lucide-react';
import { sitesApi, settingsApi } from '../services/api';
import { useSiteStore, type Site } from '../store/siteStore';
import { useCanWrite } from '../hooks/useCanWrite';
import { geocodeAddress, OSM_TILE_URL, OSM_ATTRIBUTION } from '../utils/geocode';
/** World map with one pin per site that has coordinates. */
function SitesMap({ sites, onSelect }: { sites: Site[]; onSelect: (id: number) => void }) {
const containerRef = useRef<HTMLDivElement>(null);
const pinned = sites.filter((s) => s.location_lat != null && s.location_lng != null);
useEffect(() => {
if (!containerRef.current) return;
const map = L.map(containerRef.current, { scrollWheelZoom: false, worldCopyJump: true });
L.tileLayer(OSM_TILE_URL, { attribution: OSM_ATTRIBUTION }).addTo(map);
const points: [number, number][] = [];
for (const s of pinned) {
const lat = Number(s.location_lat);
const lng = Number(s.location_lng);
points.push([lat, lng]);
const marker = L.circleMarker([lat, lng], {
radius: 10, color: '#fff', weight: 2,
fillColor: s.online_count > 0 ? '#3b82f6' : '#94a3b8',
fillOpacity: 0.9,
}).addTo(map);
marker.bindPopup(
`<b>${s.name}</b><br/>${s.address ?? ''}<br/>` +
`${s.device_count} device${s.device_count === 1 ? '' : 's'}` +
`${s.device_count ? ` &middot; ${s.online_count} online` : ''}`
);
marker.on('click', () => onSelect(s.id));
}
// Fit to the sites we have; fall back to a whole-world view when none are
// geocoded yet, so the map is never a blank grey box.
if (points.length === 1) map.setView(points[0], 11);
else if (points.length > 1) map.fitBounds(L.latLngBounds(points).pad(0.25));
else map.setView([20, 0], 2);
return () => { map.remove(); };
}, [JSON.stringify(pinned.map((s) => [s.id, s.location_lat, s.location_lng, s.online_count])), onSelect]);
return (
<div className="card overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-slate-700">
<MapPin className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex-1">Site locations</h3>
<span className="text-xs text-gray-400">
{pinned.length} of {sites.length} mapped
</span>
</div>
<div ref={containerRef} style={{ height: 420, isolation: 'isolate' }} />
</div>
);
}
export default function SitesPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const canWrite = useCanWrite();
const { setCurrentSite } = useSiteStore();
const [editing, setEditing] = useState<Site | null>(null);
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const { data: sites = [], isLoading } = useQuery({
queryKey: ['sites'],
queryFn: () => sitesApi.list().then((r) => r.data),
});
// Geocoding and tiles are third-party requests that disclose where your
// hardware is. Same off-switch as device locations (issue #106).
const { data: settings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => settingsApi.get().then((r) => r.data),
staleTime: 300_000,
});
const mapsEnabled = settings?.['maps_enabled'] !== false;
function openSite(id: number) {
// Navigate before switching: setting the site remounts the tree (see
// SiteScopedQueryProvider in main.tsx), so the route must be settled first.
navigate('/dashboard');
setCurrentSite(id);
}
async function save() {
const trimmed = name.trim();
if (!trimmed) { setError('Site name is required'); return; }
setBusy(true);
setError('');
try {
const addr = address.trim();
// Only geocode when maps are enabled and the address actually changed —
// Nominatim is a courtesy service, not something to hammer on every save.
let coords: { lat: number; lng: number } | null = null;
const addressChanged = addr && addr !== (editing?.address ?? '');
if (mapsEnabled && addressChanged) coords = await geocodeAddress(addr);
const payload = {
name: trimmed,
address: addr,
...(addressChanged
? { location_lat: coords?.lat ?? null, location_lng: coords?.lng ?? null }
: {}),
};
if (editing) await sitesApi.update(editing.id, payload);
else await sitesApi.create(payload);
await queryClient.invalidateQueries({ queryKey: ['sites'] });
cancel();
} catch (e) {
const err = e as { response?: { data?: { error?: string } }; message?: string };
setError(err.response?.data?.error ?? err.message ?? 'Could not save site');
} finally {
setBusy(false);
}
}
const removeMutation = useMutation({
mutationFn: (id: number) => sitesApi.remove(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sites'] }),
onError: (e: unknown) => {
const err = e as { response?: { data?: { error?: string } } };
setError(err.response?.data?.error ?? 'Could not delete site');
},
});
function cancel() {
setEditing(null); setAdding(false); setName(''); setAddress(''); setError('');
}
function startEdit(s: Site) {
setEditing(s); setAdding(false); setName(s.name); setAddress(s.address ?? ''); setError('');
}
const totalDevices = sites.reduce((n, s) => n + s.device_count, 0);
const totalOnline = sites.reduce((n, s) => n + s.online_count, 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">All sites</h1>
<p className="text-sm text-gray-500 dark:text-slate-400">
{sites.length} site{sites.length === 1 ? '' : 's'} · {totalDevices} device
{totalDevices === 1 ? '' : 's'} · {totalOnline} online
</p>
</div>
{canWrite && !adding && !editing && (
<button
onClick={() => { setAdding(true); setName(''); setAddress(''); setError(''); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-500"
>
<Plus className="w-4 h-4" /> New site
</button>
)}
</div>
{error && (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/40 text-sm text-red-700 dark:text-red-300">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button onClick={() => setError('')}><X className="w-4 h-4" /></button>
</div>
)}
{(adding || editing) && (
<div className="card p-4 space-y-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{editing ? `Rename “${editing.name}”` : 'New site'}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Site name"
className="input"
/>
<input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Address (optional)"
className="input"
/>
</div>
<p className="text-xs text-gray-400">
{mapsEnabled
? 'The address is geocoded so the site appears on the map above.'
: 'Maps are disabled in Settings, so the address is stored as text only.'}
</p>
<div className="flex gap-2">
<button
onClick={save}
disabled={busy}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
>
{busy ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{busy ? 'Saving…' : 'Save'}
</button>
<button onClick={cancel} className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-slate-600">
Cancel
</button>
</div>
</div>
)}
{mapsEnabled && sites.length > 0 && <SitesMap sites={sites} onSelect={openSite} />}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-slate-400 border-b border-gray-200 dark:border-slate-700">
<th className="px-4 py-2.5 font-medium">Site</th>
<th className="px-4 py-2.5 font-medium">Address</th>
<th className="px-4 py-2.5 font-medium text-right">Devices</th>
<th className="px-4 py-2.5 font-medium text-right">Online</th>
{canWrite && <th className="px-4 py-2.5 w-20" />}
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Loading…</td></tr>
)}
{!isLoading && sites.length === 0 && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">No sites yet.</td></tr>
)}
{sites.map((s) => (
<tr
key={s.id}
className="border-b border-gray-100 dark:border-slate-800 last:border-0 hover:bg-gray-50 dark:hover:bg-slate-800/50 cursor-pointer"
onClick={() => openSite(s.id)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-gray-400 flex-shrink-0" />
<span className="font-medium text-gray-900 dark:text-white">{s.name}</span>
{s.is_default && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">
default
</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
{s.address || <span className="text-gray-300 dark:text-slate-600">—</span>}
</td>
<td className="px-4 py-2.5 text-right text-gray-900 dark:text-white">{s.device_count}</td>
<td className="px-4 py-2.5 text-right">
<span className={s.online_count === s.device_count && s.device_count > 0
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-slate-400'}>
{s.online_count}
</span>
</td>
{canWrite && (
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
<button
onClick={() => startEdit(s)}
className="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-400"
title="Rename"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => removeMutation.mutate(s.id)}
className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-950/40 text-gray-400 hover:text-red-500"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+27
View File
@@ -1,5 +1,6 @@
import axios, { AxiosError } from 'axios';
import { useAuthStore } from '../store/authStore';
import { useSiteStore } from '../store/siteStore';
import type {
Device,
Interface,
@@ -32,6 +33,12 @@ const api = axios.create({
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
// Active site travels with every request (issue #130). Injecting it here
// rather than threading it through ~160 query keys means a new query is
// scoped correctly by default and cannot be forgotten. null is the all-sites
// view, which sends no header and so behaves exactly as before sites existed.
const siteId = useSiteStore.getState().currentSiteId;
if (siteId != null) config.headers['X-Site-Id'] = String(siteId);
return config;
});
@@ -1610,3 +1617,23 @@ export const configTemplatesApi = {
};
export default api;
// ─── Sites (issue #130) ───────────────────────────────────────────────────────
export interface SitePayload {
name?: string;
address?: string;
notes?: string;
location_lat?: number | null;
location_lng?: number | null;
}
export const sitesApi = {
list: () => api.get<import('../store/siteStore').Site[]>('/sites'),
create: (payload: SitePayload) =>
api.post<import('../store/siteStore').Site>('/sites', payload),
update: (id: number, payload: SitePayload) =>
api.put<import('../store/siteStore').Site>(`/sites/${id}`, payload),
remove: (id: number) => api.delete<{ ok: boolean }>(`/sites/${id}`),
assignDevices: (id: number, deviceIds: number[]) =>
api.post<{ ok: boolean; moved: number }>(`/sites/${id}/devices`, { deviceIds }),
};
+38
View File
@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface Site {
id: number;
name: string;
address: string | null;
location_lat: number | null;
location_lng: number | null;
notes: string | null;
is_default: boolean;
device_count: number;
online_count: number;
}
/** null = the all-sites view; a number = that site. */
export type SiteSelection = number | null;
interface SiteState {
currentSiteId: SiteSelection;
setCurrentSite: (id: SiteSelection) => void;
}
export const useSiteStore = create<SiteState>()(
persist(
(set) => ({
// Undefined until the site list loads. A brand-new install has exactly one
// site, and App resolves the selection to it, so single-site deployments
// never see the concept.
currentSiteId: null,
setCurrentSite: (id) => set({ currentSiteId: id }),
}),
{
name: 'mikrotik-site',
partialize: (state) => ({ currentSiteId: state.currentSiteId }),
}
)
);
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { QueryClient, QueryObserver } from '@tanstack/react-query';
/**
* Why site switching is done by rekeying the QueryClient rather than clearing it.
*
* The first implementation called queryClient.clear() on switch and looked
* correct in review: the cache really is emptied. But every already-rendered
* page went on showing the previous site's data, because a *mounted* observer
* keeps its own reference to the destroyed query and its last result, and
* nothing re-subscribes it.
*
* These tests pin that behaviour down so the shortcut cannot come back.
*/
function makeClient() {
return new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: false } },
});
}
/** Stands in for a page: subscribes and exposes what it would render. */
function mountObserver(client: QueryClient, fetcher: () => Promise<string>) {
const observer = new QueryObserver(client, { queryKey: ['devices'], queryFn: fetcher });
const unsubscribe = observer.subscribe(() => {});
return { observer, unsubscribe, current: () => observer.getCurrentResult().data };
}
const settle = () => new Promise((r) => setTimeout(r, 20));
describe('site switching and the query cache', () => {
it('clear() does NOT update a mounted observer — this was the bug', async () => {
const client = makeClient();
let site = 'site-1';
const { unsubscribe, current } = mountObserver(client, async () => `data-for-${site}`);
await settle();
expect(current()).toBe('data-for-site-1');
// Switch site the way the broken version did.
site = 'site-2';
client.clear();
await settle();
// The cache is empty, yet the mounted page still renders site 1's data.
expect(client.getQueryCache().getAll()).toHaveLength(0);
expect(current()).toBe('data-for-site-1');
unsubscribe();
});
it('a fresh QueryClient gives a mounted page the new site data', async () => {
let site = 'site-1';
const fetcher = async () => `data-for-${site}`;
const first = makeClient();
const a = mountObserver(first, fetcher);
await settle();
expect(a.current()).toBe('data-for-site-1');
// What the keyed provider does: the subtree remounts against a new client.
a.unsubscribe();
site = 'site-2';
const second = makeClient();
const b = mountObserver(second, fetcher);
await settle();
expect(b.current()).toBe('data-for-site-2');
// Nothing from the previous site survived into the new cache.
expect(second.getQueryCache().getAll()).toHaveLength(1);
b.unsubscribe();
});
it('invalidateQueries() does refetch a mounted observer', async () => {
// The in-place alternative, used where the tree must not remount (moving a
// device between sites from the device page).
const client = makeClient();
let site = 'site-1';
const { unsubscribe, current } = mountObserver(client, async () => `data-for-${site}`);
await settle();
expect(current()).toBe('data-for-site-1');
site = 'site-2';
await client.invalidateQueries();
await settle();
expect(current()).toBe('data-for-site-2');
unsubscribe();
});
});
+1
View File
@@ -64,6 +64,7 @@ export interface Device {
status: DeviceStatus;
last_seen?: string;
notes?: string;
site_id?: number | null;
location_address?: string;
location_lat?: number;
location_lng?: number;
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { fleetStatus } from './fleetStatus';
describe('fleetStatus', () => {
// The reported bug: switching site remounts with no summary, and the header
// interpolated it straight into the string.
it('reports loading rather than "undefined devices unreachable"', () => {
for (const absent of [undefined, null]) {
const s = fleetStatus(absent);
expect(s.kind).toBe('loading');
expect(s.headline).not.toMatch(/undefined|NaN/);
expect(s.sentence).not.toMatch(/undefined|NaN/);
}
});
// Defaulting the missing summary to 0 would have swapped one wrong claim for
// another: an unheard-from fleet is not a healthy one.
it('does not claim health while loading', () => {
expect(fleetStatus(undefined).tone).toBe('neutral');
expect(fleetStatus(undefined).kind).not.toBe('healthy');
});
it('distinguishes an empty site from a healthy one', () => {
const empty = fleetStatus({ total: 0, online: 0, offline: 0 });
expect(empty.kind).toBe('empty');
expect(empty.sentence).toBe('No devices in this site yet.');
const healthy = fleetStatus({ total: 3, online: 3, offline: 0 });
expect(healthy.kind).toBe('healthy');
expect(healthy.tone).toBe('good');
});
it('counts and pluralises unreachable devices', () => {
expect(fleetStatus({ total: 4, online: 3, offline: 1 }).headline)
.toBe('1 device unreachable');
expect(fleetStatus({ total: 4, online: 2, offline: 2 }).sentence)
.toBe('2 devices unreachable.');
expect(fleetStatus({ total: 4, online: 2, offline: 2 }).tone).toBe('warn');
});
it('never emits undefined or NaN for any input', () => {
const cases = [
undefined, null,
{ total: 0, online: 0, offline: 0 },
{ total: 1, online: 0, offline: 1 },
{ total: 9, online: 9, offline: 0 },
];
for (const c of cases) {
const s = fleetStatus(c);
expect(`${s.headline} ${s.sentence}`).not.toMatch(/undefined|NaN|null/);
}
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Resolves the dashboard's headline device status.
*
* Exists because the summary can legitimately be *absent* rather than zero, and
* the two are not the same claim. Switching site remounts the tree against an
* empty cache (see SiteScopedQueryProvider), so there is always a moment with no
* summary — which rendered "undefined device(s) unreachable". Substituting 0
* would have been just as wrong in the other direction: reporting a healthy
* fleet we have not heard from yet.
*
* A site with no devices is a third distinct state, and one multi-site makes
* ordinary: "0 devices unreachable" is technically true and reads as nonsense.
*/
export interface FleetSummaryDevices {
total: number;
online: number;
offline: number;
}
export type FleetStatusKind = 'loading' | 'empty' | 'healthy' | 'degraded';
export interface FleetStatus {
kind: FleetStatusKind;
/** Compact label for the summary header. */
headline: string;
/** Sentence form for the operations hero. */
sentence: string;
tone: 'neutral' | 'good' | 'warn';
}
export function fleetStatus(devices?: FleetSummaryDevices | null): FleetStatus {
if (!devices) {
return { kind: 'loading', headline: 'Checking…', sentence: 'Checking…', tone: 'neutral' };
}
if (devices.total === 0) {
return {
kind: 'empty',
headline: 'No devices yet',
sentence: 'No devices in this site yet.',
tone: 'neutral',
};
}
if (devices.offline === 0) {
return {
kind: 'healthy',
headline: 'All systems nominal',
sentence: "Everything's running.",
tone: 'good',
};
}
const n = devices.offline;
const noun = `device${n === 1 ? '' : 's'}`;
return {
kind: 'degraded',
headline: `${n} ${noun} unreachable`,
sentence: `${n} ${noun} unreachable.`,
tone: 'warn',
};
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Address → coordinates via Nominatim.
*
* Extracted from DeviceLocationSection so device locations and site locations
* share one implementation (issue #130). Callers must check the `maps_enabled`
* setting before calling: geocoding is a third-party request that discloses
* where your hardware is, and issue #106 exists to let operators refuse it.
*/
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
{ headers: { Accept: 'application/json' } }
);
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
}
} catch {
// Geocoding is best-effort: a site without coordinates simply has no pin.
}
return null;
}
/** The tile source used by every map in the app, so attribution stays consistent. */
export const OSM_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.35 Beta';
export const APP_VERSION = 'v0.24.0 Beta';