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:
143
frontend/src/components/templates/PushProgressPanel.tsx
Normal file
143
frontend/src/components/templates/PushProgressPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
320
frontend/src/components/templates/TemplateEditor.tsx
Normal file
320
frontend/src/components/templates/TemplateEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
348
frontend/src/components/templates/TemplatesPage.tsx
Normal file
348
frontend/src/components/templates/TemplatesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user