mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
feat: add sidebar update indicator toggle and Stack Health badge (#1570)
* feat: add sidebar update indicator toggle and Stack Health badge - Add image_update_sidebar_indicators setting (default off, node-scoped) - Gate the Updates filter chip and sidebar status indicators on the setting - Add "Update available" badge to Stack Health table (always visible) - Extend ImageUpdateStatus with sidebarIndicators boolean - Poll /api/image-updates/status alongside /detail in useImageUpdates - React to SENCHO_SETTINGS_CHANGED for instant toggle propagation - Reset sidebar state on node switch; generation-guard stale responses - Disable toggle when status is null (loading) or field is absent (old node) - Wire stackUpdates through ViewRouter → HomeDashboard → StackHealthTable - Update settings registry, operator docs, and sidebar/dashboard docs * fix: guard against stale node renders, memo drift, and cross-node error toasts - Track owning node ID in useImageUpdates state so React never renders node B with node A's data before the passive effect resets (P2) - Replace incorrect stackUpdates dependency with sidebarStackUpdates in chipFilteredFiles useMemo (P3) - Guard the error toast in handleSidebarIndicatorsChange so a stale PATCH failure from node A does not surface while viewing node B (P3) * fix: default sidebar update indicators to on (opt-out) The sidebar indicators are a safe convenience that most users want. Switching the default from off to on matches the opt-out convention used by prune_on_update, reclaim_hero, and health_gate_enabled.
This commit is contained in:
@@ -32,6 +32,7 @@ const ALLOWED_SETTING_KEYS = new Set([
|
|||||||
'health_gate_enabled',
|
'health_gate_enabled',
|
||||||
'health_gate_window_seconds',
|
'health_gate_window_seconds',
|
||||||
'env_block_deploy_on_missing_required',
|
'env_block_deploy_on_missing_required',
|
||||||
|
'image_update_sidebar_indicators',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Keys whose write requires a paid license, not just an admin role.
|
// Keys whose write requires a paid license, not just an admin role.
|
||||||
@@ -62,6 +63,7 @@ const SettingsPatchSchema = z.object({
|
|||||||
health_gate_enabled: z.enum(['0', '1']),
|
health_gate_enabled: z.enum(['0', '1']),
|
||||||
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
|
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
|
||||||
env_block_deploy_on_missing_required: z.enum(['0', '1']),
|
env_block_deploy_on_missing_required: z.enum(['0', '1']),
|
||||||
|
image_update_sidebar_indicators: z.enum(['0', '1']),
|
||||||
}).partial();
|
}).partial();
|
||||||
|
|
||||||
export const settingsRouter = Router();
|
export const settingsRouter = Router();
|
||||||
|
|||||||
@@ -1665,6 +1665,7 @@ export class DatabaseService {
|
|||||||
stmt.run('image_update_check_interval_minutes', '120');
|
stmt.run('image_update_check_interval_minutes', '120');
|
||||||
stmt.run('image_update_check_mode', 'interval');
|
stmt.run('image_update_check_mode', 'interval');
|
||||||
stmt.run('image_update_check_cron', '');
|
stmt.run('image_update_check_cron', '');
|
||||||
|
stmt.run('image_update_sidebar_indicators', '1');
|
||||||
stmt.run('env_block_deploy_on_missing_required', '0');
|
stmt.run('env_block_deploy_on_missing_required', '0');
|
||||||
|
|
||||||
// Seed the default local node if none exists
|
// Seed the default local node if none exists
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface ImageUpdateStatus {
|
|||||||
manualCooldownRemainingMs: number;
|
manualCooldownRemainingMs: number;
|
||||||
mode: 'interval' | 'cron';
|
mode: 'interval' | 'cron';
|
||||||
cronExpression: string | null;
|
cronExpression: string | null;
|
||||||
|
sidebarIndicators: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Compose file helpers ────────────────────────────────────────────────────
|
// ─── Compose file helpers ────────────────────────────────────────────────────
|
||||||
@@ -390,6 +391,13 @@ export class ImageUpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getStatus(): ImageUpdateStatus {
|
public getStatus(): ImageUpdateStatus {
|
||||||
|
let sidebarIndicators = false;
|
||||||
|
try {
|
||||||
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||||
|
sidebarIndicators = settings.image_update_sidebar_indicators === '1';
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ImageUpdateService] Failed to read sidebar indicator setting:', e);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
checking: this.isRunning,
|
checking: this.isRunning,
|
||||||
intervalMinutes: Math.round(this.intervalMs / (60 * 1000)),
|
intervalMinutes: Math.round(this.intervalMs / (60 * 1000)),
|
||||||
@@ -399,6 +407,7 @@ export class ImageUpdateService {
|
|||||||
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
|
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
cronExpression: this.cronExpression,
|
cronExpression: this.cronExpression,
|
||||||
|
sidebarIndicators,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ A mono table of every stack discovered in the active node's `COMPOSE_DIR`, sorte
|
|||||||
| Column | Description |
|
| Column | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **Status dot** | Green when the stack is running and its 10-minute peak CPU is under 80%, amber when peak CPU is at or above 80%, rose when any container has exited or peak CPU is at or above 90% |
|
| **Status dot** | Green when the stack is running and its 10-minute peak CPU is under 80%, amber when peak CPU is at or above 80%, rose when any container has exited or peak CPU is at or above 90% |
|
||||||
| **STACK** | Stack name, derived from the compose file (extension stripped) |
|
| **STACK** | Stack name with an orange "Update available" badge when a newer image has been detected. The badge appears regardless of the sidebar indicator setting. |
|
||||||
| **HOST** | Active node this stack belongs to |
|
| **HOST** | Active node this stack belongs to |
|
||||||
| **UP** | How long the oldest running container has been up, in compact units (`s` / `m` / `h` / `d`); a stopped or never-started stack reads `--` |
|
| **UP** | How long the oldest running container has been up, in compact units (`s` / `m` / `h` / `d`); a stopped or never-started stack reads `--` |
|
||||||
| **CPU** | Latest aggregate CPU across the stack's containers |
|
| **CPU** | Latest aggregate CPU across the stack's containers |
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Four chips sit below the search box. Each shows a live count to the right of its
|
|||||||
- **All**: every stack on the node.
|
- **All**: every stack on the node.
|
||||||
- **Up**: stacks that are running with nothing crashed. A stack whose only stopped container finished cleanly (an init job that exited without error) still counts as up.
|
- **Up**: stacks that are running with nothing crashed. A stack whose only stopped container finished cleanly (an init job that exited without error) still counts as up.
|
||||||
- **Down**: stacks that need attention, whether fully stopped or running with at least one crashed container (the `PT` state described below).
|
- **Down**: stacks that need attention, whether fully stopped or running with at least one crashed container (the `PT` state described below).
|
||||||
- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance.
|
- **Updates**: stacks with at least one image update available. The chip renders in orange when the count is non-zero so you can spot pending updates at a glance. The Updates chip and the trailing update indicators on stack rows are controlled by the Image Update Checks [sidebar setting](/reference/settings#image-update-checks---sidebar). When the setting is off, the chip and indicators are hidden.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/sidebar/sidebar-filter-chips.png" alt="Filter chip row showing ALL (15), Up (15), Down (0), and Updates (1) with the Updates chip highlighted in orange and a collapse toggle icon on the right" />
|
<img src="/images/sidebar/sidebar-filter-chips.png" alt="Filter chip row showing ALL (15), Up (15), Down (0), and Updates (1) with the Updates chip highlighted in orange and a collapse toggle icon on the right" />
|
||||||
|
|||||||
@@ -686,6 +686,9 @@ components:
|
|||||||
cronExpression:
|
cronExpression:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
description: 5-field cron expression when mode is 'cron', null otherwise.
|
description: 5-field cron expression when mode is 'cron', null otherwise.
|
||||||
|
sidebarIndicators:
|
||||||
|
type: boolean
|
||||||
|
description: Whether sidebar update-status indicators are enabled. Controlled by the `image_update_sidebar_indicators` global setting. Default is `false`.
|
||||||
|
|
||||||
responses:
|
responses:
|
||||||
Unauthorized:
|
Unauthorized:
|
||||||
|
|||||||
@@ -465,6 +465,16 @@ Configure how often this node polls container registries to detect available ima
|
|||||||
|
|
||||||
The section footer shows the last-checked timestamp and when the next check is scheduled.
|
The section footer shows the last-checked timestamp and when the next check is scheduled.
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
|
||||||
|
| Setting | Default | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| **Show update status in sidebar** | On | When on, the sidebar shows a pulsing dot on stacks with an available update, a warning icon when a check fails, and an Updates filter chip. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected. |
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Nodes running older versions of Sencho do not expose this setting. Upgrade the node to enable the toggle.
|
||||||
|
</Note>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Webhooks
|
## Webhooks
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export default function EditorLayout() {
|
|||||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||||
stackUpdates,
|
stackUpdates,
|
||||||
fetchImageUpdates,
|
fetchImageUpdates,
|
||||||
|
sidebarIndicators,
|
||||||
|
sidebarStackUpdates,
|
||||||
pinned,
|
pinned,
|
||||||
isCollapsed, toggleCollapse,
|
isCollapsed, toggleCollapse,
|
||||||
remoteSearchLoading,
|
remoteSearchLoading,
|
||||||
@@ -669,7 +671,7 @@ export default function EditorLayout() {
|
|||||||
stackLabelMap,
|
stackLabelMap,
|
||||||
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
||||||
stackCounts,
|
stackCounts,
|
||||||
stackUpdates,
|
stackUpdates: sidebarStackUpdates,
|
||||||
gitSourcePendingMap,
|
gitSourcePendingMap,
|
||||||
pinnedFiles: pinned,
|
pinnedFiles: pinned,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
@@ -697,6 +699,7 @@ export default function EditorLayout() {
|
|||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
onBulkAction={handleBulkAction}
|
onBulkAction={handleBulkAction}
|
||||||
|
showUpdatesChip={sidebarIndicators}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -777,6 +780,7 @@ export default function EditorLayout() {
|
|||||||
fleetTab={fleetTab}
|
fleetTab={fleetTab}
|
||||||
onFleetTabConsumed={() => setFleetTab(null)}
|
onFleetTabConsumed={() => setFleetTab(null)}
|
||||||
renderEditor={renderEditor}
|
renderEditor={renderEditor}
|
||||||
|
stackUpdates={stackUpdates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { NotificationItem } from '../dashboard/types';
|
|||||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||||
|
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||||
|
|
||||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||||
@@ -99,6 +100,7 @@ export interface ViewRouterProps {
|
|||||||
// (large) editor JSX is only allocated when activeView === 'editor',
|
// (large) editor JSX is only allocated when activeView === 'editor',
|
||||||
// not on every parent render that lands on a different view.
|
// not on every parent render that lands on a different view.
|
||||||
renderEditor: () => ReactNode;
|
renderEditor: () => ReactNode;
|
||||||
|
stackUpdates: Record<string, StackUpdateInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ViewRouter({
|
export function ViewRouter({
|
||||||
@@ -128,6 +130,7 @@ export function ViewRouter({
|
|||||||
fleetTab,
|
fleetTab,
|
||||||
onFleetTabConsumed,
|
onFleetTabConsumed,
|
||||||
renderEditor,
|
renderEditor,
|
||||||
|
stackUpdates,
|
||||||
}: ViewRouterProps): ReactNode {
|
}: ViewRouterProps): ReactNode {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
if (activeView === 'settings') {
|
if (activeView === 'settings') {
|
||||||
@@ -251,6 +254,7 @@ export function ViewRouter({
|
|||||||
onOpenSettingsSection={onOpenSettingsSection}
|
onOpenSettingsSection={onOpenSettingsSection}
|
||||||
notifications={notifications}
|
notifications={notifications}
|
||||||
onClearNotifications={onClearNotifications}
|
onClearNotifications={onClearNotifications}
|
||||||
|
stackUpdates={stackUpdates}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
|
|||||||
import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions';
|
import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions';
|
||||||
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
|
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
|
||||||
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
|
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
|
||||||
|
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||||
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
||||||
import type { StackAction, StackActionResult } from '../EditorView';
|
import type { StackAction, StackActionResult } from '../EditorView';
|
||||||
import type { Label as StackLabel } from '../../label-types';
|
import type { Label as StackLabel } from '../../label-types';
|
||||||
@@ -60,6 +61,8 @@ export interface RemoteResult {
|
|||||||
files: Array<{ file: string; status: StackRowStatus }>;
|
files: Array<{ file: string; status: StackRowStatus }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_UPDATES: Record<string, StackUpdateInfo> = {};
|
||||||
|
|
||||||
export function useStackListState() {
|
export function useStackListState() {
|
||||||
const { nodes, activeNode } = useNodes();
|
const { nodes, activeNode } = useNodes();
|
||||||
|
|
||||||
@@ -96,7 +99,8 @@ export function useStackListState() {
|
|||||||
const [bulkMode, setBulkMode] = useState(false);
|
const [bulkMode, setBulkMode] = useState(false);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id);
|
const { stackUpdates, refresh: fetchImageUpdates, sidebarIndicators } = useImageUpdates(activeNode?.id);
|
||||||
|
const sidebarStackUpdates = sidebarIndicators ? stackUpdates : EMPTY_UPDATES;
|
||||||
const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id);
|
const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id);
|
||||||
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
|
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
|
||||||
const { runBulk } = useBulkStackActions();
|
const { runBulk } = useBulkStackActions();
|
||||||
@@ -295,16 +299,16 @@ export function useStackListState() {
|
|||||||
all: filteredFiles.length,
|
all: filteredFiles.length,
|
||||||
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
|
up: filteredFiles.filter(f => stackStatuses[f] === 'running').length,
|
||||||
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
|
down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length,
|
||||||
updates: filteredFiles.filter(f => stackUpdates[f]?.hasUpdate).length,
|
updates: filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate).length,
|
||||||
}), [filteredFiles, stackStatuses, stackUpdates]);
|
}), [filteredFiles, stackStatuses, sidebarStackUpdates]);
|
||||||
|
|
||||||
const chipFilteredFiles = useMemo(() => {
|
const chipFilteredFiles = useMemo(() => {
|
||||||
if (filterChip === 'all') return filteredFiles;
|
if (filterChip === 'all') return filteredFiles;
|
||||||
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
|
if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running');
|
||||||
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
|
if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f]));
|
||||||
if (filterChip === 'updates') return filteredFiles.filter(f => stackUpdates[f]?.hasUpdate);
|
if (filterChip === 'updates') return filteredFiles.filter(f => sidebarStackUpdates[f]?.hasUpdate);
|
||||||
return filteredFiles;
|
return filteredFiles;
|
||||||
}, [filteredFiles, filterChip, stackStatuses, stackUpdates]);
|
}, [filteredFiles, filterChip, stackStatuses, sidebarStackUpdates]);
|
||||||
|
|
||||||
const toggleBulkMode = useCallback(() => {
|
const toggleBulkMode = useCallback(() => {
|
||||||
setBulkMode(prev => {
|
setBulkMode(prev => {
|
||||||
@@ -381,6 +385,15 @@ export function useStackListState() {
|
|||||||
});
|
});
|
||||||
}, [remoteStackResults, nodes]);
|
}, [remoteStackResults, nodes]);
|
||||||
|
|
||||||
|
// When the sidebar indicator toggle is turned off, reset an active Updates
|
||||||
|
// filter to 'all' so the user is not stuck in a filter that shows nothing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarIndicators && filterChip === 'updates') {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setFilterChip('all');
|
||||||
|
}
|
||||||
|
}, [sidebarIndicators, filterChip]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
files, setFiles, filesNodeId,
|
files, setFiles, filesNodeId,
|
||||||
selectedFile, setSelectedFile,
|
selectedFile, setSelectedFile,
|
||||||
@@ -410,6 +423,7 @@ export function useStackListState() {
|
|||||||
scheduleStateInvalidateRefresh,
|
scheduleStateInvalidateRefresh,
|
||||||
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
||||||
stackUpdates, fetchImageUpdates,
|
stackUpdates, fetchImageUpdates,
|
||||||
|
sidebarIndicators, sidebarStackUpdates,
|
||||||
pinned, pin, unpin, isPinned,
|
pinned, pin, unpin, isPinned,
|
||||||
isCollapsed, toggleCollapse,
|
isCollapsed, toggleCollapse,
|
||||||
remoteSearchLoading,
|
remoteSearchLoading,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import type { NotificationItem } from './dashboard/types';
|
import type { NotificationItem } from './dashboard/types';
|
||||||
import type { SectionId } from './settings/types';
|
import type { SectionId } from './settings/types';
|
||||||
|
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||||
import {
|
import {
|
||||||
HealthStatusBar,
|
HealthStatusBar,
|
||||||
ResourceGauges,
|
ResourceGauges,
|
||||||
@@ -16,11 +17,12 @@ interface HomeDashboardProps {
|
|||||||
onOpenSettingsSection?: (section: SectionId) => void;
|
onOpenSettingsSection?: (section: SectionId) => void;
|
||||||
notifications: NotificationItem[];
|
notifications: NotificationItem[];
|
||||||
onClearNotifications: () => void | Promise<void>;
|
onClearNotifications: () => void | Promise<void>;
|
||||||
|
stackUpdates?: Record<string, StackUpdateInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOOP = () => {};
|
const NOOP = () => {};
|
||||||
|
|
||||||
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications }: HomeDashboardProps) {
|
export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection, notifications, onClearNotifications, stackUpdates = {} }: HomeDashboardProps) {
|
||||||
const { activeNode, nodes } = useNodes();
|
const { activeNode, nodes } = useNodes();
|
||||||
const data = useDashboardData();
|
const data = useDashboardData();
|
||||||
const activeNodeName = activeNode?.name || 'Local';
|
const activeNodeName = activeNode?.name || 'Local';
|
||||||
@@ -49,6 +51,7 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
|
|||||||
metrics={data.metrics}
|
metrics={data.metrics}
|
||||||
stackCpuSeries={data.stackCpuSeries}
|
stackCpuSeries={data.stackCpuSeries}
|
||||||
onNavigateToStack={onNavigateToStack ?? NOOP}
|
onNavigateToStack={onNavigateToStack ?? NOOP}
|
||||||
|
stackUpdates={stackUpdates}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Sparkline } from '@/components/ui/sparkline';
|
|||||||
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react';
|
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
|
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
|
||||||
|
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||||
import { classifyRow, type RowState } from './classifyRow';
|
import { classifyRow, type RowState } from './classifyRow';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface StackHealthTableProps {
|
|||||||
metrics: MetricPoint[];
|
metrics: MetricPoint[];
|
||||||
stackCpuSeries: Record<string, StackCpuSeries>;
|
stackCpuSeries: Record<string, StackCpuSeries>;
|
||||||
onNavigateToStack: (stackFile: string) => void;
|
onNavigateToStack: (stackFile: string) => void;
|
||||||
|
stackUpdates?: Record<string, StackUpdateInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = 'stack' | 'up' | 'cpu' | 'mem';
|
type SortKey = 'stack' | 'up' | 'cpu' | 'mem';
|
||||||
@@ -90,6 +92,7 @@ export function StackHealthTable({
|
|||||||
metrics,
|
metrics,
|
||||||
stackCpuSeries,
|
stackCpuSeries,
|
||||||
onNavigateToStack,
|
onNavigateToStack,
|
||||||
|
stackUpdates = {},
|
||||||
}: StackHealthTableProps) {
|
}: StackHealthTableProps) {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
// null = the default health-state ordering (worst first); a SortKey switches
|
// null = the default health-state ordering (worst first); a SortKey switches
|
||||||
@@ -127,9 +130,10 @@ export function StackHealthTable({
|
|||||||
runningSince: entry.runningSince ?? null,
|
runningSince: entry.runningSince ?? null,
|
||||||
source: entry.source ?? 'local',
|
source: entry.source ?? 'local',
|
||||||
mainPort: entry.mainPort ?? null,
|
mainPort: entry.mainPort ?? null,
|
||||||
|
hasUpdate: stackUpdates[file]?.hasUpdate ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [stackStatuses, stackAggregates, stackCpuSeries]);
|
}, [stackStatuses, stackAggregates, stackCpuSeries, stackUpdates]);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const list = [...baseRows];
|
const list = [...baseRows];
|
||||||
@@ -246,7 +250,14 @@ export function StackHealthTable({
|
|||||||
className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`}
|
className={`grid ${GRID_TEMPLATE} cursor-pointer items-center gap-4 px-[var(--density-row-x)] py-[var(--density-row-y)] transition-colors hover:bg-accent/5 ${rowTint[row.state]}`}
|
||||||
>
|
>
|
||||||
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
|
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
|
||||||
<span className="truncate font-mono text-sm text-stat-value">{row.name}</span>
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="flex-1 min-w-0 truncate font-mono text-sm text-stat-value">{row.name}</span>
|
||||||
|
{row.hasUpdate && (
|
||||||
|
<span className="shrink-0 rounded-full bg-update/15 px-2 py-0.5 font-mono text-[10px] leading-none text-update tracking-wide">
|
||||||
|
Update available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="truncate font-mono text-[11px] uppercase tracking-wide text-stat-subtitle">
|
<span className="truncate font-mono text-[11px] uppercase tracking-wide text-stat-subtitle">
|
||||||
{row.source === 'git' ? 'Git' : 'Local'}
|
{row.source === 'git' ? 'Git' : 'Local'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -12,6 +13,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
|
|||||||
import { SettingsPrimaryButton } from './SettingsActions';
|
import { SettingsPrimaryButton } from './SettingsActions';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
|
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime';
|
import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime';
|
||||||
@@ -61,6 +63,49 @@ export function UpdatesSection() {
|
|||||||
|
|
||||||
const intervalMinutes = status?.intervalMinutes ?? null;
|
const intervalMinutes = status?.intervalMinutes ?? null;
|
||||||
|
|
||||||
|
// Mirror activeNode.id in a ref so the PATCH handler can detect a node
|
||||||
|
// switch mid-flight and discard a stale write.
|
||||||
|
const activeNodeIdRef = useRef(activeNode?.id ?? null);
|
||||||
|
activeNodeIdRef.current = activeNode?.id ?? null;
|
||||||
|
|
||||||
|
// Derive toggle state from the current status. When the field is missing
|
||||||
|
// (older remote node) the toggle is disabled with a helpful message.
|
||||||
|
const sidebarIndicators = status?.sidebarIndicators ?? false;
|
||||||
|
const nodeSupportsSidebarSetting = status !== null && status.sidebarIndicators !== undefined;
|
||||||
|
|
||||||
|
const handleSidebarIndicatorsChange = useCallback(async (next: boolean) => {
|
||||||
|
const targetNodeId = activeNodeIdRef.current;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
nodeId: targetNodeId ?? null,
|
||||||
|
body: JSON.stringify({ image_update_sidebar_indicators: next ? '1' : '0' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err?.error || 'Failed to update setting');
|
||||||
|
}
|
||||||
|
// Guard: if the active node changed while the PATCH was in flight,
|
||||||
|
// discard the response — it belongs to a different node.
|
||||||
|
if (activeNodeIdRef.current === targetNodeId) {
|
||||||
|
setStatus(prev => prev ? { ...prev, sidebarIndicators: next } : prev);
|
||||||
|
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
|
||||||
|
detail: { changedKeys: ['image_update_sidebar_indicators'] },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Only surface the error if the active node hasn't changed. A
|
||||||
|
// stale failure from node A must not toast while the user views
|
||||||
|
// node B.
|
||||||
|
if (activeNodeIdRef.current === targetNodeId) {
|
||||||
|
toast.error((e as Error)?.message || 'Failed to update sidebar indicator setting.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useMastheadStats(
|
useMastheadStats(
|
||||||
isLoading || intervalMinutes == null
|
isLoading || intervalMinutes == null
|
||||||
? null
|
? null
|
||||||
@@ -70,6 +115,7 @@ export function UpdatesSection() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
|
setStatus(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/image-updates/status');
|
const res = await apiFetch('/image-updates/status');
|
||||||
@@ -280,6 +326,25 @@ export function UpdatesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsField>
|
</SettingsField>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="Sidebar" kicker="node-scoped">
|
||||||
|
<SettingsField
|
||||||
|
label="Show update status in sidebar"
|
||||||
|
helper={
|
||||||
|
status !== null && status.sidebarIndicators === undefined
|
||||||
|
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
|
||||||
|
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
|
||||||
|
}
|
||||||
|
htmlFor="sidebar-indicators-toggle"
|
||||||
|
>
|
||||||
|
<TogglePill
|
||||||
|
id="sidebar-indicators-toggle"
|
||||||
|
checked={sidebarIndicators}
|
||||||
|
onChange={handleSidebarIndicatorsChange}
|
||||||
|
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
|
||||||
|
/>
|
||||||
|
</SettingsField>
|
||||||
|
</SettingsSection>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|||||||
group: 'automation',
|
group: 'automation',
|
||||||
label: 'Image update checks',
|
label: 'Image update checks',
|
||||||
description: 'How often this node polls registries to detect available image updates and raise notifications.',
|
description: 'How often this node polls registries to detect available image updates and raise notifications.',
|
||||||
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck'],
|
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck', 'sidebar', 'badge', 'dot', 'indicator', 'status'],
|
||||||
tier: null,
|
tier: null,
|
||||||
scope: 'node',
|
scope: 'node',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface PatchableSettings {
|
|||||||
health_gate_enabled?: '0' | '1';
|
health_gate_enabled?: '0' | '1';
|
||||||
health_gate_window_seconds?: string;
|
health_gate_window_seconds?: string;
|
||||||
env_block_deploy_on_missing_required?: '0' | '1';
|
env_block_deploy_on_missing_required?: '0' | '1';
|
||||||
|
image_update_sidebar_indicators?: '0' | '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: PatchableSettings = {
|
export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||||
@@ -44,6 +45,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
|||||||
health_gate_enabled: '1',
|
health_gate_enabled: '1',
|
||||||
health_gate_window_seconds: '90',
|
health_gate_window_seconds: '90',
|
||||||
env_block_deploy_on_missing_required: '0',
|
env_block_deploy_on_missing_required: '0',
|
||||||
|
image_update_sidebar_indicators: '1',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SectionId =
|
export type SectionId =
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface SidebarFilterChipsProps {
|
|||||||
onChange: (chip: FilterChip) => void;
|
onChange: (chip: FilterChip) => void;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
showUpdatesChip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chips: { id: FilterChip; label: string }[] = [
|
const chips: { id: FilterChip; label: string }[] = [
|
||||||
@@ -24,12 +25,13 @@ const chips: { id: FilterChip; label: string }[] = [
|
|||||||
{ id: 'updates', label: 'Updates' },
|
{ id: 'updates', label: 'Updates' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle }: SidebarFilterChipsProps) {
|
export function SidebarFilterChips({ active, counts, onChange, visible, onToggle, showUpdatesChip = true }: SidebarFilterChipsProps) {
|
||||||
|
const visibleChips = showUpdatesChip ? chips : chips.filter(c => c.id !== 'updates');
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center pb-1.5 pt-0.5 pl-2">
|
<div className="flex items-center pb-1.5 pt-0.5 pl-2">
|
||||||
{visible ? (
|
{visible ? (
|
||||||
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
|
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
|
||||||
{chips.map(({ id, label }) => {
|
{visibleChips.map(({ id, label }) => {
|
||||||
const count = counts[id];
|
const count = counts[id];
|
||||||
const displayCount = count > 99 ? '99+' : count;
|
const displayCount = count > 99 ? '99+' : count;
|
||||||
const isActive = active === id;
|
const isActive = active === id;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface StackSidebarProps {
|
|||||||
onToggleSelect: (file: string) => void;
|
onToggleSelect: (file: string) => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onBulkAction: (action: BulkAction) => void;
|
onBulkAction: (action: BulkAction) => void;
|
||||||
|
showUpdatesChip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StackSidebar(props: StackSidebarProps) {
|
export function StackSidebar(props: StackSidebarProps) {
|
||||||
@@ -41,6 +42,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
|||||||
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
|
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
|
||||||
list, activitySummary, onActivityAction,
|
list, activitySummary, onActivityAction,
|
||||||
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
|
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
|
||||||
|
showUpdatesChip = true,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [filtersVisible, setFiltersVisible] = useState(() => {
|
const [filtersVisible, setFiltersVisible] = useState(() => {
|
||||||
@@ -84,6 +86,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
|||||||
onChange={onFilterChipChange}
|
onChange={onFilterChipChange}
|
||||||
visible={filtersVisible}
|
visible={filtersVisible}
|
||||||
onToggle={handleToggleFilters}
|
onToggle={handleToggleFilters}
|
||||||
|
showUpdatesChip={showUpdatesChip}
|
||||||
/>
|
/>
|
||||||
{selectedFiles.size > 0 && (
|
{selectedFiles.size > 0 && (
|
||||||
<SidebarBulkBar
|
<SidebarBulkBar
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||||
|
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
|
||||||
|
|
||||||
const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
|
const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@@ -10,55 +11,128 @@ const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
|
|||||||
* `refresh()` to force a refetch (e.g. after a deploy or a manual
|
* `refresh()` to force a refetch (e.g. after a deploy or a manual
|
||||||
* registry-check trigger).
|
* registry-check trigger).
|
||||||
*
|
*
|
||||||
* Extracted from EditorLayout so the polling lifecycle and its state
|
* Also owns the sidebar-indicator toggle preference, fetched from
|
||||||
* live next to each other instead of being spread across a 3000-line
|
* /api/image-updates/status on the same cadence. All requests are
|
||||||
* component. The dependency on `apiFetch` keeps the call routed
|
* pinned to the captured node so a mid-flight node switch never
|
||||||
* through the active-node header just like before.
|
* writes stale data.
|
||||||
*/
|
*/
|
||||||
export function useImageUpdates(activeNodeId: number | undefined) {
|
export function useImageUpdates(activeNodeId: number | undefined) {
|
||||||
const [stackUpdates, setStackUpdates] = useState<Record<string, StackUpdateInfo>>({});
|
const [stackUpdates, setStackUpdates] = useState<Record<string, StackUpdateInfo>>({});
|
||||||
|
const [sidebarIndicators, setSidebarIndicators] = useState(false);
|
||||||
|
|
||||||
|
// Track which node owns the current state. When activeNodeId changes
|
||||||
|
// React renders once with the old owner before the passive effect clears
|
||||||
|
// the data. Returning empty defaults when the IDs mismatch prevents a
|
||||||
|
// single-frame flash of the wrong node's data.
|
||||||
|
const [ownerNodeId, setOwnerNodeId] = useState<number | undefined>(activeNodeId);
|
||||||
|
|
||||||
|
// Generation counter: every activeNodeId change increments it, and every
|
||||||
|
// await is gated against it so a slow response from a previous node is
|
||||||
|
// discarded.
|
||||||
|
const genRef = useRef(0);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
const gen = ++genRef.current;
|
||||||
const res = await apiFetch('/image-updates/detail');
|
const targetNodeId = activeNodeId ?? null;
|
||||||
if (res.ok) {
|
|
||||||
setStackUpdates(await res.json() as Record<string, StackUpdateInfo>);
|
// Self-contained status helper: owns fetch, parse, and state write.
|
||||||
return;
|
// A failure here never blocks the detail path below.
|
||||||
}
|
const fetchStatus = async (): Promise<void> => {
|
||||||
// A remote node on an older Sencho lacks /detail; fall back to the boolean
|
try {
|
||||||
// map so update badges keep working until that node is upgraded.
|
const res = await apiFetch('/image-updates/status', { nodeId: targetNodeId });
|
||||||
if (res.status === 404) {
|
if (genRef.current !== gen) return;
|
||||||
const boolRes = await apiFetch('/image-updates');
|
if (res.ok) {
|
||||||
if (boolRes.ok) {
|
const data = await res.json() as ImageUpdateStatus;
|
||||||
const bool = await boolRes.json() as Record<string, boolean>;
|
if (genRef.current !== gen) return;
|
||||||
const synthesized: Record<string, StackUpdateInfo> = {};
|
setSidebarIndicators(data.sidebarIndicators ?? false);
|
||||||
for (const [stack, hasUpdate] of Object.entries(bool)) {
|
|
||||||
synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 };
|
|
||||||
}
|
|
||||||
setStackUpdates(synthesized);
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status);
|
console.error('[ImageUpdates] status fetch returned', res.status);
|
||||||
}
|
}
|
||||||
return;
|
} catch (e) {
|
||||||
|
console.error('[ImageUpdates] status fetch failed:', e);
|
||||||
}
|
}
|
||||||
// Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep
|
};
|
||||||
// the last-known state on screen, but do not let the failure go silent.
|
|
||||||
console.error('[ImageUpdates] /image-updates/detail returned', res.status);
|
// Self-contained detail helper: owns fetch, parse, 404 fallback, and
|
||||||
} catch (e: unknown) {
|
// state write. A failure here never blocks the status path above.
|
||||||
console.error('[ImageUpdates] fetch failed:', e);
|
const fetchDetail = async (): Promise<void> => {
|
||||||
}
|
try {
|
||||||
}, []);
|
const res = await apiFetch('/image-updates/detail', { nodeId: targetNodeId });
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as Record<string, StackUpdateInfo>;
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
|
setStackUpdates(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A remote node on an older Sencho lacks /detail; fall back to the boolean
|
||||||
|
// map so update badges keep working until that node is upgraded.
|
||||||
|
if (res.status === 404) {
|
||||||
|
const boolRes = await apiFetch('/image-updates', { nodeId: targetNodeId });
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
|
if (boolRes.ok) {
|
||||||
|
const bool = await boolRes.json() as Record<string, boolean>;
|
||||||
|
if (genRef.current !== gen) return;
|
||||||
|
const synthesized: Record<string, StackUpdateInfo> = {};
|
||||||
|
for (const [stack, hasUpdate] of Object.entries(bool)) {
|
||||||
|
synthesized[stack] = { hasUpdate, checkStatus: 'ok', lastError: null, checkedAt: 0 };
|
||||||
|
}
|
||||||
|
setStackUpdates(synthesized);
|
||||||
|
} else {
|
||||||
|
console.error('[ImageUpdates] /detail 404 fallback to /image-updates failed:', boolRes.status);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any other non-ok (500, or a proxy 5xx from an unreachable remote): keep
|
||||||
|
// the last-known state on screen, but do not let the failure go silent.
|
||||||
|
console.error('[ImageUpdates] /image-updates/detail returned', res.status);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error('[ImageUpdates] fetch failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.allSettled([fetchStatus(), fetchDetail()]);
|
||||||
|
}, [activeNodeId]);
|
||||||
|
|
||||||
// Pin the interval to the latest closure without retriggering it on
|
// Pin the interval to the latest closure without retriggering it on
|
||||||
// every render the way putting `refresh` into the deps array would.
|
// every render the way putting `refresh` into the deps array would.
|
||||||
const refreshRef = useRef(refresh);
|
const refreshRef = useRef(refresh);
|
||||||
refreshRef.current = refresh;
|
refreshRef.current = refresh;
|
||||||
|
|
||||||
|
// Poll on mount and on node change. Reset state and capture the owning
|
||||||
|
// node BEFORE fetching so the old node's data is cleared before the new
|
||||||
|
// node's first response arrives, and the guard above returns empty defaults
|
||||||
|
// on the render before this effect fires.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
genRef.current += 1;
|
||||||
|
setStackUpdates({}); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
|
setSidebarIndicators(false); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
|
setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
void refreshRef.current();
|
void refreshRef.current();
|
||||||
const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS);
|
const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [activeNodeId]);
|
}, [activeNodeId]);
|
||||||
|
|
||||||
return { stackUpdates, refresh };
|
// React to settings changes so toggling the sidebar-indicator preference
|
||||||
|
// propagates immediately without waiting for the 5-minute poll.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<{ changedKeys?: string[] }>).detail;
|
||||||
|
if (detail?.changedKeys?.includes('image_update_sidebar_indicators')) {
|
||||||
|
refreshRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||||
|
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Return empty defaults until the owning node matches the active node.
|
||||||
|
// This prevents React from rendering node B with node A's update data and
|
||||||
|
// sidebar preference during the single frame before the passive effect fires.
|
||||||
|
const isOwner = activeNodeId !== undefined && activeNodeId === ownerNodeId;
|
||||||
|
return {
|
||||||
|
stackUpdates: isOwner ? stackUpdates : {} as Record<string, StackUpdateInfo>,
|
||||||
|
refresh,
|
||||||
|
sidebarIndicators: isOwner ? sidebarIndicators : false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface ImageUpdateStatus {
|
|||||||
mode: 'interval' | 'cron';
|
mode: 'interval' | 'cron';
|
||||||
/** 5-field cron expression when mode is 'cron', null otherwise. */
|
/** 5-field cron expression when mode is 'cron', null otherwise. */
|
||||||
cronExpression: string | null;
|
cronExpression: string | null;
|
||||||
|
/** Whether sidebar update-status indicators are enabled. Optional for older-node compatibility. */
|
||||||
|
sidebarIndicators?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user