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
+31 -2
View File
@@ -43,6 +43,7 @@ import { GlobalObservabilityView } from './GlobalObservabilityView';
import { FleetView } from './FleetView';
import { AuditLogView } from './AuditLogView';
import ScheduledOperationsView from './ScheduledOperationsView';
import AutoUpdatePoliciesView from './AutoUpdatePoliciesView';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
@@ -126,7 +127,7 @@ export default function EditorLayout() {
window.matchMedia('(prefers-color-scheme: dark)').matches
);
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops'>('dashboard');
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates'>('dashboard');
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
@@ -168,6 +169,9 @@ export default function EditorLayout() {
{ value: 'templates', label: 'App Store', icon: CloudDownload },
{ value: 'global-observability', label: 'Logs', icon: Activity },
);
if (isPro && isAdmin) {
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
}
if (isPro && license?.variant === 'team') {
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
@@ -445,6 +449,10 @@ export default function EditorLayout() {
refreshStacks();
fetchImageUpdates();
// Poll for image update results every 5 minutes so background checks are picked up
const imageUpdateInterval = setInterval(fetchImageUpdates, 5 * 60 * 1000);
return () => clearInterval(imageUpdateInterval);
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchNotifications = async () => {
@@ -979,6 +987,7 @@ export default function EditorLayout() {
setContainers(Array.isArray(conts) ? conts : []);
}
await refreshStacks(true);
if (action === 'update') fetchImageUpdates();
if (action === 'deploy' && isPro) {
try {
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
@@ -999,7 +1008,25 @@ export default function EditorLayout() {
const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
if (res.ok) {
toast.success('Checking for image updates...');
setTimeout(() => fetchImageUpdates(), 3000);
// Poll until the background check completes instead of using a fixed timeout
let elapsed = 0;
const poll = setInterval(async () => {
elapsed += 2000;
try {
const statusRes = await apiFetch('/image-updates/status');
if (statusRes.ok) {
const { checking } = await statusRes.json();
if (!checking || elapsed >= 60000) {
clearInterval(poll);
await fetchImageUpdates();
if (!checking) toast.success('Image update check complete.');
}
}
} catch {
clearInterval(poll);
await fetchImageUpdates();
}
}, 2000);
} else {
const data = await res.json().catch(() => ({}));
toast.error(data.error || 'Failed to check for updates');
@@ -1809,6 +1836,8 @@ export default function EditorLayout() {
}} />
) : activeView === 'audit-log' ? (
<AuditLogView />
) : activeView === 'auto-updates' ? (
<AutoUpdatePoliciesView />
) : activeView === 'scheduled-ops' ? (
<ScheduledOperationsView />
) : (