feat(auto-update): add auto-update policies and fix image update detection (#297)

* feat(auto-update): add auto-update policies and fix image update detection

Auto-Update Policies (Skipper+ tier):
- New scheduled task action type 'update' for check-then-update flow
- Dedicated AutoUpdatePoliciesView with CRUD, cron presets, and run history
- Conditional tier gating: Skipper gets auto-update, Admiral gets full scheduled ops
- Backend executeUpdate: checks digests, pulls only if newer, atomic redeploy

Image Update Detection fixes (all tiers):
- Fix stack name key mismatch: use working_dir label instead of project label
- Add 5-minute periodic frontend polling for background check results
- Replace fixed 3s timeout with polling-based manual refresh via /api/image-updates/status
- Clear update status after successful stack update

* fix(ui): remove Skipper tier badge from Auto-Update Policies header

* fix(ui): remove auto-update action from Scheduled Operations view

Admiral users have a dedicated Auto-Update view — showing update tasks
in Scheduled Operations too was confusing duplication. Each view now
owns a distinct, non-overlapping set of action types.

* fix(auto-update): fix node-stack linking and add All Stacks option

- Stack dropdown now re-fetches when node selection changes using
  fetchForNode, and resets the selected stack
- Node selector moved above stack selector with stack disabled until
  a node is picked
- Added "All Stacks" wildcard option that checks and updates every
  stack on the selected node
- Backend executeUpdate refactored to iterate over all stacks when
  target_id is "*", with per-stack error isolation

* refactor(ui): replace Select dropdowns with searchable Combobox component

Add a reusable Combobox component with inline search and use it for
Node/Stack selectors in both Auto-Update Policies and Scheduled
Operations dialogs. Also fixes node-stack linking bug where changing
node didn't update the stack list.

* fix(ui): resolve CI TypeScript errors in Combobox and ScheduledOperationsView

Add missing searchPlaceholder prop to ComboboxProps interface and remove
dead 'update' action filter that conflicted with the narrowed type union.

* fix(ui): use Geist Sans font in toast component

The toast renders via React portal on document.body, bypassing the app's
font inheritance. Add explicit font-family declaration using var(--font-sans)
to match Sencho's design system.
This commit is contained in:
Anso
2026-04-01 00:22:47 -04:00
committed by GitHub
parent d393d06885
commit 28c7a8fd54
14 changed files with 1073 additions and 57 deletions
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
@@ -13,7 +12,8 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { apiFetch, fetchForNode } from '@/lib/api';
import { Combobox } from '@/components/ui/combobox';
import cronstrue from 'cronstrue';
interface ScheduledTask {
@@ -117,9 +117,11 @@ export default function ScheduledOperationsView() {
}
}, []);
const fetchStacks = useCallback(async () => {
const fetchStacks = useCallback(async (nodeId?: string) => {
try {
const res = await apiFetch('/stacks');
const res = nodeId
? await fetchForNode('/stacks', parseInt(nodeId, 10))
: await apiFetch('/stacks');
if (res.ok) {
setStacks(await res.json());
}
@@ -166,6 +168,17 @@ export default function ScheduledOperationsView() {
return () => { cancelled = true; };
}, [formAction, formTargetId]);
// Re-fetch stacks when node changes
useEffect(() => {
if (!dialogOpen) return;
if (formNodeId) {
fetchStacks(formNodeId);
setFormTargetId('');
} else {
setStacks([]);
}
}, [formNodeId, dialogOpen, fetchStacks]);
const openCreate = () => {
setEditingTask(null);
setFormName('');
@@ -435,45 +448,34 @@ export default function ScheduledOperationsView() {
<div className="space-y-2">
<Label>Action</Label>
<Select value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
options={ACTION_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
value={formAction}
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
placeholder="Select action..."
/>
</div>
{targetType === 'stack' && (
<>
<div className="space-y-2">
<Label>Stack</Label>
<Select value={formTargetId} onValueChange={setFormTargetId}>
<SelectTrigger>
<SelectValue placeholder="Select stack..." />
</SelectTrigger>
<SelectContent>
{stacks.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Label>Node</Label>
<Combobox
options={nodes.map(n => ({ value: String(n.id), label: n.name }))}
value={formNodeId}
onValueChange={setFormNodeId}
placeholder="Select node..."
/>
</div>
<div className="space-y-2">
<Label>Node</Label>
<Select value={formNodeId} onValueChange={setFormNodeId}>
<SelectTrigger>
<SelectValue placeholder="Select node..." />
</SelectTrigger>
<SelectContent>
{nodes.map(n => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label>Stack</Label>
<Combobox
options={stacks.map(s => ({ value: s, label: s }))}
value={formTargetId}
onValueChange={setFormTargetId}
placeholder={formNodeId ? "Select stack..." : "Select a node first"}
disabled={!formNodeId}
/>
</div>
{formAction === 'restart' && formTargetId && availableServices.length > 0 && (
<div className="space-y-2">