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:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,449 @@
/**
* FirmwarePage — firmware dashboard with version groups, upgrade buttons,
* channel preferences, and upgrade progress tracking.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Building2,
Download,
ChevronDown,
ChevronRight,
CheckCircle,
AlertTriangle,
HelpCircle,
} from 'lucide-react'
import {
firmwareApi,
type FirmwareOverview,
type DeviceFirmwareStatus,
type FirmwareVersionGroup,
} from '@/lib/firmwareApi'
import { useUIStore } from '@/lib/store'
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import { cn } from '@/lib/utils'
import { TableSkeleton } from '@/components/ui/page-skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { UpgradeProgressModal } from './UpgradeProgressModal'
// ---------------------------------------------------------------------------
// Stat card
// ---------------------------------------------------------------------------
function StatCard({
label,
value,
color,
}: {
label: string
value: number
color: string
}) {
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className={cn('text-2xl font-bold', color)}>{value}</div>
<div className="text-xs text-text-muted mt-1">{label}</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Upgrade confirmation dialog
// ---------------------------------------------------------------------------
function UpgradeDialog({
open,
onClose,
tenantId,
devices,
targetVersion,
channel,
}: {
open: boolean
onClose: () => void
tenantId: string
devices: DeviceFirmwareStatus[]
targetVersion: string
channel: string
}) {
const queryClient = useQueryClient()
const [confirmed, setConfirmed] = useState(false)
const isMass = devices.length > 1
// Check for major version change
const hasMajorChange = devices.some((d) => {
if (!d.routeros_version) return false
const currentMajor = d.routeros_version.split('.')[0]
const targetMajor = targetVersion.split('.')[0]
return currentMajor !== targetMajor
})
const upgradeMutation = useMutation({
mutationFn: async () => {
if (isMass) {
return firmwareApi.startMassUpgrade(tenantId, {
device_ids: devices.map((d) => d.id),
target_version: targetVersion,
channel,
confirmed_major_upgrade: hasMajorChange ? confirmed : false,
})
} else {
return firmwareApi.startUpgrade(tenantId, {
device_id: devices[0].id,
target_version: targetVersion,
architecture: devices[0].architecture ?? '',
channel,
confirmed_major_upgrade: hasMajorChange ? confirmed : false,
})
}
},
onSuccess: (data) => {
void queryClient.invalidateQueries({ queryKey: ['firmware-overview'] })
void queryClient.invalidateQueries({ queryKey: ['upgrade-jobs'] })
toast({ title: isMass ? 'Mass upgrade started' : 'Upgrade started' })
onClose()
// Open progress modal
if ('rollout_group_id' in data) {
setProgressRolloutId(data.rollout_group_id)
} else if ('job_id' in data) {
setProgressJobId(data.job_id)
}
},
onError: () =>
toast({ title: 'Failed to start upgrade', variant: 'destructive' }),
})
const [progressJobId, setProgressJobId] = useState<string | null>(null)
const [progressRolloutId, setProgressRolloutId] = useState<string | null>(null)
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isMass ? 'Mass Firmware Upgrade' : 'Firmware Upgrade'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{isMass ? (
<p className="text-sm text-text-secondary">
Upgrade <span className="text-text-primary font-medium">{devices.length}</span>{' '}
devices to RouterOS{' '}
<span className="font-mono text-text-primary">{targetVersion}</span>.
</p>
) : (
<p className="text-sm text-text-secondary">
Upgrade{' '}
<span className="text-text-primary font-medium">{devices[0].hostname}</span>{' '}
from{' '}
<span className="font-mono text-text-secondary">
{devices[0].routeros_version ?? 'unknown'}
</span>{' '}
to{' '}
<span className="font-mono text-text-primary">{targetVersion}</span>.
</p>
)}
{isMass && (
<div className="rounded border border-info/40 bg-info/10 p-3 text-xs text-info">
Devices will be upgraded one at a time (sequential rollout). If any
device fails, the rollout will pause automatically.
</div>
)}
{hasMajorChange && (
<div className="rounded border border-error/40 bg-error/10 p-3 space-y-2">
<p className="text-xs text-error font-medium">
WARNING: Major version upgrade detected
</p>
<p className="text-xs text-error/80">
Upgrading across major versions (e.g., RouterOS 6 to 7) may cause
breaking changes. Ensure you have reviewed the MikroTik migration
guide before proceeding.
</p>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={confirmed}
onCheckedChange={(v) => setConfirmed(!!v)}
/>
<span className="text-error">I understand the risks</span>
</label>
</div>
)}
<div className="rounded border border-border bg-surface p-3 text-xs text-text-secondary">
A mandatory config backup will be taken before upgrading each device.
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
onClick={() => upgradeMutation.mutate()}
disabled={
upgradeMutation.isPending || (hasMajorChange && !confirmed)
}
>
{upgradeMutation.isPending
? 'Starting...'
: isMass
? `Upgrade ${devices.length} devices`
: 'Upgrade'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{progressJobId && (
<UpgradeProgressModal
open={!!progressJobId}
onClose={() => setProgressJobId(null)}
tenantId={tenantId}
jobId={progressJobId}
/>
)}
{progressRolloutId && (
<UpgradeProgressModal
open={!!progressRolloutId}
onClose={() => setProgressRolloutId(null)}
tenantId={tenantId}
rolloutGroupId={progressRolloutId}
/>
)}
</>
)
}
// ---------------------------------------------------------------------------
// Version group card
// ---------------------------------------------------------------------------
function VersionGroupCard({
group,
tenantId,
canUpgrade,
}: {
group: FirmwareVersionGroup
tenantId: string
canUpgrade: boolean
}) {
const [expanded, setExpanded] = useState(false)
const [upgradeTarget, setUpgradeTarget] = useState<DeviceFirmwareStatus[] | null>(null)
// Determine the target version for outdated devices
const firstOutdated = group.devices.find((d) => !d.is_up_to_date && d.latest_version)
const latestVersion = firstOutdated?.latest_version ?? ''
const channel = firstOutdated?.channel ?? 'stable'
return (
<>
<div className="rounded-lg border border-border bg-surface 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"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
<span className="font-mono text-sm text-text-primary">
{group.version === 'unknown' ? 'Unknown' : `v${group.version}`}
</span>
{group.is_latest ? (
<span className="text-[10px] bg-success/20 text-success border border-success/40 rounded px-1.5 py-0.5">
latest
</span>
) : group.version === 'unknown' ? (
<span className="text-[10px] bg-elevated text-text-muted border border-border rounded px-1.5 py-0.5">
unknown
</span>
) : (
<span className="text-[10px] bg-warning/20 text-warning border border-warning/40 rounded px-1.5 py-0.5">
outdated
</span>
)}
<span className="text-xs text-text-muted ml-auto">
{group.count} device{group.count !== 1 ? 's' : ''}
</span>
{canUpgrade && !group.is_latest && group.version !== 'unknown' && latestVersion && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs ml-2"
onClick={(e) => {
e.stopPropagation()
setUpgradeTarget(group.devices.filter((d) => !d.is_up_to_date))
}}
>
Upgrade All to {latestVersion}
</Button>
)}
</button>
{expanded && (
<div className="border-t border-border">
{/* Table header */}
<div className="flex items-center gap-3 px-4 py-1.5 text-[10px] text-text-muted font-medium uppercase">
<span className="flex-1">Hostname</span>
<span className="w-28">Model</span>
<span className="w-24">Architecture</span>
<span className="w-24">Serial</span>
<span className="w-20">Channel</span>
<span className="w-20">Status</span>
<span className="w-20" />
</div>
{group.devices.map((device) => (
<div
key={device.id}
className="flex items-center gap-3 px-4 py-2 border-t border-border/50 text-xs"
>
<span className="flex-1 text-text-secondary truncate">{device.hostname}</span>
<span className="w-28 text-text-muted truncate">
{device.model ?? '—'}
</span>
<span className="w-24 text-text-muted font-mono">
{device.architecture ?? '—'}
</span>
<span className="w-24 text-text-muted font-mono">
{device.serial_number || '—'}
</span>
<span className="w-20 text-text-muted">{device.channel}</span>
<span className="w-20">
{device.is_up_to_date ? (
<CheckCircle className="h-3.5 w-3.5 text-success" />
) : device.routeros_version ? (
<AlertTriangle className="h-3.5 w-3.5 text-warning" />
) : (
<HelpCircle className="h-3.5 w-3.5 text-text-muted" />
)}
</span>
<span className="w-20 text-right">
{canUpgrade && !device.is_up_to_date && device.latest_version && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2"
onClick={() => setUpgradeTarget([device])}
>
Upgrade
</Button>
)}
</span>
</div>
))}
</div>
)}
</div>
{upgradeTarget && latestVersion && (
<UpgradeDialog
open={!!upgradeTarget}
onClose={() => setUpgradeTarget(null)}
tenantId={tenantId}
devices={upgradeTarget}
targetVersion={latestVersion}
channel={channel}
/>
)}
</>
)
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export function FirmwarePage() {
const { user } = useAuth()
const { selectedTenantId } = useUIStore()
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
const { data: overview, isLoading } = useQuery({
queryKey: ['firmware-overview', tenantId],
queryFn: () => firmwareApi.getFirmwareOverview(tenantId),
enabled: !!tenantId,
refetchInterval: 60_000,
})
const summary = overview?.summary ?? { total: 0, up_to_date: 0, outdated: 0, unknown: 0 }
return (
<div className="space-y-4 max-w-4xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Download className="h-5 w-5 text-text-secondary" />
<h1 className="text-lg font-semibold">Firmware</h1>
</div>
</div>
{!tenantId ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Building2 className="h-10 w-10 text-text-muted mb-3" />
<p className="text-sm text-text-muted">
Select an organization from the header to view firmware status.
</p>
</div>
) : isLoading ? (
<TableSkeleton />
) : (
<>
{/* Summary stats */}
<div className="grid grid-cols-4 gap-3">
<StatCard label="Total Devices" value={summary.total} color="text-text-primary" />
<StatCard label="Up to Date" value={summary.up_to_date} color="text-success" />
<StatCard label="Outdated" value={summary.outdated} color="text-warning" />
<StatCard label="Unknown" value={summary.unknown} color="text-text-muted" />
</div>
{/* Version groups */}
<div>
<h2 className="text-sm font-medium text-text-secondary mb-3">Version Groups</h2>
{overview?.version_groups.length === 0 ? (
<EmptyState
icon={Download}
title="All firmware up to date"
description="All devices are running the latest firmware version."
/>
) : (
<div className="space-y-2">
{overview?.version_groups.map((group) => (
<VersionGroupCard
key={group.version}
group={group}
tenantId={tenantId}
canUpgrade={canWrite(user)}
/>
))}
</div>
)}
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,340 @@
/**
* UpgradeProgressModal — real-time upgrade progress tracking.
* Supports single-device and mass rollout views.
* Uses SSE firmware-progress events for live updates with polling as fallback.
*/
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Circle,
Clock,
Download,
Upload,
RefreshCw,
Search,
CheckCircle,
XCircle,
PauseCircle,
} from 'lucide-react'
import {
firmwareApi,
type FirmwareUpgradeJob,
} from '@/lib/firmwareApi'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<
string,
{ icon: React.FC<{ className?: string }>; label: string; color: string }
> = {
pending: { icon: Circle, label: 'Pending', color: 'text-text-muted' },
scheduled: { icon: Clock, label: 'Scheduled', color: 'text-info' },
downloading: { icon: Download, label: 'Downloading NPK', color: 'text-info' },
uploading: { icon: Upload, label: 'Uploading to device', color: 'text-info' },
rebooting: { icon: RefreshCw, label: 'Rebooting', color: 'text-warning' },
verifying: { icon: Search, label: 'Verifying version', color: 'text-warning' },
completed: { icon: CheckCircle, label: 'Completed', color: 'text-success' },
failed: { icon: XCircle, label: 'Failed', color: 'text-error' },
paused: { icon: PauseCircle, label: 'Paused', color: 'text-warning' },
}
const STATUS_STEPS = [
'pending',
'downloading',
'uploading',
'rebooting',
'verifying',
'completed',
]
function StatusStep({ step, currentStatus }: { step: string; currentStatus: string }) {
const config = STATUS_CONFIG[step] ?? STATUS_CONFIG.pending
const Icon = config.icon
const currentIndex = STATUS_STEPS.indexOf(currentStatus)
const stepIndex = STATUS_STEPS.indexOf(step)
const isActive = step === currentStatus
const isDone = currentStatus === 'completed' || (stepIndex < currentIndex && currentStatus !== 'failed')
const isFailed = currentStatus === 'failed' && isActive
return (
<div className="flex items-center gap-2">
<Icon
className={cn(
'h-4 w-4',
isDone ? 'text-success' : isActive ? config.color : isFailed ? 'text-error' : 'text-text-muted',
isActive && !isFailed && 'animate-pulse',
)}
/>
<span
className={cn(
'text-xs',
isDone ? 'text-success' : isActive ? 'text-text-primary' : 'text-text-muted',
)}
>
{config.label}
</span>
</div>
)
}
function SingleUpgradeProgress({
tenantId,
jobId,
}: {
tenantId: string
jobId: string
}) {
// ── SSE live state ────────────────────────────────────────────────────
const [sseStatus, setSSEStatus] = useState<string | null>(null)
const [sseMessage, setSSEMessage] = useState<string | null>(null)
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as {
job_id?: string
device_id?: string
stage: string
message?: string
}
if (detail.job_id === jobId) {
setSSEStatus(detail.stage)
setSSEMessage(detail.message ?? null)
}
}
window.addEventListener('firmware-progress', handler)
return () => window.removeEventListener('firmware-progress', handler)
}, [jobId])
// ── Polling fallback (slower interval since SSE provides live updates) ─
const { data: job } = useQuery({
queryKey: ['upgrade-job', tenantId, jobId],
queryFn: () => firmwareApi.getUpgradeJob(tenantId, jobId),
refetchInterval: (query) => {
const status = sseStatus ?? query.state.data?.status
if (status === 'completed' || status === 'failed') return false
return 15_000 // Slower poll as backup when SSE is active
},
})
if (!job) return <div className="text-sm text-text-muted py-4">Loading...</div>
// Use SSE status if available, fall back to polled status
const displayStatus = sseStatus ?? job.status
return (
<div className="space-y-4">
<div className="text-sm text-text-secondary">
{job.device_hostname ?? job.device_id.slice(0, 8)} upgrading to{' '}
<span className="text-text-primary font-mono">{job.target_version}</span>
</div>
<div className="space-y-2">
{STATUS_STEPS.map((step) => (
<StatusStep key={step} step={step} currentStatus={displayStatus} />
))}
</div>
{/* SSE live message */}
{sseMessage && (
<div className="text-xs text-info animate-pulse">
{sseMessage}
</div>
)}
{job.error_message && (
<div className="rounded border border-error/40 bg-error/10 p-3 text-xs text-error">
{job.error_message}
</div>
)}
{job.started_at && (
<div className="text-xs text-text-muted">
Started: {new Date(job.started_at).toLocaleString()}
{job.completed_at && (
<>
{' — '}Finished: {new Date(job.completed_at).toLocaleString()}
</>
)}
</div>
)}
</div>
)
}
function MassUpgradeProgress({
tenantId,
rolloutGroupId,
onResume,
onAbort,
}: {
tenantId: string
rolloutGroupId: string
onResume?: () => void
onAbort?: () => void
}) {
// ── SSE live message for current device ────────────────────────────────
const [sseMessage, setSSEMessage] = useState<string | null>(null)
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as {
job_id?: string
device_id?: string
stage: string
message?: string
}
// Any firmware progress event is relevant to mass upgrade
setSSEMessage(detail.message ?? `Stage: ${detail.stage}`)
}
window.addEventListener('firmware-progress', handler)
return () => window.removeEventListener('firmware-progress', handler)
}, [])
const { data: rollout } = useQuery({
queryKey: ['rollout-status', tenantId, rolloutGroupId],
queryFn: () => firmwareApi.getRolloutStatus(tenantId, rolloutGroupId),
refetchInterval: (query) => {
const data = query.state.data
if (!data) return 5_000
if (data.completed + data.failed >= data.total) return false
if (data.paused > 0) return false
return 15_000 // Slower poll as backup when SSE is active
},
})
if (!rollout) return <div className="text-sm text-text-muted py-4">Loading...</div>
const progressPct =
rollout.total > 0
? Math.round(((rollout.completed + rollout.failed) / rollout.total) * 100)
: 0
return (
<div className="space-y-4">
{/* Progress bar */}
<div>
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
<span>
{rollout.completed}/{rollout.total} devices
</span>
<span>{progressPct}%</span>
</div>
<div className="h-2 rounded-full bg-elevated overflow-hidden">
<div
className="h-full rounded-full bg-success transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{/* Summary */}
<div className="flex gap-4 text-xs">
<span className="text-success">Completed: {rollout.completed}</span>
{rollout.failed > 0 && <span className="text-error">Failed: {rollout.failed}</span>}
{rollout.paused > 0 && (
<span className="text-warning">Paused: {rollout.paused}</span>
)}
{rollout.pending > 0 && <span className="text-text-muted">Pending: {rollout.pending}</span>}
</div>
{rollout.current_device && (
<div className="text-xs text-info">
Currently upgrading: {rollout.current_device}
</div>
)}
{/* SSE live message */}
{sseMessage && (
<div className="text-xs text-info animate-pulse">
{sseMessage}
</div>
)}
{/* Device list */}
<div className="rounded-lg border border-border bg-surface 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
return (
<div
key={job.id}
className="flex items-center gap-2 px-3 py-1.5 border-b border-border/50 last:border-0 text-xs"
>
<Icon className={cn('h-3.5 w-3.5', config.color)} />
<span className="text-text-secondary flex-1">
{job.device_hostname ?? job.device_id.slice(0, 8)}
</span>
<span className={cn('text-[10px]', config.color)}>{config.label}</span>
</div>
)
})}
</div>
{/* Actions for paused rollout */}
{rollout.paused > 0 && (
<div className="flex gap-2">
{onResume && (
<Button size="sm" variant="outline" onClick={onResume}>
Resume Rollout
</Button>
)}
{onAbort && (
<Button size="sm" variant="destructive" onClick={onAbort}>
Abort Remaining
</Button>
)}
</div>
)}
</div>
)
}
export function UpgradeProgressModal({
open,
onClose,
tenantId,
jobId,
rolloutGroupId,
onResume,
onAbort,
}: {
open: boolean
onClose: () => void
tenantId: string
jobId?: string
rolloutGroupId?: string
onResume?: () => void
onAbort?: () => void
}) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{rolloutGroupId ? 'Mass Upgrade Progress' : 'Upgrade Progress'}
</DialogTitle>
</DialogHeader>
{jobId && <SingleUpgradeProgress tenantId={tenantId} jobId={jobId} />}
{rolloutGroupId && (
<MassUpgradeProgress
tenantId={tenantId}
rolloutGroupId={rolloutGroupId}
onResume={onResume}
onAbort={onAbort}
/>
)}
<div className="flex justify-end pt-2">
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
)
}