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,78 @@
import { useEffect, useRef, useState } from 'react'
/**
* Animates a number from 0 to a target value using requestAnimationFrame.
* Respects prefers-reduced-motion by displaying the value instantly.
*
* @param target - The target number to animate to
* @param duration - Animation duration in milliseconds (default 800)
* @param decimals - Number of decimal places to round to (default 0)
* @returns The current animated display value
*/
export function useAnimatedCounter(
target: number,
duration = 800,
decimals = 0,
): number {
const [value, setValue] = useState(0)
const frameRef = useRef<number>(0)
const startTimeRef = useRef<number>(0)
const startValueRef = useRef<number>(0)
useEffect(() => {
// Check reduced motion preference
const prefersReduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (prefersReduced) {
setValue(round(target, decimals))
return
}
// Cancel any in-progress animation
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
}
startValueRef.current = value
startTimeRef.current = 0
const animate = (timestamp: number) => {
if (!startTimeRef.current) {
startTimeRef.current = timestamp
}
const elapsed = timestamp - startTimeRef.current
const progress = Math.min(elapsed / duration, 1)
// Ease-out cubic: t => 1 - (1 - t)^3
const eased = 1 - Math.pow(1 - progress, 3)
const current =
startValueRef.current + (target - startValueRef.current) * eased
setValue(round(current, decimals))
if (progress < 1) {
frameRef.current = requestAnimationFrame(animate)
}
}
frameRef.current = requestAnimationFrame(animate)
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target, duration, decimals])
return value
}
function round(value: number, decimals: number): number {
if (decimals === 0) return Math.round(value)
const factor = Math.pow(10, decimals)
return Math.round(value * factor) / factor
}

View File

@@ -0,0 +1,161 @@
/**
* Reusable hooks for config panel browse/apply operations.
*
* useConfigBrowse — wraps configEditorApi.browse with TanStack Query caching
* useConfigPanel — manages pending changes, apply mode, and execution
*/
import { useState, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { configEditorApi } from '@/lib/configEditorApi'
import {
type ApplyMode,
type ConfigChange,
DEFAULT_APPLY_MODES,
} from '@/lib/configPanelTypes'
// ---------------------------------------------------------------------------
// useConfigBrowse
// ---------------------------------------------------------------------------
interface UseConfigBrowseOptions {
/** Only fetch when true (tab active guard) */
enabled?: boolean
/** Refetch interval in ms (0 = disabled) */
refetchInterval?: number
}
/**
* Wraps configEditorApi.browse with TanStack Query for automatic caching,
* refetching, and loading/error state management.
*/
export function useConfigBrowse(
tenantId: string,
deviceId: string,
path: string,
options: UseConfigBrowseOptions = {},
) {
const { enabled = true, refetchInterval = 0 } = options
const query = useQuery({
queryKey: ['config-browse', tenantId, deviceId, path],
queryFn: () => configEditorApi.browse(tenantId, deviceId, path),
enabled: enabled && !!tenantId && !!deviceId && !!path,
refetchInterval: refetchInterval || undefined,
staleTime: 30_000, // 30s — config doesn't change often
})
return {
entries: query.data?.entries ?? [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
}
}
// ---------------------------------------------------------------------------
// useConfigPanel
// ---------------------------------------------------------------------------
/**
* Manages the full config apply workflow: pending changes collection,
* apply mode toggling, and execution via the config editor API.
*/
export function useConfigPanel(
tenantId: string,
deviceId: string,
panelType: string,
) {
const queryClient = useQueryClient()
const [pendingChanges, setPendingChanges] = useState<ConfigChange[]>([])
const [applyMode, setApplyMode] = useState<ApplyMode>(
DEFAULT_APPLY_MODES[panelType] ?? 'quick',
)
const addChange = useCallback((change: ConfigChange) => {
setPendingChanges((prev) => [...prev, change])
}, [])
const removeChange = useCallback((index: number) => {
setPendingChanges((prev) => prev.filter((_, i) => i !== index))
}, [])
const clearChanges = useCallback(() => {
setPendingChanges([])
}, [])
const applyMutation = useMutation({
mutationFn: async (changes: ConfigChange[]) => {
// Both modes execute changes sequentially via the binary API.
// "Safe" mode defaults are set per-panel to trigger the review/confirm
// dialog before execution — the safety is in the UI review step.
for (const change of changes) {
let result
switch (change.operation) {
case 'add':
result = await configEditorApi.addEntry(
tenantId,
deviceId,
change.path,
change.properties,
)
break
case 'set':
result = await configEditorApi.setEntry(
tenantId,
deviceId,
change.path,
change.entryId,
change.properties,
)
break
case 'remove':
if (!change.entryId) {
throw new Error(`Remove operation requires entryId: ${change.description}`)
}
result = await configEditorApi.removeEntry(
tenantId,
deviceId,
change.path,
change.entryId,
)
break
}
if (!result.success) {
throw new Error(result.error ?? `Failed: ${change.description}`)
}
}
},
onSuccess: () => {
const count = pendingChanges.length
setPendingChanges([])
toast.success(`${count} change${count !== 1 ? 's' : ''} applied successfully`)
// Invalidate all config-browse queries for this device to refresh data
queryClient.invalidateQueries({
queryKey: ['config-browse', tenantId, deviceId],
})
},
onError: (error: Error) => {
toast.error('Configuration failed', {
description: error.message,
})
},
})
const applyChanges = useCallback(() => {
if (pendingChanges.length === 0) return
applyMutation.mutate(pendingChanges)
}, [pendingChanges, applyMutation])
return {
pendingChanges,
applyMode,
setApplyMode,
addChange,
removeChange,
clearChanges,
applyChanges,
isApplying: applyMutation.isPending,
}
}

View File

@@ -0,0 +1,208 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '@/lib/api'
// ─── Types ───────────────────────────────────────────────────────────────────
export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected'
export interface SSEEvent {
type: string // device_status, alert_fired, alert_resolved, config_push, firmware_progress, metric_update
data: unknown // parsed JSON payload
id: string // NATS sequence number
}
type EventCallback = (event: SSEEvent) => void
// ─── Constants ───────────────────────────────────────────────────────────────
const EVENT_TYPES = [
'device_status',
'alert_fired',
'alert_resolved',
'config_push',
'firmware_progress',
'metric_update',
] as const
const INITIAL_RETRY_DELAY_MS = 1000
const MAX_RETRY_DELAY_MS = 30000
const RETRY_MULTIPLIER = 2
const MAX_RETRIES = 5
// SSE exchange tokens are valid for 30 seconds, so reconnect before expiry.
// Using 25 seconds gives a comfortable margin.
const TOKEN_REFRESH_INTERVAL_MS = 25 * 1000
// ─── SSE Token Exchange ─────────────────────────────────────────────────────
/**
* Exchange the current session (httpOnly cookie) for a short-lived,
* single-use SSE token via POST /api/auth/sse-token.
*
* This avoids exposing the full JWT in the EventSource URL query parameter.
*/
async function getSSEToken(): Promise<string> {
const { data } = await api.post<{ token: string }>('/api/auth/sse-token')
return data.token
}
// ─── Hook ────────────────────────────────────────────────────────────────────
export function useEventStream(
tenantId: string | null,
onEvent: EventCallback,
): {
connectionState: ConnectionState
lastConnectedAt: Date | null
reconnect: () => void
} {
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
const [lastConnectedAt, setLastConnectedAt] = useState<Date | null>(null)
// Refs to persist across renders without causing re-renders
const eventSourceRef = useRef<EventSource | null>(null)
const retryCountRef = useRef(0)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const tokenRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const onEventRef = useRef<EventCallback>(onEvent)
const isUnmountedRef = useRef(false)
// Keep onEvent ref current without triggering reconnection
useEffect(() => {
onEventRef.current = onEvent
}, [onEvent])
// Close existing EventSource and clear timers
const cleanup = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
if (reconnectTimerRef.current !== null) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (tokenRefreshTimerRef.current !== null) {
clearInterval(tokenRefreshTimerRef.current)
tokenRefreshTimerRef.current = null
}
}, [])
// Core connection function
const connect = useCallback(async () => {
if (!tenantId || isUnmountedRef.current) return
// Clean up any existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setConnectionState('connecting')
// Exchange session cookie for a short-lived SSE token
let sseToken: string
try {
sseToken = await getSSEToken()
} catch {
// Token exchange failed -- go to reconnect flow
if (isUnmountedRef.current) return
handleReconnect()
return
}
if (isUnmountedRef.current) return
const baseUrl = import.meta.env.VITE_API_URL ?? ''
const url = `${baseUrl}/api/tenants/${tenantId}/events/stream?token=${encodeURIComponent(sseToken)}`
const es = new EventSource(url)
eventSourceRef.current = es
es.onopen = () => {
if (isUnmountedRef.current) return
setConnectionState('connected')
setLastConnectedAt(new Date())
retryCountRef.current = 0
}
// Register named event listeners for each SSE event type
EVENT_TYPES.forEach((type) => {
es.addEventListener(type, (e: MessageEvent) => {
if (isUnmountedRef.current) return
try {
const data: unknown = JSON.parse(e.data as string)
onEventRef.current({ type, data, id: e.lastEventId })
} catch {
// Malformed JSON -- skip event
}
})
})
es.onerror = () => {
if (isUnmountedRef.current) return
es.close()
eventSourceRef.current = null
handleReconnect()
}
// Set up token refresh interval — SSE tokens are 30s, reconnect at 25s
if (tokenRefreshTimerRef.current !== null) {
clearInterval(tokenRefreshTimerRef.current)
}
tokenRefreshTimerRef.current = setInterval(() => {
if (isUnmountedRef.current) return
// Silently reconnect with a fresh SSE token
void connect()
}, TOKEN_REFRESH_INTERVAL_MS)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantId, cleanup])
// Reconnection with exponential backoff
const handleReconnect = useCallback(() => {
if (isUnmountedRef.current) return
if (retryCountRef.current >= MAX_RETRIES) {
setConnectionState('disconnected')
return
}
setConnectionState('reconnecting')
const delay = Math.min(
INITIAL_RETRY_DELAY_MS * Math.pow(RETRY_MULTIPLIER, retryCountRef.current),
MAX_RETRY_DELAY_MS,
)
retryCountRef.current += 1
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null
void connect()
}, delay)
}, [connect])
// Manual reconnect: reset retry count, start fresh
const reconnect = useCallback(() => {
retryCountRef.current = 0
cleanup()
void connect()
}, [cleanup, connect])
// Main effect: connect on mount / tenantId change, cleanup on unmount
useEffect(() => {
isUnmountedRef.current = false
if (tenantId) {
void connect()
} else {
cleanup()
setConnectionState('disconnected')
}
return () => {
isUnmountedRef.current = true
cleanup()
}
}, [tenantId, connect, cleanup])
return { connectionState, lastConnectedAt, reconnect }
}

View File

@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { useRouterState } from '@tanstack/react-router'
const ROUTE_TITLES: Record<string, string> = {
'/': 'Dashboard',
'/alerts': 'Alerts',
'/alert-rules': 'Alert Rules',
'/audit': 'Audit Log',
'/batch-config': 'Batch Config',
'/bulk-commands': 'Bulk Commands',
'/certificates': 'Certificates',
'/config-editor': 'Config Editor',
'/firmware': 'Firmware',
'/maintenance': 'Maintenance',
'/map': 'Map',
'/reports': 'Reports',
'/settings': 'Settings',
'/settings/api-keys': 'API Keys',
'/setup': 'Setup',
'/templates': 'Templates',
'/tenants': 'Organizations',
'/topology': 'Topology',
'/transparency': 'Transparency',
'/vpn': 'VPN',
'/about': 'About',
}
export function usePageTitle() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
useEffect(() => {
// Match exact path or fall back to first segment
let title = ROUTE_TITLES[pathname]
if (!title) {
// Handle device-specific routes like /devices/:id/...
if (pathname.startsWith('/devices/')) {
title = 'Device'
} else {
// Capitalize the first path segment
const segment = pathname.split('/').filter(Boolean)[0]
title = segment
? segment.charAt(0).toUpperCase() + segment.slice(1).replace(/-/g, ' ')
: 'Dashboard'
}
}
document.title = `${title} | TOD`
}, [pathname])
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react'
/**
* Hook to register a single-key keyboard shortcut.
* Skips when focus is in INPUT, TEXTAREA, or contentEditable elements.
*/
export function useShortcut(key: string, callback: () => void, enabled = true) {
const callbackRef = useRef(callback)
callbackRef.current = callback
useEffect(() => {
if (!enabled) return
const handler = (e: KeyboardEvent) => {
// Skip if user is typing in an input/textarea/contenteditable
const target = e.target as HTMLElement
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
)
return
if (e.key === key) {
e.preventDefault()
callbackRef.current()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [key, enabled])
}
/**
* Hook to register a two-key sequence shortcut (e.g., "g" then "d").
* The second key must be pressed within 1 second of the first.
* Skips when focus is in INPUT, TEXTAREA, or contentEditable elements.
*/
export function useSequenceShortcut(
keys: [string, string],
callback: () => void,
enabled = true,
) {
const callbackRef = useRef(callback)
callbackRef.current = callback
const pendingRef = useRef<string | null>(null)
const timeoutRef = useRef<number | null>(null)
useEffect(() => {
if (!enabled) return
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
)
return
if (pendingRef.current === keys[0] && e.key === keys[1]) {
e.preventDefault()
pendingRef.current = null
if (timeoutRef.current) clearTimeout(timeoutRef.current)
callbackRef.current()
} else if (e.key === keys[0]) {
pendingRef.current = keys[0]
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = window.setTimeout(() => {
pendingRef.current = null
}, 1000)
} else {
pendingRef.current = null
}
}
window.addEventListener('keydown', handler)
return () => {
window.removeEventListener('keydown', handler)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [keys[0], keys[1], enabled])
}

View File

@@ -0,0 +1,39 @@
/**
* useSimpleConfigMode -- Per-device Simple/Standard mode toggle with localStorage persistence.
*
* Each device independently stores its mode preference. Default is 'standard' (opt-in to Simple).
*/
import { useState, useCallback } from 'react'
const STORAGE_KEY = 'mikrotik-simple-mode'
type ConfigMode = 'simple' | 'standard'
function readPrefs(): Record<string, ConfigMode> {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? (JSON.parse(stored) as Record<string, ConfigMode>) : {}
} catch {
return {}
}
}
export function useSimpleConfigMode(deviceId: string) {
const [mode, setMode] = useState<ConfigMode>(() => {
const prefs = readPrefs()
return prefs[deviceId] ?? 'standard'
})
const toggleMode = useCallback(
(newMode: ConfigMode) => {
setMode(newMode)
const prefs = readPrefs()
prefs[deviceId] = newMode
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
},
[deviceId],
)
return { mode, toggleMode }
}