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:
435
frontend/src/components/templates/TemplatePushWizard.tsx
Normal file
435
frontend/src/components/templates/TemplatePushWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user