mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user