mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
feat(nodes): add per-node scheduling and update visibility (#344)
* feat(labels): add stack_labels schema and DatabaseService CRUD methods
* feat(labels): add label CRUD, assignment, and bulk action API routes
* feat(labels): add oklch label color palette for light and dark themes
* feat(labels): add LabelPill and LabelDot reusable components
* feat(labels): add LabelAssignPopover component for inline label management
* feat(labels): add label pill bar, label dots, and label assignment to sidebar
* feat(labels): add label filtering and label dots to fleet view
* feat(labels): add label-scoped bulk actions (deploy/stop/restart all)
* docs: add Stack Labels feature documentation
* fix(labels): use context menu sub-menu for label assignment and add settings integration
Replace broken Popover-inside-ContextMenu pattern with native Radix
ContextMenuSub for reliable label toggling on right-click. Wrap
ContextMenuSubContent in a Portal to prevent overflow clipping. Add
"Manage labels..." item that opens Settings directly to Labels section.
Fix close button overlap in LabelsSection header. Add LabelsSection
settings component with full CRUD, assignment counts, and ProGate.
Add initialSection prop to SettingsModal for deep-linking. Include
screenshots for documentation.
* docs: update stack labels documentation with screenshots and corrected instructions
* fix(labels): address security and quality issues from code review
- Add NaN validation on parseInt(req.params.id) in label routes
- Scope updateLabel/deleteLabel by nodeId to prevent cross-node IDOR
- Validate labelIds belong to correct node in setStackLabels
- Add requireAdmin check on bulk action endpoint
- Replace error: any with error: unknown and proper narrowing
- Remove unused Label import from index.ts
- Remove unused isPro prop from LabelsSection
- Add strokeWidth={1.5} to Check icons per design system
* chore: update CHANGELOG with stack labels feature
* feat(nodes): add per-node scheduling and update visibility
Add Schedules and Updates columns to the Nodes table showing active
task counts, next run times, and auto-update status per node. A calendar
action button navigates to filtered schedule/auto-update views.
Backend changes:
- Add node_id to stack_update_status table (migration + unique index)
- Cascade cleanup on node deletion (scheduled_tasks + update status)
- Pre-check target node existence/status before executing scheduled tasks
- New GET /api/nodes/scheduling-summary endpoint
- New GET /api/image-updates/fleet endpoint with 2-minute cache
- Parallelize remote node fetches with Promise.allSettled
- Wrap deleteNode cascade in a transaction
Frontend changes:
- NodeManager: Schedules/Updates columns with summary data fetch
- EditorLayout: sencho-navigate event listener for cross-component nav
- ScheduledOperationsView/AutoUpdatePoliciesView: filterNodeId prop,
filter bar UI, pre-selected node in create dialog
This commit is contained in:
@@ -73,7 +73,12 @@ function formatTimestamp(ts: number | null): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function AutoUpdatePoliciesContent() {
|
||||
interface AutoUpdatePoliciesProps {
|
||||
filterNodeId?: number | null;
|
||||
onClearFilter?: () => void;
|
||||
}
|
||||
|
||||
function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
|
||||
const [policies, setPolicies] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -100,6 +105,13 @@ function AutoUpdatePoliciesContent() {
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
|
||||
const filteredPolicies = filterNodeId != null
|
||||
? policies.filter(p => p.node_id === filterNodeId)
|
||||
: policies;
|
||||
const filterNodeName = filterNodeId != null
|
||||
? nodes.find(n => n.id === filterNodeId)?.name
|
||||
: null;
|
||||
|
||||
const fetchPolicies = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -153,11 +165,14 @@ function AutoUpdatePoliciesContent() {
|
||||
setEditingPolicy(null);
|
||||
setFormName('');
|
||||
setFormTargetId('');
|
||||
setFormNodeId('');
|
||||
setFormNodeId(filterNodeId != null ? String(filterNodeId) : '');
|
||||
setFormCron('0 3 * * *');
|
||||
setFormCronPreset('0 3 * * *');
|
||||
setFormEnabled(true);
|
||||
setDialogOpen(true);
|
||||
if (filterNodeId != null) {
|
||||
fetchStacks(String(filterNodeId));
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (policy: ScheduledTask) => {
|
||||
@@ -297,11 +312,23 @@ function AutoUpdatePoliciesContent() {
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && policies.length === 0 ? (
|
||||
{filterNodeId != null && filterNodeName && (
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<Badge variant="outline" className="gap-1.5 text-xs">
|
||||
Filtered to node: <span className="font-medium">{filterNodeName}</span>
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={onClearFilter}>
|
||||
Clear filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loading && filteredPolicies.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">Loading...</div>
|
||||
) : policies.length === 0 ? (
|
||||
) : filteredPolicies.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
No auto-update policies yet. Create one to keep your stacks up to date automatically.
|
||||
{filterNodeId != null
|
||||
? 'No auto-update policies for this node. Create one to keep your stacks up to date automatically.'
|
||||
: 'No auto-update policies yet. Create one to keep your stacks up to date automatically.'}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -318,7 +345,7 @@ function AutoUpdatePoliciesContent() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{policies.map((policy) => (
|
||||
{filteredPolicies.map((policy) => (
|
||||
<TableRow key={policy.id}>
|
||||
<TableCell className="font-medium">{policy.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm text-muted-foreground">{policy.target_id === '*' ? 'All Stacks' : policy.target_id}</TableCell>
|
||||
@@ -561,10 +588,10 @@ function AutoUpdatePoliciesContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutoUpdatePoliciesView() {
|
||||
export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
|
||||
return (
|
||||
<ProGate featureName="Auto-Update Policies">
|
||||
<AutoUpdatePoliciesContent />
|
||||
<AutoUpdatePoliciesContent filterNodeId={filterNodeId} onClearFilter={onClearFilter} />
|
||||
</ProGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ import { FleetView } from './FleetView';
|
||||
import { AuditLogView } from './AuditLogView';
|
||||
import ScheduledOperationsView from './ScheduledOperationsView';
|
||||
import AutoUpdatePoliciesView from './AutoUpdatePoliciesView';
|
||||
import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
|
||||
import type { SenchoNavigateDetail } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -131,6 +133,7 @@ export default function EditorLayout() {
|
||||
);
|
||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates'>('dashboard');
|
||||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||
@@ -218,6 +221,7 @@ export default function EditorLayout() {
|
||||
setActiveView('dashboard');
|
||||
} else {
|
||||
setActiveView(value as typeof activeView);
|
||||
setFilterNodeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,6 +239,19 @@ export default function EditorLayout() {
|
||||
localStorage.setItem('sencho-theme', theme);
|
||||
}, [isDarkMode, theme]);
|
||||
|
||||
// Listen for cross-component navigation (e.g., NodeManager → Schedules)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<SenchoNavigateDetail>).detail;
|
||||
if (detail?.view) {
|
||||
setActiveView(detail.view);
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
}
|
||||
};
|
||||
window.addEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
||||
return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
// Force Monaco to re-measure its container after the tab switch DOM settles.
|
||||
// Monaco's internal child is position:static with an explicit pixel height that
|
||||
// creates a circular CSS dependency (Monaco drives card height → grid height → Monaco).
|
||||
@@ -2017,9 +2034,9 @@ export default function EditorLayout() {
|
||||
) : activeView === 'audit-log' ? (
|
||||
<AuditLogView />
|
||||
) : activeView === 'auto-updates' ? (
|
||||
<AutoUpdatePoliciesView />
|
||||
<AutoUpdatePoliciesView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
||||
) : activeView === 'scheduled-ops' ? (
|
||||
<ScheduledOperationsView />
|
||||
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
||||
) : (
|
||||
<HomeDashboard />
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
@@ -13,7 +13,30 @@ import { Separator } from './ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle } from 'lucide-react';
|
||||
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface NodeSchedulingSummary {
|
||||
active_tasks: number;
|
||||
auto_update_enabled: boolean;
|
||||
next_run_at: number | null;
|
||||
stacks_with_updates: number;
|
||||
}
|
||||
|
||||
export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
|
||||
export interface SenchoNavigateDetail {
|
||||
view: 'scheduled-ops' | 'auto-updates';
|
||||
nodeId: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const diff = timestamp - Date.now();
|
||||
if (diff < 0) return 'overdue';
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h`;
|
||||
return `${Math.floor(hrs / 24)}d`;
|
||||
}
|
||||
|
||||
interface NodeFormData {
|
||||
name: string;
|
||||
@@ -49,6 +72,24 @@ export function NodeManager() {
|
||||
const [generatingToken, setGeneratingToken] = useState(false);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
|
||||
// Per-node scheduling summary
|
||||
const [nodeSummary, setNodeSummary] = useState<Record<number, NodeSchedulingSummary>>({});
|
||||
|
||||
const fetchSchedulingSummary = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes/scheduling-summary', { localOnly: true });
|
||||
if (res.ok) setNodeSummary(await res.json());
|
||||
} catch {
|
||||
// Non-fatal — summary is supplementary info
|
||||
}
|
||||
}, []);
|
||||
|
||||
const nodeIdKey = useMemo(() => nodes.map(n => n.id).join(','), [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSchedulingSummary();
|
||||
}, [nodeIdKey, fetchSchedulingSummary]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes', {
|
||||
@@ -407,6 +448,8 @@ export function NodeManager() {
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Schedules</TableHead>
|
||||
<TableHead>Updates</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -438,8 +481,82 @@ export function NodeManager() {
|
||||
{node.type === 'local' ? 'docker.sock' : (node.api_url || '-')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(node.status)}</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const summary = nodeSummary[node.id];
|
||||
if (!summary || summary.active_tasks === 0) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-sm tabular-nums tracking-tight">
|
||||
{summary.active_tasks}
|
||||
</span>
|
||||
{summary.next_run_at && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
next {formatRelativeTime(summary.next_run_at)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{new Date(summary.next_run_at).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const summary = nodeSummary[node.id];
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{summary?.auto_update_enabled ? (
|
||||
<Badge variant="outline" className="text-info border-info/30 gap-1 text-xs">
|
||||
<RefreshCw className="w-3 h-3" strokeWidth={1.5} />
|
||||
Auto
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">Off</span>
|
||||
)}
|
||||
{(summary?.stacks_with_updates ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-info animate-pulse" />
|
||||
<span className="font-mono text-xs tabular-nums tracking-tight text-info">
|
||||
{summary!.stacks_with_updates}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
|
||||
detail: { view: 'scheduled-ops', nodeId: node.id },
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Calendar className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Schedules</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -72,7 +72,12 @@ function formatTimestamp(ts: number | null): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
export default function ScheduledOperationsView() {
|
||||
interface ScheduledOperationsViewProps {
|
||||
filterNodeId?: number | null;
|
||||
onClearFilter?: () => void;
|
||||
}
|
||||
|
||||
export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: ScheduledOperationsViewProps) {
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -103,6 +108,13 @@ export default function ScheduledOperationsView() {
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
|
||||
const filteredTasks = filterNodeId != null
|
||||
? tasks.filter(t => t.node_id === filterNodeId)
|
||||
: tasks;
|
||||
const filterNodeName = filterNodeId != null
|
||||
? nodes.find(n => n.id === filterNodeId)?.name
|
||||
: null;
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -184,13 +196,16 @@ export default function ScheduledOperationsView() {
|
||||
setFormName('');
|
||||
setFormAction('restart');
|
||||
setFormTargetId('');
|
||||
setFormNodeId('');
|
||||
setFormNodeId(filterNodeId != null ? String(filterNodeId) : '');
|
||||
setFormCron('0 3 * * *');
|
||||
setFormEnabled(true);
|
||||
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
|
||||
setFormTargetServices([]);
|
||||
setFormPruneLabelFilter('');
|
||||
setDialogOpen(true);
|
||||
if (filterNodeId != null) {
|
||||
fetchStacks(String(filterNodeId));
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (task: ScheduledTask) => {
|
||||
@@ -352,11 +367,23 @@ export default function ScheduledOperationsView() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && tasks.length === 0 ? (
|
||||
{filterNodeId != null && filterNodeName && (
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<Badge variant="outline" className="gap-1.5 text-xs">
|
||||
Filtered to node: <span className="font-medium">{filterNodeName}</span>
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={onClearFilter}>
|
||||
Clear filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{loading && filteredTasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">Loading...</div>
|
||||
) : tasks.length === 0 ? (
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
No scheduled tasks yet. Create one to automate recurring operations.
|
||||
{filterNodeId != null
|
||||
? 'No scheduled tasks for this node. Create one to automate recurring operations.'
|
||||
: 'No scheduled tasks yet. Create one to automate recurring operations.'}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -373,7 +400,7 @@ export default function ScheduledOperationsView() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
{filteredTasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="font-medium">{task.name}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
Reference in New Issue
Block a user