Spaces:
Sleeping
Sleeping
| import { useState } from "react" | |
| import { | |
| CheckCircle2, | |
| ChevronDown, | |
| CircleSlash, | |
| Loader2, | |
| Save, | |
| ShieldCheck, | |
| Sparkles, | |
| Trash2, | |
| Wand2, | |
| } from "lucide-react" | |
| import { useDashboard } from "@/lib/dashboard" | |
| import { Button } from "@/components/ui/button" | |
| import { Input } from "@/components/ui/input" | |
| import { Textarea } from "@/components/ui/textarea" | |
| import { Badge } from "@/components/ui/badge" | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" | |
| import { Label } from "@/components/ui/label" | |
| import { Switch } from "@/components/ui/switch" | |
| import { cn } from "@/lib/utils" | |
| import type { | |
| ActionSpec, | |
| AutomationDocument, | |
| AutomationRule, | |
| ConditionBlock, | |
| CooldownCondition, | |
| } from "@/lib/types" | |
| export function RuleStudioPanel() { | |
| const { | |
| rulesText, | |
| setRulesText, | |
| replaceRulesText, | |
| validate, | |
| validation, | |
| save, | |
| setRuleEnabled, | |
| deleteRule, | |
| compiler, | |
| compile, | |
| compiling, | |
| } = useDashboard() | |
| const [instruction, setInstruction] = useState( | |
| "When a cat appears in the image, notify me.", | |
| ) | |
| const [armedDelete, setArmedDelete] = useState<string | null>(null) | |
| const [expandedRule, setExpandedRule] = useState<string | null>(null) | |
| const document = validation?.ok ? validation.document : null | |
| const applyDocument = (nextDocument: AutomationDocument) => { | |
| void replaceRulesText(JSON.stringify(nextDocument, null, 2), true) | |
| } | |
| const updateRule = (ruleIndex: number, updater: (rule: AutomationRule) => void) => { | |
| if (!document) return | |
| const nextDocument = cloneDocument(document) | |
| const rule = nextDocument.rules[ruleIndex] | |
| if (!rule) return | |
| updater(rule) | |
| applyDocument(nextDocument) | |
| } | |
| return ( | |
| <Tabs defaultValue="compose"> | |
| <div className="flex items-center justify-between"> | |
| <TabsList> | |
| <TabsTrigger value="compose"> | |
| <Sparkles className="size-3.5" /> | |
| Compose | |
| </TabsTrigger> | |
| <TabsTrigger value="source">Source</TabsTrigger> | |
| <TabsTrigger value="rules"> | |
| Rules | |
| {validation?.ok && ( | |
| <span className="ml-1 font-mono text-[0.625rem] text-muted-foreground"> | |
| {validation.rules.length} | |
| </span> | |
| )} | |
| </TabsTrigger> | |
| </TabsList> | |
| {validation && | |
| (validation.ok ? ( | |
| <Badge variant="live"> | |
| <CheckCircle2 className="size-3" /> | |
| valid | |
| </Badge> | |
| ) : ( | |
| <Badge variant="danger"> | |
| <CircleSlash className="size-3" /> | |
| invalid | |
| </Badge> | |
| ))} | |
| </div> | |
| {/* Compose: NL → rules */} | |
| <TabsContent value="compose"> | |
| <div className="space-y-3"> | |
| <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| <p className="text-xs text-muted-foreground"> | |
| Describe the automation in plain language. It compiles to validated rules through{" "} | |
| {compiler.provider} — never executable code. | |
| </p> | |
| <Badge variant="default">{compiler.provider}</Badge> | |
| </div> | |
| <Textarea | |
| value={instruction} | |
| onChange={(e) => setInstruction(e.target.value)} | |
| className="min-h-24" | |
| placeholder="When a cat is near the feeder, dispense food once every 30 minutes…" | |
| /> | |
| <Button onClick={() => compile(instruction)} disabled={compiling || !instruction.trim()}> | |
| {compiling ? <Loader2 className="size-4 animate-spin" /> : <Wand2 className="size-4" />} | |
| {compiling | |
| ? "Compiling…" | |
| : `Compile with ${compiler.provider}`} | |
| </Button> | |
| </div> | |
| </TabsContent> | |
| {/* Source: raw editor */} | |
| <TabsContent value="source"> | |
| <div className="space-y-3"> | |
| <div className="relative"> | |
| <div className="absolute left-0 top-0 z-10 rounded-br-md rounded-tl-md border-b border-r border-border bg-black/40 px-2 py-0.5 font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground"> | |
| yaml · json | |
| </div> | |
| <Textarea | |
| value={rulesText} | |
| onChange={(e) => setRulesText(e.target.value)} | |
| spellCheck={false} | |
| className="min-h-64 bg-black/40 pt-7 font-mono text-xs leading-relaxed" | |
| /> | |
| </div> | |
| {validation && !validation.ok && validation.error && ( | |
| <pre className="max-h-28 overflow-auto rounded-md border border-destructive/30 bg-destructive/5 p-3 font-mono text-[0.6875rem] text-destructive/90 whitespace-pre-wrap"> | |
| {validation.error} | |
| </pre> | |
| )} | |
| <div className="flex gap-2"> | |
| <Button variant="secondary" onClick={validate}> | |
| <ShieldCheck className="size-4" /> | |
| Validate | |
| </Button> | |
| <Button variant="secondary" onClick={save}> | |
| <Save className="size-4" /> | |
| Save | |
| </Button> | |
| </div> | |
| </div> | |
| </TabsContent> | |
| {/* Rules: parsed view */} | |
| <TabsContent value="rules"> | |
| {validation?.ok && document && document.rules.length > 0 ? ( | |
| <div className="space-y-2"> | |
| {document.rules.map((rule, ruleIndex) => { | |
| const r = validation.rules.find((item) => item.name === rule.name) ?? { | |
| name: rule.name, | |
| enabled: rule.gate.enabled, | |
| trigger: rule.trigger.on, | |
| labels: [], | |
| conditions: rule.when.all.length + rule.when.any.length, | |
| actions: ruleActions(rule).map((action) => ({ type: action.type, name: action.name })), | |
| } | |
| const expanded = expandedRule === rule.name | |
| return ( | |
| <div | |
| key={r.name} | |
| className="rounded-lg border border-border bg-black/20" | |
| > | |
| <button | |
| type="button" | |
| className="flex w-full flex-col gap-3 px-4 py-3 text-left sm:flex-row sm:items-center sm:justify-between" | |
| onClick={() => setExpandedRule(expanded ? null : rule.name)} | |
| > | |
| <div className="min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <ChevronDown | |
| className={cn( | |
| "size-4 shrink-0 text-muted-foreground transition-transform", | |
| expanded && "rotate-180 text-primary", | |
| )} | |
| /> | |
| <span | |
| className={cn( | |
| "size-1.5 rounded-full", | |
| r.enabled ? "bg-primary" : "bg-muted-foreground/40", | |
| )} | |
| /> | |
| <span className="truncate font-mono text-sm text-foreground">{r.name}</span> | |
| {r.name.startsWith("example-") && <Badge variant="default">example</Badge>} | |
| <Badge variant="default">{r.trigger}</Badge> | |
| </div> | |
| <div className="mt-1 pl-9 text-xs text-muted-foreground"> | |
| {r.conditions} condition{r.conditions === 1 ? "" : "s"} →{" "} | |
| {r.actions.map((a) => `${a.type}:${a.name}`).join(", ")} | |
| </div> | |
| {r.labels.length > 0 && ( | |
| <div className="mt-2 flex flex-wrap gap-1 pl-9"> | |
| {r.labels.map((label) => ( | |
| <span | |
| key={label} | |
| className="rounded border border-border bg-black/30 px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground" | |
| > | |
| {label} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex shrink-0 items-center gap-2" onClick={(event) => event.stopPropagation()}> | |
| <Label htmlFor={`enabled-${r.name}`} className="text-xs text-muted-foreground"> | |
| {r.enabled ? "enabled" : "disabled"} | |
| </Label> | |
| <Switch | |
| id={`enabled-${r.name}`} | |
| checked={r.enabled} | |
| onCheckedChange={(checked) => setRuleEnabled(r.name, checked)} | |
| /> | |
| <Button | |
| variant={armedDelete === r.name ? "destructive" : "secondary"} | |
| size="sm" | |
| onClick={() => { | |
| if (armedDelete === r.name) { | |
| void deleteRule(r.name) | |
| setArmedDelete(null) | |
| } else { | |
| setArmedDelete(r.name) | |
| } | |
| }} | |
| > | |
| <Trash2 className="size-4" /> | |
| {armedDelete === r.name ? "Confirm" : "Delete"} | |
| </Button> | |
| </div> | |
| </button> | |
| {expanded && ( | |
| <RuleEditor | |
| rule={rule} | |
| onUpdate={(updater) => updateRule(ruleIndex, updater)} | |
| /> | |
| )} | |
| </div> | |
| )})} | |
| </div> | |
| ) : validation?.ok ? ( | |
| <div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground"> | |
| <ShieldCheck className="size-7 opacity-40" /> | |
| <p className="text-sm">No rules saved.</p> | |
| </div> | |
| ) : ( | |
| <div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground"> | |
| <ShieldCheck className="size-7 opacity-40" /> | |
| <p className="text-sm">Validate the source to see parsed rules.</p> | |
| <Button variant="secondary" size="sm" onClick={validate}> | |
| Validate now | |
| </Button> | |
| </div> | |
| )} | |
| </TabsContent> | |
| </Tabs> | |
| ) | |
| } | |
| function RuleEditor({ | |
| rule, | |
| onUpdate, | |
| }: { | |
| rule: AutomationRule | |
| onUpdate: (updater: (rule: AutomationRule) => void) => void | |
| }) { | |
| return ( | |
| <div className="border-t border-border px-4 pb-4 pt-3"> | |
| <div className="grid gap-3 lg:grid-cols-2"> | |
| <div className="space-y-3"> | |
| <div className="flex items-center justify-between rounded-md border border-border bg-black/20 px-3 py-2"> | |
| <div> | |
| <div className="text-xs font-medium text-foreground">Cooldown</div> | |
| <div className="text-[0.6875rem] text-muted-foreground">Blocks repeated fires for this rule.</div> | |
| </div> | |
| <Switch | |
| checked={Boolean(rule.gate.cooldown)} | |
| onCheckedChange={(checked) => | |
| onUpdate((draft) => { | |
| draft.gate.cooldown = checked ? { key: draft.name, seconds: null, minutes: 5 } : null | |
| }) | |
| } | |
| /> | |
| </div> | |
| {rule.gate.cooldown && ( | |
| <CooldownEditor | |
| cooldown={rule.gate.cooldown} | |
| onChange={(next) => onUpdate((draft) => { draft.gate.cooldown = next })} | |
| /> | |
| )} | |
| <ConditionList | |
| title="All conditions" | |
| conditions={rule.when.all} | |
| onUpdate={(conditionIndex, updater) => | |
| onUpdate((draft) => { | |
| const condition = draft.when.all[conditionIndex] | |
| if (condition) updater(condition) | |
| }) | |
| } | |
| /> | |
| {rule.when.any.length > 0 && ( | |
| <ConditionList | |
| title="Any conditions" | |
| conditions={rule.when.any} | |
| onUpdate={(conditionIndex, updater) => | |
| onUpdate((draft) => { | |
| const condition = draft.when.any[conditionIndex] | |
| if (condition) updater(condition) | |
| }) | |
| } | |
| /> | |
| )} | |
| </div> | |
| <div className="space-y-3"> | |
| <ActionList | |
| rule={rule} | |
| onUpdate={(bucket, actionIndex, updater) => | |
| onUpdate((draft) => { | |
| updateRuleAction(draft, bucket, actionIndex, updater) | |
| }) | |
| } | |
| /> | |
| <div className="rounded-md border border-border bg-black/20 p-3 text-[0.6875rem] text-muted-foreground"> | |
| Global webhooks still act as the master safety switch. Per-rule webhook actions can carry their own URL. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function ConditionList({ | |
| title, | |
| conditions, | |
| onUpdate, | |
| }: { | |
| title: string | |
| conditions: ConditionBlock[] | |
| onUpdate: (conditionIndex: number, updater: (condition: ConditionBlock) => void) => void | |
| }) { | |
| return ( | |
| <div className="space-y-2"> | |
| <div className="font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground">{title}</div> | |
| {conditions.map((condition, index) => ( | |
| <ConditionEditor | |
| key={index} | |
| condition={condition} | |
| onUpdate={(updater) => onUpdate(index, updater)} | |
| /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| function ConditionEditor({ | |
| condition, | |
| onUpdate, | |
| }: { | |
| condition: ConditionBlock | |
| onUpdate: (updater: (condition: ConditionBlock) => void) => void | |
| }) { | |
| if (condition.present) { | |
| return ( | |
| <EditorBlock label="present"> | |
| <ReadOnlyField label="label" value={condition.present.label} /> | |
| <NumberField label="min count" value={condition.present.min_count} min={1} step={1} onChange={(value) => onUpdate((draft) => { draft.present!.min_count = Math.max(1, Math.round(value)) })} /> | |
| </EditorBlock> | |
| ) | |
| } | |
| if (condition.count) { | |
| return ( | |
| <EditorBlock label="count"> | |
| <ReadOnlyField label="label" value={condition.count.label} /> | |
| <NumberField label="minimum" value={condition.count.minimum} min={1} step={1} onChange={(value) => onUpdate((draft) => { draft.count!.minimum = Math.max(1, Math.round(value)) })} /> | |
| </EditorBlock> | |
| ) | |
| } | |
| if (condition.near) { | |
| return ( | |
| <EditorBlock label="near"> | |
| <ReadOnlyField label="a" value={condition.near.a} /> | |
| <ReadOnlyField label="b" value={condition.near.b} /> | |
| <NumberField label="max gap %" value={condition.near.max_gap_percent} min={0} step={1} onChange={(value) => onUpdate((draft) => { draft.near!.max_gap_percent = value })} /> | |
| </EditorBlock> | |
| ) | |
| } | |
| if (condition.far) { | |
| return ( | |
| <EditorBlock label="far"> | |
| <ReadOnlyField label="a" value={condition.far.a} /> | |
| <ReadOnlyField label="b" value={condition.far.b} /> | |
| <NumberField label="min gap %" value={condition.far.min_gap_percent} min={0} step={1} onChange={(value) => onUpdate((draft) => { draft.far!.min_gap_percent = value })} /> | |
| </EditorBlock> | |
| ) | |
| } | |
| if (condition.moving) { | |
| return ( | |
| <EditorBlock label="moving"> | |
| <ReadOnlyField label="label" value={condition.moving.label} /> | |
| <NumberField label="min move (×size)" value={condition.moving.min_displacement_ratio} min={0} step={0.05} onChange={(value) => onUpdate((draft) => { draft.moving!.min_displacement_ratio = value })} /> | |
| <NumberField label="frames" value={condition.moving.window_frames} min={3} step={1} onChange={(value) => onUpdate((draft) => { draft.moving!.window_frames = Math.max(3, Math.round(value)) })} /> | |
| <NumberField label="misses" value={condition.moving.max_missing_frames} min={0} step={1} onChange={(value) => onUpdate((draft) => { draft.moving!.max_missing_frames = Math.max(0, Math.round(value)) })} /> | |
| </EditorBlock> | |
| ) | |
| } | |
| if (condition.cooldown) { | |
| return ( | |
| <EditorBlock label="cooldown"> | |
| <CooldownEditor cooldown={condition.cooldown} onChange={(next) => onUpdate((draft) => { draft.cooldown = next })} /> | |
| </EditorBlock> | |
| ) | |
| } | |
| return null | |
| } | |
| function CooldownEditor({ | |
| cooldown, | |
| onChange, | |
| }: { | |
| cooldown: CooldownCondition | |
| onChange: (next: CooldownCondition) => void | |
| }) { | |
| return ( | |
| <div className="grid gap-2 sm:grid-cols-3"> | |
| <TextField label="key" value={cooldown.key ?? ""} onChange={(value) => onChange({ ...cooldown, key: value || null })} /> | |
| <OptionalNumberField label="seconds" value={cooldown.seconds} onChange={(value) => onChange({ ...cooldown, seconds: value, minutes: null })} /> | |
| <OptionalNumberField label="minutes" value={cooldown.minutes} onChange={(value) => onChange({ ...cooldown, minutes: value, seconds: null })} /> | |
| </div> | |
| ) | |
| } | |
| function ActionList({ | |
| rule, | |
| onUpdate, | |
| }: { | |
| rule: AutomationRule | |
| onUpdate: (bucket: ActionBucket, actionIndex: number, updater: (action: ActionSpec) => void) => void | |
| }) { | |
| const actions = editableActions(rule) | |
| return ( | |
| <div className="space-y-2"> | |
| <div className="font-mono text-[0.625rem] uppercase tracking-wider text-muted-foreground">Actions</div> | |
| {actions.map(({ action, bucket, actionIndex, label }) => ( | |
| <EditorBlock key={`${bucket}-${actionIndex}`} label={label}> | |
| <TextField label="name" value={action.name} onChange={(value) => onUpdate(bucket, actionIndex, (draft) => { draft.name = value })} /> | |
| <div className="space-y-1"> | |
| <Label className="text-[0.6875rem] text-muted-foreground">type</Label> | |
| <select | |
| value={action.type} | |
| onChange={(event) => onUpdate(bucket, actionIndex, (draft) => { draft.type = event.target.value as ActionSpec["type"] })} | |
| className="h-9 w-full rounded-md border border-input bg-black/20 px-3 text-sm text-foreground focus-visible:outline-none focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring" | |
| > | |
| <option value="simulate">simulate</option> | |
| <option value="webhook">webhook</option> | |
| </select> | |
| </div> | |
| {action.type === "webhook" && ( | |
| <TextField label="url" value={action.url ?? ""} onChange={(value) => onUpdate(bucket, actionIndex, (draft) => { draft.url = value || null })} /> | |
| )} | |
| </EditorBlock> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| function EditorBlock({ label, children }: { label: string; children: React.ReactNode }) { | |
| return ( | |
| <div className="rounded-md border border-border bg-black/20 p-3"> | |
| <div className="mb-2 font-mono text-[0.625rem] uppercase tracking-wider text-primary">{label}</div> | |
| <div className="grid gap-2 sm:grid-cols-2">{children}</div> | |
| </div> | |
| ) | |
| } | |
| function TextField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { | |
| return ( | |
| <div className="space-y-1"> | |
| <Label className="text-[0.6875rem] text-muted-foreground">{label}</Label> | |
| <Input value={value} onChange={(event) => onChange(event.target.value)} /> | |
| </div> | |
| ) | |
| } | |
| function ReadOnlyField({ label, value }: { label: string; value: string }) { | |
| return ( | |
| <div className="space-y-1"> | |
| <Label className="text-[0.6875rem] text-muted-foreground">{label}</Label> | |
| <div className="flex h-9 items-center rounded-md border border-border bg-black/30 px-3 font-mono text-xs text-muted-foreground"> | |
| {value} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function NumberField({ | |
| label, | |
| value, | |
| min, | |
| step, | |
| onChange, | |
| }: { | |
| label: string | |
| value: number | |
| min?: number | |
| step?: number | |
| onChange: (value: number) => void | |
| }) { | |
| return ( | |
| <div className="space-y-1"> | |
| <Label className="text-[0.6875rem] text-muted-foreground">{label}</Label> | |
| <Input | |
| type="number" | |
| value={Number.isFinite(value) ? value : 0} | |
| min={min} | |
| step={step} | |
| onChange={(event) => onChange(Number(event.target.value || 0))} | |
| /> | |
| </div> | |
| ) | |
| } | |
| function OptionalNumberField({ | |
| label, | |
| value, | |
| onChange, | |
| }: { | |
| label: string | |
| value: number | null | |
| onChange: (value: number | null) => void | |
| }) { | |
| return ( | |
| <div className="space-y-1"> | |
| <Label className="text-[0.6875rem] text-muted-foreground">{label}</Label> | |
| <Input | |
| type="number" | |
| value={value ?? ""} | |
| min={0} | |
| step={1} | |
| onChange={(event) => onChange(event.target.value === "" ? null : Number(event.target.value))} | |
| /> | |
| </div> | |
| ) | |
| } | |
| type ActionBucket = "then" | "enter" | "exit" | "while" | |
| function editableActions(rule: AutomationRule): Array<{ | |
| bucket: ActionBucket | |
| actionIndex: number | |
| label: string | |
| action: ActionSpec | |
| }> { | |
| if (Array.isArray(rule.then)) { | |
| return rule.then.map((action, actionIndex) => ({ bucket: "then", actionIndex, label: "then", action })) | |
| } | |
| return [ | |
| ...rule.then.enter.map((action, actionIndex) => ({ bucket: "enter" as const, actionIndex, label: "enter", action })), | |
| ...rule.then.exit.map((action, actionIndex) => ({ bucket: "exit" as const, actionIndex, label: "exit", action })), | |
| ...rule.then.while.map((action, actionIndex) => ({ bucket: "while" as const, actionIndex, label: "while", action })), | |
| ] | |
| } | |
| function ruleActions(rule: AutomationRule): ActionSpec[] { | |
| return editableActions(rule).map((item) => item.action) | |
| } | |
| function updateRuleAction( | |
| rule: AutomationRule, | |
| bucket: ActionBucket, | |
| actionIndex: number, | |
| updater: (action: ActionSpec) => void, | |
| ) { | |
| if (Array.isArray(rule.then)) { | |
| const action = rule.then[actionIndex] | |
| if (action) updater(action) | |
| return | |
| } | |
| const action = rule.then[bucket === "then" ? "enter" : bucket][actionIndex] | |
| if (action) updater(action) | |
| } | |
| function cloneDocument(document: AutomationDocument): AutomationDocument { | |
| return JSON.parse(JSON.stringify(document)) as AutomationDocument | |
| } | |