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,143 @@
/**
* PushProgressPanel -- real-time per-device push status display.
* Polls the push status API every 3 seconds until all devices
* reach a terminal state (committed/reverted/failed).
*/
import { useQuery } from '@tanstack/react-query'
import { CheckCircle, XCircle, AlertTriangle, Loader2, Clock } from 'lucide-react'
import { templatesApi } from '@/lib/templatesApi'
import { cn } from '@/lib/utils'
import { formatDateTime } from '@/lib/utils'
interface PushProgressPanelProps {
tenantId: string
rolloutId: string
onClose: () => void
}
const STATUS_CONFIG: Record<string, { icon: typeof Clock; color: string; label: string }> = {
pending: { icon: Clock, color: 'text-text-muted', label: 'Pending' },
pushing: { icon: Loader2, color: 'text-info', label: 'Pushing' },
committed: { icon: CheckCircle, color: 'text-success', label: 'Committed' },
reverted: { icon: AlertTriangle, color: 'text-warning', label: 'Reverted' },
failed: { icon: XCircle, color: 'text-error', label: 'Failed' },
}
const TERMINAL_STATUSES = new Set(['committed', 'reverted', 'failed'])
export function PushProgressPanel({ tenantId, rolloutId, onClose }: PushProgressPanelProps) {
const { data } = useQuery({
queryKey: ['push-status', rolloutId],
queryFn: () => templatesApi.pushStatus(tenantId, rolloutId),
refetchInterval: (query) => {
const jobs = query.state.data?.jobs ?? []
const allTerminal = jobs.length > 0 && jobs.every((j) => TERMINAL_STATUSES.has(j.status))
return allTerminal ? false : 3000
},
})
const jobs = data?.jobs ?? []
const total = jobs.length
const committed = jobs.filter((j) => j.status === 'committed').length
const failed = jobs.filter((j) => j.status === 'failed').length
const reverted = jobs.filter((j) => j.status === 'reverted').length
const pending = jobs.filter((j) => j.status === 'pending').length
const allDone = jobs.length > 0 && jobs.every((j) => TERMINAL_STATUSES.has(j.status))
const hasFailed = failed > 0 || reverted > 0
// Progress percentage
const completedCount = committed + failed + reverted
const progressPct = total > 0 ? Math.round((completedCount / total) * 100) : 0
return (
<div className="space-y-4">
{/* Summary */}
<div className="flex items-center justify-between">
<div className="text-sm text-text-primary font-medium">Push Progress</div>
<div className="text-xs text-text-muted">
{completedCount} / {total} devices
</div>
</div>
{/* Progress bar */}
<div className="h-2 bg-elevated/50 rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-500 rounded-full',
hasFailed ? 'bg-error' : 'bg-success',
)}
style={{ width: `${progressPct}%` }}
/>
</div>
{/* Status message */}
{allDone && !hasFailed && (
<div className="text-xs text-success bg-success/10 rounded-lg px-3 py-2">
Push complete -- all {committed} devices configured successfully
</div>
)}
{allDone && hasFailed && pending > 0 && (
<div className="text-xs text-warning bg-warning/10 rounded-lg px-3 py-2">
Push paused -- {failed + reverted} device(s) failed/reverted.{' '}
{pending} device(s) remain pending.
</div>
)}
{allDone && hasFailed && pending === 0 && (
<div className="text-xs text-error bg-error/10 rounded-lg px-3 py-2">
Push complete with errors -- {failed} failed, {reverted} reverted out of {total} devices.
</div>
)}
{/* Per-device list */}
<div className="space-y-1">
{jobs.map((job) => {
const config = STATUS_CONFIG[job.status] ?? STATUS_CONFIG.pending
const Icon = config.icon
return (
<div
key={job.device_id}
className="flex items-center gap-3 rounded-lg border border-border/50 bg-surface/50 px-3 py-2"
>
<Icon
className={cn(
'h-4 w-4 flex-shrink-0',
config.color,
job.status === 'pushing' && 'animate-spin',
)}
/>
<div className="flex-1 min-w-0">
<div className="text-xs text-text-primary truncate">{job.hostname}</div>
{job.error_message && (
<div className="text-[10px] text-error truncate">{job.error_message}</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={cn('text-[10px] px-1.5 py-0.5 rounded', config.color, 'bg-elevated/50')}>
{config.label}
</span>
{job.completed_at && (
<span className="text-[10px] text-text-muted">{formatDateTime(job.completed_at)}</span>
)}
</div>
</div>
)
})}
</div>
{allDone && (
<div className="flex justify-end pt-2">
<button
onClick={onClose}
className="text-xs text-text-secondary hover:text-text-primary transition-colors underline"
>
Close
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,320 @@
/**
* TemplateEditor -- full-page form for creating and editing config templates.
* Includes name, description, monospace content editor, tag input,
* and auto-detected variable table.
*/
import { useState } from 'react'
import { X, Plus, Loader2, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
type TemplateResponse,
type VariableDef,
type TemplateCreateData,
} from '@/lib/templatesApi'
interface TemplateEditorProps {
tenantId: string
template?: TemplateResponse | null
onSave: (data: TemplateCreateData) => Promise<void>
onCancel: () => void
}
const VARIABLE_TYPES = ['string', 'ip', 'integer', 'boolean', 'subnet'] as const
export function TemplateEditor({ tenantId: _tenantId, template, onSave, onCancel }: TemplateEditorProps) {
const [name, setName] = useState(template?.name ?? '')
const [description, setDescription] = useState(template?.description ?? '')
const [content, setContent] = useState(template?.content ?? '')
const [tags, setTags] = useState<string[]>(template?.tags ?? [])
const [tagInput, setTagInput] = useState('')
const [variables, setVariables] = useState<VariableDef[]>(template?.variables ?? [])
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const addTag = () => {
const t = tagInput.trim()
if (t && !tags.includes(t)) {
setTags([...tags, t])
setTagInput('')
}
}
const removeTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag))
}
const detectVariables = () => {
// Simple regex-based detection of {{ variable }} patterns
const regex = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g
const found = new Set<string>()
let match
while ((match = regex.exec(content)) !== null) {
const varName = match[1]
// Skip 'device' built-in and its properties
if (varName !== 'device') {
found.add(varName)
}
}
// Also detect dot-access variables like {{ device.hostname }}
// These are built-in and we skip them
const existingNames = new Set(variables.map((v) => v.name))
const newVars: VariableDef[] = [...variables]
for (const name of found) {
if (!existingNames.has(name)) {
newVars.push({ name, type: 'string', default: null, description: null })
}
}
setVariables(newVars)
}
const updateVariable = (index: number, field: keyof VariableDef, value: string | null) => {
setVariables(
variables.map((v, i) =>
i === index ? { ...v, [field]: value } : v,
),
)
}
const removeVariable = (index: number) => {
setVariables(variables.filter((_, i) => i !== index))
}
const addVariable = () => {
setVariables([...variables, { name: '', type: 'string', default: null, description: null }])
}
const handleSave = async () => {
if (!name.trim()) {
setError('Template name is required')
return
}
if (!content.trim()) {
setError('Template content is required')
return
}
setSaving(true)
setError(null)
try {
await onSave({
name: name.trim(),
description: description.trim() || undefined,
content,
variables: variables.filter((v) => v.name.trim()),
tags,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="border-b border-border px-6 py-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-text-primary">
{template ? 'Edit Template' : 'Create Template'}
</h2>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="text-xs" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" className="text-xs gap-1" onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
Save Template
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{error && (
<div className="text-xs text-error bg-error/10 rounded px-3 py-2">{error}</div>
)}
{/* Name */}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Name *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Basic Firewall Setup"
className="bg-elevated/50 border-border"
/>
</div>
{/* Description */}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Description</Label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this template configure?"
rows={2}
className="w-full px-3 py-2 text-sm rounded-md bg-elevated/50 border border-border text-text-primary placeholder:text-text-muted resize-none focus:outline-none focus:ring-1 focus:ring-border-bright"
/>
</div>
{/* Content */}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Template Content (RouterOS commands with Jinja2 variables)</Label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={`# Example: Set system identity\n/system identity set name={{ device.hostname }}-{{ site_name }}\n\n# Add IP address\n/ip address add address={{ mgmt_ip }}/24 interface=ether1`}
rows={16}
className="w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-success placeholder:text-text-muted font-mono resize-y focus:outline-none focus:ring-1 focus:ring-border-bright leading-relaxed"
/>
</div>
{/* Tags */}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Tags</Label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-elevated text-text-secondary"
>
{tag}
<button
onClick={() => removeTag(tag)}
className="hover:text-text-primary transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add tag..."
className="h-7 text-xs bg-elevated/50 border-border flex-1"
/>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={addTag}>
Add
</Button>
</div>
</div>
{/* Variables */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-text-secondary">Variables</Label>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] gap-1"
onClick={detectVariables}
>
<Sparkles className="h-3 w-3" />
Scan for Variables
</Button>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] gap-1"
onClick={addVariable}
>
<Plus className="h-3 w-3" />
Add
</Button>
</div>
</div>
<div className="text-[10px] text-text-muted mb-1">
Built-in: {'{{ device.hostname }}'}, {'{{ device.ip }}'}, {'{{ device.model }}'} -- auto-populated per device
</div>
{variables.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-surface border-b border-border">
<th className="text-left px-3 py-1.5 text-text-secondary font-medium">Name</th>
<th className="text-left px-3 py-1.5 text-text-secondary font-medium w-28">Type</th>
<th className="text-left px-3 py-1.5 text-text-secondary font-medium">Default</th>
<th className="text-left px-3 py-1.5 text-text-secondary font-medium">Description</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{variables.map((v, i) => (
<tr key={i} className="border-b border-border/50">
<td className="px-2 py-1">
<Input
value={v.name}
onChange={(e) => updateVariable(i, 'name', e.target.value)}
className="h-6 text-xs bg-elevated/50 border-border font-mono"
placeholder="variable_name"
/>
</td>
<td className="px-2 py-1">
<Select
value={v.type}
onValueChange={(val) => updateVariable(i, 'type', val)}
>
<SelectTrigger className="h-6 text-xs bg-elevated/50 border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VARIABLE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-1">
<Input
value={v.default ?? ''}
onChange={(e) => updateVariable(i, 'default', e.target.value || null)}
className="h-6 text-xs bg-elevated/50 border-border"
placeholder="default value"
/>
</td>
<td className="px-2 py-1">
<Input
value={v.description ?? ''}
onChange={(e) => updateVariable(i, 'description', e.target.value || null)}
className="h-6 text-xs bg-elevated/50 border-border"
placeholder="description"
/>
</td>
<td className="px-1 py-1">
<button
onClick={() => removeVariable(i)}
className="p-1 rounded hover:bg-error/20 text-text-muted hover:text-error transition-colors"
>
<X className="h-3 w-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,435 @@
/**
* TemplatePushWizard -- multi-step dialog for pushing a template to devices.
* Steps: Target Selection -> Variable Input -> Preview -> Confirm & Push -> Progress
*/
import { useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Loader2, AlertTriangle, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import {
templatesApi,
type TemplateResponse,
} from '@/lib/templatesApi'
import { deviceGroupsApi, metricsApi } from '@/lib/api'
import { PushProgressPanel } from './PushProgressPanel'
interface TemplatePushWizardProps {
open: boolean
onClose: () => void
tenantId: string
template: TemplateResponse
}
type WizardStep = 'targets' | 'variables' | 'preview' | 'confirm' | 'progress'
export function TemplatePushWizard({ open, onClose, tenantId, template }: TemplatePushWizardProps) {
const [step, setStep] = useState<WizardStep>('targets')
const [selectedDeviceIds, setSelectedDeviceIds] = useState<Set<string>>(new Set())
const [variables, setVariables] = useState<Record<string, string>>(() => {
const defaults: Record<string, string> = {}
for (const v of template.variables) {
if (v.default) defaults[v.name] = v.default
}
return defaults
})
const [previewDevice, setPreviewDevice] = useState<string | null>(null)
const [previews, setPreviews] = useState<Record<string, string>>({})
const [rolloutId, setRolloutId] = useState<string | null>(null)
// Fetch devices
const { data: devices } = useQuery({
queryKey: ['fleet-devices', tenantId],
queryFn: () => metricsApi.fleetSummary(tenantId),
enabled: open,
})
// Fetch groups
const { data: groups } = useQuery({
queryKey: ['device-groups', tenantId],
queryFn: () => deviceGroupsApi.list(tenantId),
enabled: open,
})
// Preview mutation
const previewMutation = useMutation({
mutationFn: ({ deviceId }: { deviceId: string }) =>
templatesApi.preview(tenantId, template.id, deviceId, variables),
onSuccess: (data, { deviceId }) => {
setPreviews((prev) => ({ ...prev, [deviceId]: data.rendered }))
},
})
// Push mutation
const pushMutation = useMutation({
mutationFn: () =>
templatesApi.push(tenantId, template.id, Array.from(selectedDeviceIds), variables),
onSuccess: (data) => {
setRolloutId(data.rollout_id)
setStep('progress')
},
})
const selectedDevices = devices?.filter((d) => selectedDeviceIds.has(d.id)) ?? []
const handleGroupSelect = (_groupId: string) => {
// For now, just select all online devices. In a real implementation,
// we'd load group members from the API. Here we select all devices
// as a simplified approach.
if (devices) {
const onlineIds = new Set(devices.filter((d) => d.status === 'online').map((d) => d.id))
setSelectedDeviceIds(onlineIds)
}
}
const toggleDevice = (deviceId: string) => {
setSelectedDeviceIds((prev) => {
const next = new Set(prev)
if (next.has(deviceId)) {
next.delete(deviceId)
} else {
next.add(deviceId)
}
return next
})
}
const goToPreview = () => {
setStep('preview')
// Trigger preview for first selected device
if (selectedDevices.length > 0) {
const firstId = selectedDevices[0].id
setPreviewDevice(firstId)
if (!previews[firstId]) {
previewMutation.mutate({ deviceId: firstId })
}
}
}
const selectPreviewDevice = (deviceId: string) => {
setPreviewDevice(deviceId)
if (!previews[deviceId]) {
previewMutation.mutate({ deviceId })
}
}
const handleClose = () => {
setStep('targets')
setSelectedDeviceIds(new Set())
setVariables(() => {
const defaults: Record<string, string> = {}
for (const v of template.variables) {
if (v.default) defaults[v.name] = v.default
}
return defaults
})
setPreviews({})
setRolloutId(null)
onClose()
}
const userVars = template.variables.filter((v) => v.name !== 'device')
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-surface border-border text-text-primary">
<DialogHeader>
<DialogTitle className="text-sm flex items-center gap-2">
Push Template: {template.name}
{step !== 'progress' && (
<span className="text-[10px] text-text-muted font-normal">
Step {['targets', 'variables', 'preview', 'confirm'].indexOf(step) + 1} of 4
</span>
)}
</DialogTitle>
</DialogHeader>
{/* Step 1: Target Selection */}
{step === 'targets' && (
<div className="space-y-4">
<div className="text-xs text-text-secondary">Select devices to push the template to.</div>
{groups && groups.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-text-secondary">Select Group</Label>
<Select onValueChange={handleGroupSelect}>
<SelectTrigger className="bg-elevated/50 border-border text-xs">
<SelectValue placeholder="Select a device group..." />
</SelectTrigger>
<SelectContent>
{groups.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs text-text-secondary">
Devices ({selectedDeviceIds.size} selected)
</Label>
<div className="max-h-60 overflow-y-auto rounded-lg border border-border divide-y divide-white/5">
{devices?.map((device) => (
<label
key={device.id}
className="flex items-center gap-3 px-3 py-2 hover:bg-surface cursor-pointer"
>
<Checkbox
checked={selectedDeviceIds.has(device.id)}
onCheckedChange={() => toggleDevice(device.id)}
/>
<div className="flex-1 min-w-0">
<div className="text-xs text-text-primary truncate">{device.hostname}</div>
<div className="text-[10px] text-text-muted">{device.ip_address}</div>
</div>
<div
className={cn(
'h-2 w-2 rounded-full flex-shrink-0',
device.status === 'online' ? 'bg-success' : 'bg-error',
)}
/>
</label>
)) ?? (
<div className="px-3 py-6 text-xs text-text-muted text-center">Loading devices...</div>
)}
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
className="text-xs gap-1"
disabled={selectedDeviceIds.size === 0}
onClick={() => setStep(userVars.length > 0 ? 'variables' : 'preview')}
>
Next
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
{/* Step 2: Variable Input */}
{step === 'variables' && (
<div className="space-y-4">
<div className="text-xs text-text-secondary">
Provide values for template variables. Built-in device variables are auto-populated per device.
</div>
<div className="text-[10px] text-text-muted bg-surface rounded px-3 py-2">
Auto-populated: {'{{ device.hostname }}'}, {'{{ device.ip }}'}, {'{{ device.model }}'}
</div>
<div className="space-y-3">
{userVars.map((v) => (
<div key={v.name} className="space-y-1">
<Label className="text-xs text-text-secondary">
{v.name}
{v.description && (
<span className="ml-2 text-text-muted">-- {v.description}</span>
)}
</Label>
{v.type === 'boolean' ? (
<div className="flex items-center gap-2">
<Checkbox
checked={variables[v.name] === 'true'}
onCheckedChange={(c) =>
setVariables((prev) => ({ ...prev, [v.name]: c ? 'true' : 'false' }))
}
/>
<span className="text-xs text-text-secondary">{variables[v.name] ?? 'false'}</span>
</div>
) : (
<Input
value={variables[v.name] ?? ''}
onChange={(e) =>
setVariables((prev) => ({ ...prev, [v.name]: e.target.value }))
}
placeholder={
v.type === 'ip'
? '192.168.1.1'
: v.type === 'subnet'
? '192.168.1.0/24'
: v.type === 'integer'
? '0'
: v.default ?? ''
}
type={v.type === 'integer' ? 'number' : 'text'}
className="h-8 text-xs bg-elevated/50 border-border font-mono"
/>
)}
<div className="text-[10px] text-text-muted">
type: {v.type}
{v.default && ` | default: ${v.default}`}
</div>
</div>
))}
</div>
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
className="text-xs gap-1"
onClick={() => setStep('targets')}
>
<ChevronLeft className="h-3 w-3" />
Back
</Button>
<Button size="sm" className="text-xs gap-1" onClick={goToPreview}>
Next
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 'preview' && (
<div className="space-y-4">
<div className="text-xs text-text-secondary">
Preview the rendered template for each device.
</div>
<div className="flex gap-2 flex-wrap">
{selectedDevices.map((d) => (
<button
key={d.id}
onClick={() => selectPreviewDevice(d.id)}
className={cn(
'text-xs px-2 py-1 rounded transition-colors',
previewDevice === d.id
? 'bg-elevated text-text-primary'
: 'bg-surface text-text-secondary hover:text-text-secondary',
)}
>
{d.hostname}
</button>
))}
</div>
{previewDevice && previews[previewDevice] && (
<pre className="text-xs font-mono bg-background border border-border rounded-lg p-3 text-success overflow-x-auto max-h-64 whitespace-pre-wrap">
{previews[previewDevice]}
</pre>
)}
{previewDevice && !previews[previewDevice] && previewMutation.isPending && (
<div className="flex items-center justify-center py-8 text-text-muted">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading preview...
</div>
)}
{previewMutation.isError && (
<div className="text-xs text-error bg-error/10 rounded px-3 py-2">
Preview failed: {previewMutation.error instanceof Error ? previewMutation.error.message : 'Unknown error'}
</div>
)}
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
className="text-xs gap-1"
onClick={() => setStep(userVars.length > 0 ? 'variables' : 'targets')}
>
<ChevronLeft className="h-3 w-3" />
Back
</Button>
<Button size="sm" className="text-xs gap-1" onClick={() => setStep('confirm')}>
Next
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
{/* Step 4: Confirm & Push */}
{step === 'confirm' && (
<div className="space-y-4">
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 flex gap-2">
<AlertTriangle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<div className="text-xs text-warning leading-relaxed">
This will push configuration to <strong>{selectedDeviceIds.size}</strong> device(s).
Each device will be backed up before changes are applied. If a device becomes
unreachable after push, it will automatically revert.
</div>
</div>
<div className="space-y-2">
<div className="text-xs text-text-secondary">Template: {template.name}</div>
<div className="text-xs text-text-secondary">
Devices: {selectedDevices.map((d) => d.hostname).join(', ')}
</div>
{Object.entries(variables).length > 0 && (
<div className="text-xs text-text-secondary">
Variables:{' '}
{Object.entries(variables)
.map(([k, v]) => `${k}=${v}`)
.join(', ')}
</div>
)}
</div>
<div className="flex justify-between">
<Button
variant="outline"
size="sm"
className="text-xs gap-1"
onClick={() => setStep('preview')}
>
<ChevronLeft className="h-3 w-3" />
Back
</Button>
<Button
size="sm"
className="text-xs gap-1"
onClick={() => pushMutation.mutate()}
disabled={pushMutation.isPending}
>
{pushMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Play className="h-3 w-3" />
)}
Push to {selectedDeviceIds.size} Device(s)
</Button>
</div>
{pushMutation.isError && (
<div className="text-xs text-error bg-error/10 rounded px-3 py-2">
Push failed: {pushMutation.error instanceof Error ? pushMutation.error.message : 'Unknown error'}
</div>
)}
</div>
)}
{/* Step 5: Progress */}
{step === 'progress' && rolloutId && (
<PushProgressPanel tenantId={tenantId} rolloutId={rolloutId} onClose={handleClose} />
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,348 @@
/**
* TemplatesPage -- config template list page with tag filtering,
* create/edit/delete actions, and push wizard access.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useUIStore } from '@/lib/store'
import {
FileCode,
Plus,
Pencil,
Trash2,
Play,
Tag,
Loader2,
} from 'lucide-react'
import {
templatesApi,
type TemplateResponse,
} from '@/lib/templatesApi'
import { tenantsApi } from '@/lib/api'
import { useAuth, canWrite, isSuperAdmin } from '@/lib/auth'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from '@/components/ui/toast'
import { CardGridSkeleton } from '@/components/ui/page-skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { TemplateEditor } from './TemplateEditor'
import { TemplatePushWizard } from './TemplatePushWizard'
export function TemplatesPage() {
const { user } = useAuth()
const queryClient = useQueryClient()
const isSuper = isSuperAdmin(user)
const { selectedTenantId, setSelectedTenantId } = useUIStore()
const { data: tenants } = useQuery({
queryKey: ['tenants'],
queryFn: () => tenantsApi.list(),
enabled: isSuper,
})
const tenantId = isSuper ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
const writable = canWrite(user)
const [tagFilter, setTagFilter] = useState<string | undefined>()
const [view, setView] = useState<'list' | 'editor'>('list')
const [editingTemplate, setEditingTemplate] = useState<TemplateResponse | null>(null)
const [pushTemplate, setPushTemplate] = useState<TemplateResponse | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
// Fetch templates
const { data: templates, isLoading } = useQuery({
queryKey: ['templates', tenantId, tagFilter],
queryFn: () => templatesApi.list(tenantId, tagFilter),
enabled: !!tenantId,
})
// Get unique tags from all templates
const allTags = Array.from(
new Set((templates ?? []).flatMap((t) => t.tags)),
).sort()
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (templateId: string) => templatesApi.delete(tenantId, templateId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates', tenantId] })
toast({ title: 'Template deleted' })
setDeleteConfirmId(null)
},
onError: (err) =>
toast({ title: 'Delete failed', description: String(err), variant: 'destructive' }),
})
// Create handler
const handleCreate = () => {
setEditingTemplate(null)
setView('editor')
}
// Edit handler
const handleEdit = async (templateId: string) => {
const full = await templatesApi.get(tenantId, templateId)
setEditingTemplate(full)
setView('editor')
}
// Push handler
const handlePush = async (templateId: string) => {
const full = await templatesApi.get(tenantId, templateId)
setPushTemplate(full)
}
// Save handler
const handleSave = async (data: Parameters<typeof templatesApi.create>[1]) => {
if (editingTemplate) {
await templatesApi.update(tenantId, editingTemplate.id, data)
toast({ title: 'Template updated' })
} else {
await templatesApi.create(tenantId, data)
toast({ title: 'Template created' })
}
queryClient.invalidateQueries({ queryKey: ['templates', tenantId] })
setView('list')
setEditingTemplate(null)
}
// Editor view
if (view === 'editor') {
return (
<TemplateEditor
tenantId={tenantId}
template={editingTemplate}
onSave={handleSave}
onCancel={() => {
setView('list')
setEditingTemplate(null)
}}
/>
)
}
// List view
return (
<div className="flex-1 flex flex-col">
{/* Header */}
<div className="border-b border-border px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<FileCode className="h-4 w-4 text-text-muted" />
<h1 className="text-sm font-medium text-text-primary">Config Templates</h1>
{templates && (
<span className="text-xs text-text-muted">({templates.length})</span>
)}
</div>
{isSuper && tenants && tenants.length > 0 && (
<Select value={selectedTenantId ?? ''} onValueChange={setSelectedTenantId}>
<SelectTrigger className="w-48 h-7 text-xs">
<SelectValue placeholder="Select organization" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={t.id} className="text-xs">
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{writable && (
<Button size="sm" className="text-xs gap-1" onClick={handleCreate}>
<Plus className="h-3 w-3" />
Create Template
</Button>
)}
</div>
{/* Tag filter bar */}
{allTags.length > 0 && (
<div className="px-6 py-2 border-b border-border/50 flex items-center gap-2 overflow-x-auto">
<Tag className="h-3 w-3 text-text-muted flex-shrink-0" />
<button
onClick={() => setTagFilter(undefined)}
className={cn(
'text-xs px-2 py-0.5 rounded-full transition-colors whitespace-nowrap',
!tagFilter
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
)}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setTagFilter(tag === tagFilter ? undefined : tag)}
className={cn(
'text-xs px-2 py-0.5 rounded-full transition-colors whitespace-nowrap',
tagFilter === tag
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
)}
>
{tag}
</button>
))}
</div>
)}
{/* Template list */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<CardGridSkeleton />
) : !templates || templates.length === 0 ? (
<EmptyState
icon={FileCode}
title="No templates created"
description={
tagFilter
? `No templates with tag "${tagFilter}".`
: 'Create configuration templates to streamline device setup.'
}
action={
!tagFilter && writable
? { label: 'Create Template', onClick: handleCreate }
: undefined
}
/>
) : (
<div className="space-y-3">
{templates.map((template) => (
<div
key={template.id}
className="rounded-lg border border-border bg-surface/50 p-4 hover:bg-surface transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<button
onClick={() => handleEdit(template.id)}
className="text-sm font-medium text-text-primary hover:underline text-left"
>
{template.name}
</button>
{template.description && (
<div className="text-xs text-text-muted mt-0.5 line-clamp-1">
{template.description}
</div>
)}
<div className="flex items-center gap-3 mt-2">
{template.tags.length > 0 && (
<div className="flex gap-1">
{template.tags.map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-elevated/60 text-text-secondary"
>
{tag}
</span>
))}
</div>
)}
<span className="text-[10px] text-text-muted">
{template.variable_count} variable(s)
</span>
<span className="text-[10px] text-text-muted">
Updated {formatDate(template.updated_at)}
</span>
</div>
</div>
{writable && (
<div className="flex items-center gap-1 ml-4 flex-shrink-0">
<button
onClick={() => handlePush(template.id)}
className="p-1.5 rounded hover:bg-success/20 text-text-muted hover:text-success transition-colors"
title="Push to devices"
>
<Play className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleEdit(template.id)}
className="p-1.5 rounded hover:bg-elevated text-text-muted hover:text-text-primary transition-colors"
title="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setDeleteConfirmId(template.id)}
className="p-1.5 rounded hover:bg-error/20 text-text-muted hover:text-error transition-colors"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Push Wizard */}
{pushTemplate && (
<TemplatePushWizard
open={!!pushTemplate}
onClose={() => setPushTemplate(null)}
tenantId={tenantId}
template={pushTemplate}
/>
)}
{/* Delete Confirmation */}
<Dialog
open={!!deleteConfirmId}
onOpenChange={(o) => !o && setDeleteConfirmId(null)}
>
<DialogContent className="max-w-sm bg-surface border-border text-text-primary">
<DialogHeader>
<DialogTitle className="text-sm">Delete Template</DialogTitle>
</DialogHeader>
<p className="text-xs text-text-secondary">
Are you sure you want to delete this template? This action cannot be undone.
Existing push jobs will keep their rendered content.
</p>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
<Button
size="sm"
variant="destructive"
className="text-xs"
onClick={() => deleteConfirmId && deleteMutation.mutate(deleteConfirmId)}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}