feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
93
frontend/src/components/map/DeviceMarker.tsx
Normal file
93
frontend/src/components/map/DeviceMarker.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { FleetDevice } from '@/lib/api'
|
||||
import { formatUptime } from '@/lib/utils'
|
||||
|
||||
interface DeviceMarkerProps {
|
||||
device: FleetDevice
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#22c55e', // green-500
|
||||
offline: '#ef4444', // red-500
|
||||
unknown: '#eab308', // yellow-500
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
return STATUS_COLORS[status] ?? STATUS_COLORS.unknown
|
||||
}
|
||||
|
||||
function createMarkerIcon(status: string): L.DivIcon {
|
||||
const color = getStatusColor(status)
|
||||
return L.divIcon({
|
||||
className: '', // Remove default leaflet-div-icon styling
|
||||
html: `<div style="
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${color};
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
"></div>`,
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
popupAnchor: [0, -10],
|
||||
})
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
|
||||
export function DeviceMarker({ device, tenantId }: DeviceMarkerProps) {
|
||||
if (device.latitude == null || device.longitude == null) return null
|
||||
|
||||
const icon = createMarkerIcon(device.status)
|
||||
const statusColor = getStatusColor(device.status)
|
||||
const statusLabel = statusLabels[device.status] ?? device.status
|
||||
|
||||
// In super_admin "all" mode, tenantId may be empty — fall back to device's own tenant_id
|
||||
const resolvedTenantId = tenantId || device.tenant_id
|
||||
|
||||
return (
|
||||
<Marker position={[device.latitude, device.longitude]} icon={icon}>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] text-sm font-sans">
|
||||
<div className="font-semibold text-base mb-1">{device.hostname}</div>
|
||||
<div className="text-text-secondary space-y-0.5">
|
||||
<div>IP: {device.ip_address}</div>
|
||||
{device.model && <div>Model: {device.model}</div>}
|
||||
<div>Uptime: {formatUptime(device.uptime_seconds)}</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
Status:
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ background: statusColor }}
|
||||
/>
|
||||
<span>{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-2 pt-2 border-t border-border">
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices/$deviceId"
|
||||
params={{ tenantId: resolvedTenantId, deviceId: device.id }}
|
||||
className="text-info hover:text-accent text-xs font-medium"
|
||||
>
|
||||
View Details →
|
||||
</Link>
|
||||
<Link
|
||||
to="/config-editor"
|
||||
className="text-info hover:text-accent text-xs font-medium"
|
||||
>
|
||||
Config Editor →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
132
frontend/src/components/map/FleetMap.tsx
Normal file
132
frontend/src/components/map/FleetMap.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import type { FleetDevice } from '@/lib/api'
|
||||
import { DeviceMarker } from './DeviceMarker'
|
||||
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
interface FleetMapProps {
|
||||
devices: FleetDevice[]
|
||||
tenantId: string
|
||||
}
|
||||
|
||||
/** Default world view when no devices have coordinates. */
|
||||
const DEFAULT_CENTER: [number, number] = [20, 0]
|
||||
const DEFAULT_ZOOM = 2
|
||||
|
||||
/**
|
||||
* Inner component that auto-fits the map to device bounds
|
||||
* whenever the device list changes.
|
||||
*/
|
||||
function AutoFitBounds({ devices }: { devices: FleetDevice[] }) {
|
||||
const map = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
if (devices.length === 0) {
|
||||
map.setView(DEFAULT_CENTER, DEFAULT_ZOOM)
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(
|
||||
devices.map((d) => [d.latitude!, d.longitude!] as [number, number]),
|
||||
)
|
||||
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 })
|
||||
}, [devices, map])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom cluster icon factory.
|
||||
* - All online: green cluster
|
||||
* - Any offline: red cluster
|
||||
* - Mixed: yellow/orange cluster
|
||||
*/
|
||||
function createClusterIcon(cluster: L.MarkerCluster): L.DivIcon {
|
||||
const childMarkers = cluster.getAllChildMarkers()
|
||||
const count = childMarkers.length
|
||||
|
||||
// Determine aggregate status by inspecting marker HTML color
|
||||
let hasOffline = false
|
||||
let hasOnline = false
|
||||
|
||||
for (const marker of childMarkers) {
|
||||
const icon = marker.getIcon() as L.DivIcon
|
||||
const html = (icon.options.html as string) ?? ''
|
||||
if (html.includes('#ef4444')) {
|
||||
hasOffline = true
|
||||
} else if (html.includes('#22c55e')) {
|
||||
hasOnline = true
|
||||
}
|
||||
}
|
||||
|
||||
let bgColor: string
|
||||
if (hasOffline && hasOnline) {
|
||||
bgColor = '#f59e0b' // amber-500 — mixed
|
||||
} else if (hasOffline) {
|
||||
bgColor = '#ef4444' // red-500 — all offline
|
||||
} else {
|
||||
bgColor = '#22c55e' // green-500 — all online
|
||||
}
|
||||
|
||||
const size = count < 10 ? 34 : count < 100 ? 40 : 48
|
||||
|
||||
return L.divIcon({
|
||||
html: `<div style="
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
background: ${bgColor};
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: ${count < 100 ? 13 : 11}px;
|
||||
font-family: system-ui, sans-serif;
|
||||
">${count}</div>`,
|
||||
className: '',
|
||||
iconSize: L.point(size, size),
|
||||
iconAnchor: L.point(size / 2, size / 2),
|
||||
})
|
||||
}
|
||||
|
||||
export function FleetMap({ devices, tenantId }: FleetMapProps) {
|
||||
// Filter to only devices that have coordinates
|
||||
const mappedDevices = useMemo(
|
||||
() => devices.filter((d) => d.latitude != null && d.longitude != null),
|
||||
[devices],
|
||||
)
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={DEFAULT_CENTER}
|
||||
zoom={DEFAULT_ZOOM}
|
||||
className="h-full w-full"
|
||||
scrollWheelZoom
|
||||
style={{ background: 'hsl(var(--background))' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<AutoFitBounds devices={mappedDevices} />
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
iconCreateFunction={createClusterIcon}
|
||||
maxClusterRadius={50}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
>
|
||||
{mappedDevices.map((device) => (
|
||||
<DeviceMarker key={device.id} device={device} tenantId={tenantId} />
|
||||
))}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
161
frontend/src/components/map/MapPage.tsx
Normal file
161
frontend/src/components/map/MapPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 { FleetMap } from './FleetMap'
|
||||
|
||||
export function MapPage() {
|
||||
const { user } = useAuth()
|
||||
const superAdmin = isSuperAdmin(user)
|
||||
const [selectedTenant, setSelectedTenant] = useState<string>('all')
|
||||
|
||||
// Fetch devices -- super_admin gets cross-tenant, others get their own tenant
|
||||
const {
|
||||
data: devices,
|
||||
isLoading: devicesLoading,
|
||||
error: devicesError,
|
||||
} = useQuery({
|
||||
queryKey: ['fleet-map', superAdmin ? 'all' : user?.tenant_id],
|
||||
queryFn: () =>
|
||||
superAdmin
|
||||
? metricsApi.fleetSummaryAll()
|
||||
: metricsApi.fleetSummary(user!.tenant_id!),
|
||||
enabled: !!user && (superAdmin || !!user.tenant_id),
|
||||
})
|
||||
|
||||
// Fetch tenant list for super_admin filter dropdown
|
||||
const { data: tenants } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: tenantsApi.list,
|
||||
enabled: superAdmin,
|
||||
})
|
||||
|
||||
// Filter devices by selected tenant
|
||||
const filteredDevices = useMemo(() => {
|
||||
if (!devices) return []
|
||||
if (selectedTenant === 'all') return devices
|
||||
return devices.filter((d) => d.tenant_id === selectedTenant)
|
||||
}, [devices, selectedTenant])
|
||||
|
||||
// Count mapped vs total
|
||||
const totalDevices = filteredDevices.length
|
||||
const mappedDevices = filteredDevices.filter(
|
||||
(d) => d.latitude != null && d.longitude != null,
|
||||
).length
|
||||
|
||||
// Determine effective tenantId for links in markers
|
||||
const effectiveTenantId = useMemo(() => {
|
||||
if (!superAdmin) return user?.tenant_id ?? ''
|
||||
if (selectedTenant !== 'all') return selectedTenant
|
||||
// For "all" view as super_admin, we pass the device's own tenant_id from the FleetDevice record
|
||||
// The FleetMap component handles this per-device
|
||||
return ''
|
||||
}, [superAdmin, selectedTenant, user])
|
||||
|
||||
if (devicesLoading) {
|
||||
return <Skeleton className="h-[calc(100vh-8rem)] w-full rounded-lg" />
|
||||
}
|
||||
|
||||
if (devicesError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-10 w-10 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-text-secondary text-sm">Failed to load fleet data</p>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
{devicesError instanceof Error ? devicesError.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-sidebar shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPin className="h-4 w-4 text-text-secondary" />
|
||||
<h1 className="text-sm font-medium text-text-primary">Fleet Map</h1>
|
||||
<span className="text-xs text-text-muted">
|
||||
{mappedDevices} of {totalDevices} device{totalDevices !== 1 ? 's' : ''} mapped
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{superAdmin && tenants && tenants.length > 0 && (
|
||||
<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"
|
||||
>
|
||||
<option value="all">All Organizations</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="flex-1 relative">
|
||||
{totalDevices === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-10 w-10 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-text-secondary text-sm">No devices found</p>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
Add devices with coordinates to see them on the map
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : mappedDevices === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-10 w-10 text-text-muted mx-auto mb-3" />
|
||||
<p className="text-text-secondary text-sm">No devices have coordinates</p>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
Edit devices and add latitude/longitude to place them on the map
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FleetMapWithTenantRouting
|
||||
devices={filteredDevices}
|
||||
effectiveTenantId={effectiveTenantId}
|
||||
superAdmin={superAdmin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that handles tenant routing for markers.
|
||||
* In super_admin "all" mode, each device marker uses its own tenant_id.
|
||||
* Otherwise, the effective tenant is used for all markers.
|
||||
*/
|
||||
function FleetMapWithTenantRouting({
|
||||
devices,
|
||||
effectiveTenantId,
|
||||
superAdmin,
|
||||
}: {
|
||||
devices: Array<{ latitude: number | null; longitude: number | null; tenant_id: string } & Record<string, unknown>>
|
||||
effectiveTenantId: string
|
||||
superAdmin: boolean
|
||||
}) {
|
||||
// For super_admin "all" view we need per-device tenant routing
|
||||
// FleetMap + DeviceMarker handle this by using device.tenant_id when tenantId is empty
|
||||
const tenantId = superAdmin && !effectiveTenantId ? '' : effectiveTenantId
|
||||
|
||||
return (
|
||||
<FleetMap
|
||||
devices={devices as unknown as import('@/lib/api').FleetDevice[]}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user