diff --git a/CHANGELOG.md b/CHANGELOG.md index 69fd0323..ea381f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **labels:** Stack Labels — organize stacks with custom colored labels for filtering and bulk actions (Pro) + * Create, edit, and delete labels from Settings → Labels + * Assign labels to stacks via right-click context menu or dropdown menu + * Filter sidebar stack list by label (OR logic with clickable pill bar) + * Filter Fleet View by label + * Bulk actions on labeled stacks: deploy all, stop all, restart all + * 10 curated oklch label colors with light/dark theme support + * Label data persists across tier downgrades — upgrading restores all labels * **resources:** Docker network management — create, inspect, and delete networks from the UI * **resources:** Network inspect panel showing IPAM config, connected containers with IPs/MACs, and labels * **resources:** Network creation dialog with driver, subnet, gateway, internal, and attachable options diff --git a/backend/src/index.ts b/backend/src/index.ts index 51ddd4df..81421572 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -48,6 +48,7 @@ const _origEmitWarning = process.emitWarning.bind(process); }; const MIN_PASSWORD_LENGTH = 8; +const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const; const app = express(); const PORT = 3000; @@ -2464,6 +2465,190 @@ app.get('/api/containers', async (req: Request, res: Response) => { } }); +// --- Label Routes (Pro-gated) --- + +app.get('/api/labels', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const nodeId = req.nodeId ?? 0; + const labels = DatabaseService.getInstance().getLabels(nodeId); + res.json(labels); + } catch (error) { + console.error('[Labels] List error:', error); + res.status(500).json({ error: 'Failed to list labels' }); + } +}); + +app.post('/api/labels', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const nodeId = req.nodeId ?? 0; + const { name, color } = req.body; + + if (!name || typeof name !== 'string' || name.trim().length === 0 || name.length > 30) { + res.status(400).json({ error: 'name is required and must be 1-30 characters' }); + return; + } + if (!/^[a-zA-Z0-9 -]+$/.test(name)) { + res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' }); + return; + } + if (!color || !(VALID_LABEL_COLORS as readonly string[]).includes(color)) { + res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` }); + return; + } + + const label = DatabaseService.getInstance().createLabel(nodeId, name.trim(), color); + res.status(201).json(label); + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') { + res.status(409).json({ error: 'A label with that name already exists' }); + return; + } + console.error('[Labels] Create error:', error); + res.status(500).json({ error: 'Failed to create label' }); + } +}); + +app.get('/api/labels/assignments', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const nodeId = req.nodeId ?? 0; + const assignments = DatabaseService.getInstance().getLabelsForStacks(nodeId); + res.json(assignments); + } catch (error) { + console.error('[Labels] Assignments error:', error); + res.status(500).json({ error: 'Failed to fetch label assignments' }); + } +}); + +app.put('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } + const nodeId = req.nodeId ?? 0; + const { name, color } = req.body; + + if (name !== undefined) { + if (typeof name !== 'string' || name.trim().length === 0 || name.length > 30) { + res.status(400).json({ error: 'name must be 1-30 characters' }); + return; + } + if (!/^[a-zA-Z0-9 -]+$/.test(name)) { + res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' }); + return; + } + } + if (color !== undefined && !(VALID_LABEL_COLORS as readonly string[]).includes(color)) { + res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` }); + return; + } + + const updated = DatabaseService.getInstance().updateLabel(id, nodeId, { + name: name?.trim(), + color, + }); + if (!updated) { + res.status(404).json({ error: 'Label not found' }); + return; + } + res.json(updated); + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') { + res.status(409).json({ error: 'A label with that name already exists' }); + return; + } + console.error('[Labels] Update error:', error); + res.status(500).json({ error: 'Failed to update label' }); + } +}); + +app.delete('/api/labels/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } + const nodeId = req.nodeId ?? 0; + DatabaseService.getInstance().deleteLabel(id, nodeId); + res.json({ success: true }); + } catch (error) { + console.error('[Labels] Delete error:', error); + res.status(500).json({ error: 'Failed to delete label' }); + } +}); + +app.put('/api/stacks/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + const nodeId = req.nodeId ?? 0; + const { labelIds } = req.body; + + if (!Array.isArray(labelIds) || !labelIds.every((id: unknown) => typeof id === 'number')) { + res.status(400).json({ error: 'labelIds must be an array of numbers' }); + return; + } + + DatabaseService.getInstance().setStackLabels(stackName, nodeId, labelIds); + res.json({ success: true }); + } catch (error) { + console.error('[Labels] Set stack labels error:', error); + res.status(500).json({ error: 'Failed to set stack labels' }); + } +}); + +app.post('/api/labels/:id/action', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + if (!requireAdmin(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + if (isNaN(id)) { res.status(400).json({ error: 'Invalid label ID' }); return; } + const { action } = req.body; + const validActions = ['deploy', 'stop', 'restart']; + if (!action || !validActions.includes(action)) { + res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` }); + return; + } + + const nodeId = req.nodeId ?? 0; + const stackNames = DatabaseService.getInstance().getStacksForLabel(id); + const fsStacks = await FileSystemService.getInstance(nodeId).getStacks(); + const fsStackNames = new Set(fsStacks); + const validStacks = stackNames.filter(name => fsStackNames.has(name)); + + const results: { stackName: string; success: boolean; error?: string }[] = []; + + for (const stackName of validStacks) { + try { + if (action === 'deploy') { + await ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false); + } else { + const dockerController = DockerController.getInstance(req.nodeId); + const containers = await dockerController.getContainersByStack(stackName); + if (action === 'stop') { + await Promise.all(containers.map(c => dockerController.stopContainer(c.Id))); + } else { + await Promise.all(containers.map(c => dockerController.restartContainer(c.Id))); + } + } + results.push({ stackName, success: true }); + } catch (err: unknown) { + results.push({ stackName, success: false, error: (err as Error)?.message || 'Unknown error' }); + } + } + + res.json({ results }); + } catch (error) { + console.error('[Labels] Bulk action error:', error); + res.status(500).json({ error: 'Failed to execute bulk action' }); + } +}); + // Stack Routes - Updated to use stackName (directory name) instead of filename app.get('/api/stacks', async (req: Request, res: Response) => { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 907780fb..cdc1b200 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -38,6 +38,13 @@ export interface Node { api_token?: string; } +export interface Label { + id: number; + node_id: number; + name: string; + color: string; +} + export interface Webhook { id?: number; name: string; @@ -416,6 +423,24 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task ON scheduled_task_runs(task_id); CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at); + + CREATE TABLE IF NOT EXISTS stack_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + color TEXT NOT NULL, + UNIQUE(node_id, name) + ); + + CREATE TABLE IF NOT EXISTS stack_label_assignments ( + label_id INTEGER NOT NULL REFERENCES stack_labels(id) ON DELETE CASCADE, + stack_name TEXT NOT NULL, + node_id INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (label_id, stack_name, node_id) + ); + + CREATE INDEX IF NOT EXISTS idx_label_assignments_stack + ON stack_label_assignments(stack_name, node_id); `); // Apply migrations safely (ignore if columns already exist) @@ -1349,4 +1374,71 @@ export class DatabaseService { const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff); } + + // --- Stack Labels --- + + public getLabels(nodeId: number): Label[] { + return this.db.prepare('SELECT * FROM stack_labels WHERE node_id = ? ORDER BY name').all(nodeId) as Label[]; + } + + public createLabel(nodeId: number, name: string, color: string): Label { + const result = this.db.prepare( + 'INSERT INTO stack_labels (node_id, name, color) VALUES (?, ?, ?)' + ).run(nodeId, name, color); + return { id: result.lastInsertRowid as number, node_id: nodeId, name, color }; + } + + public updateLabel(id: number, nodeId: number, updates: { name?: string; color?: string }): Label | null { + const label = this.db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?').get(id, nodeId) as Label | undefined; + if (!label) return null; + const name = updates.name ?? label.name; + const color = updates.color ?? label.color; + this.db.prepare('UPDATE stack_labels SET name = ?, color = ? WHERE id = ? AND node_id = ?').run(name, color, id, nodeId); + return { ...label, name, color }; + } + + public deleteLabel(id: number, nodeId: number): void { + this.db.prepare('DELETE FROM stack_labels WHERE id = ? AND node_id = ?').run(id, nodeId); + } + + public setStackLabels(stackName: string, nodeId: number, labelIds: number[]): void { + const txn = this.db.transaction(() => { + if (labelIds.length > 0) { + const placeholders = labelIds.map(() => '?').join(','); + const validCount = this.db.prepare( + `SELECT COUNT(*) as cnt FROM stack_labels WHERE id IN (${placeholders}) AND node_id = ?` + ).get(...labelIds, nodeId) as { cnt: number }; + if (validCount.cnt !== labelIds.length) { + throw new Error('One or more label IDs are invalid for this node'); + } + } + this.db.prepare('DELETE FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?').run(stackName, nodeId); + const insert = this.db.prepare('INSERT INTO stack_label_assignments (label_id, stack_name, node_id) VALUES (?, ?, ?)'); + for (const labelId of labelIds) { + insert.run(labelId, stackName, nodeId); + } + }); + txn(); + } + + public getLabelsForStacks(nodeId: number): Record { + const rows = this.db.prepare(` + SELECT a.stack_name, l.id, l.node_id, l.name, l.color + FROM stack_label_assignments a + JOIN stack_labels l ON a.label_id = l.id + WHERE a.node_id = ? + ORDER BY l.name + `).all(nodeId) as (Label & { stack_name: string })[]; + const result: Record = {}; + for (const row of rows) { + if (!result[row.stack_name]) result[row.stack_name] = []; + result[row.stack_name].push({ id: row.id, node_id: row.node_id, name: row.name, color: row.color }); + } + return result; + } + + public getStacksForLabel(labelId: number): string[] { + const rows = this.db.prepare('SELECT stack_name FROM stack_label_assignments WHERE label_id = ?').all(labelId) as { stack_name: string }[]; + return rows.map(r => r.stack_name); + } } diff --git a/docs/docs.json b/docs/docs.json index d4806581..f4419429 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -103,6 +103,7 @@ "features/host-console", "features/multi-node", "features/fleet-view", + "features/stack-labels", "features/alerts-notifications", "features/webhooks", "features/rbac", diff --git a/docs/features/stack-labels.mdx b/docs/features/stack-labels.mdx new file mode 100644 index 00000000..1ab060eb --- /dev/null +++ b/docs/features/stack-labels.mdx @@ -0,0 +1,96 @@ +--- +title: Stack Labels +description: Organize your stacks with colored labels for quick filtering, grouping, and bulk actions. +--- + + + Stack Labels require a Sencho **Skipper** or **Admiral** license. + + +Stack Labels let you tag your Docker Compose stacks with custom colored labels like `production`, `staging`, or `media-server`. Once labeled, you can filter your stack list, identify stacks at a glance, and run bulk actions on entire groups. + + + Sidebar showing stacks with colored label dots + + +## Creating labels + +### From Settings + +1. Open **Settings → Labels** +2. Click **New Label** +3. Enter a name and pick a color +4. Click **Create** + + + Settings Labels section with label management + + +### From the stack context menu + +1. Right-click any stack in the sidebar (or click the three-dot menu) +2. Hover over **Labels** +3. Click **Manage labels...** at the bottom of the sub-menu +4. Create your label from the Settings panel that opens + +## Assigning labels to stacks + +1. Right-click a stack in the sidebar (or click the three-dot menu) +2. Hover over **Labels** to open the sub-menu +3. Click a label to toggle it on or off + + + Right-click context menu with Labels sub-menu + + +A stack can have multiple labels. Changes save immediately. + +## Filtering by label + +### Sidebar + +When you have labels, a pill bar appears between the search box and the stack list. Click a label pill to filter — only stacks with that label are shown. Click multiple pills to see stacks matching **any** of the selected labels. Click an active pill again to deselect it. + + + Sidebar filtered by label showing only matching stacks + + +### Fleet View + +Label filter pills appear in the Fleet View toolbar alongside the existing Status and Type filters. Selecting a label filters to nodes that contain stacks with that label. + +## Bulk actions + +Right-click a label pill in the sidebar to access bulk actions: + +| Action | Effect | +|--------|--------| +| **Deploy all** | Runs deploy on every stack with that label | +| **Stop all** | Stops every stack with that label | +| **Restart all** | Restarts every stack with that label | + +A confirmation dialog shows which stacks will be affected before executing. + +## Managing labels + +Open **Settings → Labels** to view, edit, and delete your labels. + +- **Edit**: Change a label's name or color +- **Delete**: Removes the label and unassigns it from all stacks + +## Troubleshooting + + + + Labels require a **Skipper** or **Admiral** license. Verify your license tier in **Settings → License**. Labels also only appear when at least one label has been created. + + + If you downgrade from Skipper/Admiral to Community, the label UI is hidden but your data is preserved. Upgrading again will restore all labels and assignments. + + + If a stack was deleted from disk but still had a label assignment, it will be skipped during bulk actions. The results toast will indicate how many stacks failed. Remove orphaned assignments by re-opening **Settings → Labels**. + + + Label names must be unique per node. If you see a “label with that name already exists” error, choose a different name or edit the existing label. + + diff --git a/docs/images/stack-labels/context-menu-label-submenu-open.png b/docs/images/stack-labels/context-menu-label-submenu-open.png new file mode 100644 index 00000000..bd3a9f32 Binary files /dev/null and b/docs/images/stack-labels/context-menu-label-submenu-open.png differ diff --git a/docs/images/stack-labels/context-menu-labels-submenu.png b/docs/images/stack-labels/context-menu-labels-submenu.png new file mode 100644 index 00000000..d8f7ee05 Binary files /dev/null and b/docs/images/stack-labels/context-menu-labels-submenu.png differ diff --git a/docs/images/stack-labels/label-assign-popover.png b/docs/images/stack-labels/label-assign-popover.png new file mode 100644 index 00000000..3d01f4df Binary files /dev/null and b/docs/images/stack-labels/label-assign-popover.png differ diff --git a/docs/images/stack-labels/settings-labels-fixed.png b/docs/images/stack-labels/settings-labels-fixed.png new file mode 100644 index 00000000..7656a537 Binary files /dev/null and b/docs/images/stack-labels/settings-labels-fixed.png differ diff --git a/docs/images/stack-labels/settings-labels-section.png b/docs/images/stack-labels/settings-labels-section.png new file mode 100644 index 00000000..d749e6ef Binary files /dev/null and b/docs/images/stack-labels/settings-labels-section.png differ diff --git a/docs/images/stack-labels/sidebar-filtered-by-label.png b/docs/images/stack-labels/sidebar-filtered-by-label.png new file mode 100644 index 00000000..067a40da Binary files /dev/null and b/docs/images/stack-labels/sidebar-filtered-by-label.png differ diff --git a/docs/images/stack-labels/sidebar-label-pill-bar.png b/docs/images/stack-labels/sidebar-label-pill-bar.png new file mode 100644 index 00000000..ad20acea Binary files /dev/null and b/docs/images/stack-labels/sidebar-label-pill-bar.png differ diff --git a/docs/images/stack-labels/sidebar-with-label-dots.png b/docs/images/stack-labels/sidebar-with-label-dots.png new file mode 100644 index 00000000..ba2cfdb9 Binary files /dev/null and b/docs/images/stack-labels/sidebar-with-label-dots.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 5b6256ca..d9d78c3e 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -18,8 +18,10 @@ import { springs } from '@/lib/motion'; import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2 } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; +import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill'; +import { LabelAssignPopover } from './LabelAssignPopover'; import { UserProfileDropdown } from './UserProfileDropdown'; import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; @@ -31,7 +33,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu'; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; import { cn } from '@/lib/utils'; @@ -132,6 +134,13 @@ export default function EditorLayout() { const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); + const [labels, setLabels] = useState([]); + const [stackLabelMap, setStackLabelMap] = useState>({}); + const [activeLabelFilters, setActiveLabelFilters] = useState>(new Set()); + const [bulkActionLabel, setBulkActionLabel] = useState(null); + const [bulkAction, setBulkAction] = useState(''); + const [bulkActionOpen, setBulkActionOpen] = useState(false); + const [bulkActionRunning, setBulkActionRunning] = useState(false); // Bash exec modal state const [bashModalOpen, setBashModalOpen] = useState(false); @@ -148,6 +157,7 @@ export default function EditorLayout() { // Notifications & Settings state const [notifications, setNotifications] = useState([]); const [settingsModalOpen, setSettingsModalOpen] = useState(false); + const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels'>('account'); const [alertSheetOpen, setAlertSheetOpen] = useState(false); const [alertSheetStack, setAlertSheetStack] = useState(''); @@ -268,6 +278,7 @@ export default function EditorLayout() { } } setStackStatuses(statuses); + refreshLabels(); return fileList; } catch (error) { console.error('Failed to refresh stacks:', error); @@ -278,6 +289,20 @@ export default function EditorLayout() { } }; + const refreshLabels = async () => { + if (!isPro) return; + try { + const [labelsRes, assignmentsRes] = await Promise.all([ + apiFetch('/labels'), + apiFetch('/labels/assignments'), + ]); + if (labelsRes.ok) setLabels(await labelsRes.json()); + if (assignmentsRes.ok) setStackLabelMap(await assignmentsRes.json()); + } catch { + // Labels are non-critical; fail silently + } + }; + const handleScanStacks = async () => { if (isScanning) return; setIsScanning(true); @@ -1128,7 +1153,12 @@ export default function EditorLayout() { // Filter files based on search query const filteredFiles = files.filter(file => { - return file.toLowerCase().includes(searchQuery.toLowerCase()); + if (!file.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (activeLabelFilters.size > 0) { + const fileLabels = stackLabelMap[file] || []; + if (!fileLabels.some(l => activeLabelFilters.has(l.id))) return false; + } + return true; }); // Get display name for stack (now just returns the name as-is since no extension) @@ -1252,6 +1282,45 @@ export default function EditorLayout() { className="h-9" /> + {isPro && labels.length > 0 && ( +
+ {labels.map(label => ( + + +
+ { + setActiveLabelFilters(prev => { + const next = new Set(prev); + if (next.has(label.id)) next.delete(label.id); + else next.add(label.id); + return next; + }); + }} + /> +
+
+ + { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}> + + Deploy all + + { setBulkActionLabel(label); setBulkAction('stop'); setBulkActionOpen(true); }}> + + Stop all + + { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}> + + Restart all + + +
+ ))} +
+ )}

STACKS

@@ -1281,6 +1350,13 @@ export default function EditorLayout() { {stackStatuses[file] === 'running' ? 'UP' : stackStatuses[file] === 'exited' ? 'DN' : '--'} {getDisplayName(file)} + {isPro && stackLabelMap[file]?.length > 0 && ( + + {stackLabelMap[file].map(l => ( + + ))} + + )} {stackUpdates[file] && ( Alerts + {isPro && ( + l.id)} + onLabelsChanged={refreshLabels} + > + e.preventDefault()}> + + Labels + + + )} checkUpdatesForStack()}> Check for updates @@ -1349,6 +1438,47 @@ export default function EditorLayout() { Alerts + {isPro && ( + + + + Labels + + + {labels.map(label => { + const assigned = (stackLabelMap[file] || []).some(l => l.id === label.id); + return ( + { + const currentIds = (stackLabelMap[file] || []).map(l => l.id); + const newIds = assigned ? currentIds.filter(id => id !== label.id) : [...currentIds, label.id]; + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { method: 'PUT', body: JSON.stringify({ labelIds: newIds }) }); + if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.error || 'Failed to update labels.'); } + refreshLabels(); + } catch (err: unknown) { toast.error((err as Error)?.message || 'Failed to update labels.'); } + }} + > + + {label.name} + {assigned && } + + ); + })} + {labels.length === 0 && ( + + No labels yet + + )} + + { setSettingsInitialSection('labels'); setSettingsModalOpen(true); }}> + + Manage labels... + + + + )} checkUpdatesForStack()}> Check for updates @@ -1912,6 +2042,63 @@ export default function EditorLayout() { + + + + + {bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all “{bulkActionLabel?.name}” stacks? + + + This will {bulkAction} all stacks labeled “{bulkActionLabel?.name}”. + {stackLabelMap && bulkActionLabel && ( + + Affected: {Object.entries(stackLabelMap) + .filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id)) + .map(([name]) => name) + .join(', ') || 'none'} + + )} + + + + Cancel + { + e.preventDefault(); + if (!bulkActionLabel) return; + setBulkActionRunning(true); + try { + const res = await apiFetch(`/labels/${bulkActionLabel.id}/action`, { + method: 'POST', + body: JSON.stringify({ action: bulkAction }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || `Bulk ${bulkAction} failed.`); + } + const data = await res.json(); + const failed = data.results?.filter((r: { success: boolean }) => !r.success) || []; + if (failed.length > 0) { + toast.error(`${failed.length} stack(s) failed to ${bulkAction}.`); + } else { + toast.success(`All stacks ${bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'} successfully.`); + } + setBulkActionOpen(false); + refreshStacks(true); + } catch (err: unknown) { + toast.error((err as Error)?.message || 'Something went wrong.'); + } finally { + setBulkActionRunning(false); + } + }} + > + {bulkActionRunning ? 'Running...' : `${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} All`} + + + + + {/* Bash Exec Modal */} {selectedContainer && ( setSettingsModalOpen(false)} + onClose={() => { setSettingsModalOpen(false); setSettingsInitialSection('account'); }} + initialSection={settingsInitialSection} /> {/* Stack Alert Sheet */} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 2f4da2ab..2babc459 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -18,6 +18,7 @@ import { useLicense } from '@/context/LicenseContext'; import { ProGate } from './ProGate'; import FleetSnapshots from './FleetSnapshots'; import { toast } from '@/components/ui/toast-store'; +import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill'; // --- Types --- @@ -186,10 +187,11 @@ function ContainerRow({ container, nodeId, onNavigate }: { ); } -function StackSection({ stackName, nodeId, onNavigate }: { +function StackSection({ stackName, nodeId, onNavigate, labelMap }: { stackName: string; nodeId: number; onNavigate: (nodeId: number, stackName: string) => void; + labelMap?: Record; }) { const [expanded, setExpanded] = useState(false); const [containers, setContainers] = useState(null); @@ -228,6 +230,13 @@ function StackSection({ stackName, nodeId, onNavigate }: { {expanded ? : } {stackName} + {labelMap?.[stackName]?.length ? ( + + {labelMap[stackName].map(l => ( + + ))} + + ) : null} {containers !== null && ( {runningCount}/{totalCount} @@ -259,7 +268,7 @@ function StackSection({ stackName, nodeId, onNavigate }: { ); } -function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void }) { +function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record }) { const { isPro } = useLicense(); const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); @@ -414,6 +423,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: stackName={stack} nodeId={node.id} onNavigate={onNavigate} + labelMap={labelMap} /> ))}
@@ -440,6 +450,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const [refreshing, setRefreshing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [prefs, setPrefs] = useState(loadPreferences); + const [fleetLabels, setFleetLabels] = useState([]); + const [fleetStackLabelMap, setFleetStackLabelMap] = useState>({}); + const [labelFilters, setLabelFilters] = useState>(new Set()); const { isPro } = useLicense(); const updatePrefs = useCallback((update: Partial) => { @@ -465,9 +478,24 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { } }, []); + const fetchLabels = useCallback(async () => { + if (!isPro) return; + try { + const [labelsRes, assignmentsRes] = await Promise.all([ + apiFetch('/labels', { localOnly: true }), + apiFetch('/labels/assignments', { localOnly: true }), + ]); + if (labelsRes.ok) setFleetLabels(await labelsRes.json()); + if (assignmentsRes.ok) setFleetStackLabelMap(await assignmentsRes.json()); + } catch { + // Non-critical + } + }, [isPro]); + useEffect(() => { fetchOverview(); - }, [fetchOverview]); + fetchLabels(); + }, [fetchOverview, fetchLabels]); // Pro: auto-refresh every 30s useEffect(() => { @@ -521,6 +549,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { // Critical filter if (prefs.filterCritical) filtered = filtered.filter(isCritical); + // Label filter + if (labelFilters.size > 0) { + filtered = filtered.filter(n => + n.stacks?.some(s => { + const sLabels = fleetStackLabelMap[s] || []; + return sLabels.some(l => labelFilters.has(l.id)); + }) + ); + } + // Sort filtered.sort((a, b) => { let cmp = 0; @@ -546,7 +584,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { } return filtered; - }, [nodes, searchQuery, isPro, prefs]); + }, [nodes, searchQuery, isPro, prefs, labelFilters, fleetStackLabelMap]); return (
@@ -730,6 +768,30 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { Critical Only + + {fleetLabels.length > 0 && ( + <> +
+
+ {fleetLabels.map(label => ( + { + setLabelFilters(prev => { + const next = new Set(prev); + if (next.has(label.id)) next.delete(label.id); + else next.add(label.id); + return next; + }); + }} + /> + ))} +
+ + )}
)} @@ -741,6 +803,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { key={node.id} node={node} onNavigate={onNavigateToNode} + labelMap={fleetStackLabelMap} /> ))}
@@ -756,6 +819,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { onClick={() => { setSearchQuery(''); updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false }); + setLabelFilters(new Set()); }} > diff --git a/frontend/src/components/LabelAssignPopover.tsx b/frontend/src/components/LabelAssignPopover.tsx new file mode 100644 index 00000000..78046606 --- /dev/null +++ b/frontend/src/components/LabelAssignPopover.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import { Check, Plus } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { LabelDot, type Label, type LabelColor } from './LabelPill'; + +const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate']; + +interface LabelAssignPopoverProps { + stackName: string; + allLabels: Label[]; + assignedLabelIds: number[]; + onLabelsChanged: () => void; + children: React.ReactNode; +} + +export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onLabelsChanged, children }: LabelAssignPopoverProps) { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [newColor, setNewColor] = useState('teal'); + const [saving, setSaving] = useState(false); + + const toggleLabel = async (labelId: number) => { + const current = new Set(assignedLabelIds); + if (current.has(labelId)) { + current.delete(labelId); + } else { + current.add(labelId); + } + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/labels`, { + method: 'PUT', + body: JSON.stringify({ labelIds: Array.from(current) }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Failed to update labels.'); + } + onLabelsChanged(); + } catch (err: unknown) { + toast.error((err as Error)?.message || 'Failed to update labels.'); + } + }; + + const createAndAssign = async () => { + if (!newName.trim()) return; + setSaving(true); + try { + const res = await apiFetch('/labels', { + method: 'POST', + body: JSON.stringify({ name: newName.trim(), color: newColor }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Failed to create label.'); + } + const label: Label = await res.json(); + const newIds = [...assignedLabelIds, label.id]; + const assignRes = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/labels`, { + method: 'PUT', + body: JSON.stringify({ labelIds: newIds }), + }); + if (!assignRes.ok) { + const data = await assignRes.json().catch(() => ({})); + throw new Error(data?.error || 'Failed to assign label.'); + } + onLabelsChanged(); + setCreating(false); + setNewName(''); + setNewColor('teal'); + } catch (err: unknown) { + toast.error((err as Error)?.message || 'Failed to create label.'); + } finally { + setSaving(false); + } + }; + + return ( + + + {children} + + +
Labels
+
+ {allLabels.map(label => ( + + ))} + {allLabels.length === 0 && !creating && ( +
No labels yet.
+ )} +
+ {creating ? ( +
+ setNewName(e.target.value)} + className="h-7 text-xs font-mono" + maxLength={30} + autoFocus + onKeyDown={e => { if (e.key === 'Enter') createAndAssign(); if (e.key === 'Escape') setCreating(false); }} + /> +
+ {LABEL_COLORS.map(c => ( +
+
+ + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/LabelPill.tsx b/frontend/src/components/LabelPill.tsx new file mode 100644 index 00000000..8e101de6 --- /dev/null +++ b/frontend/src/components/LabelPill.tsx @@ -0,0 +1,67 @@ +import { type MouseEvent, type ReactNode } from 'react'; + +export type LabelColor = 'teal' | 'blue' | 'purple' | 'rose' | 'amber' | 'green' | 'orange' | 'pink' | 'cyan' | 'slate'; + +export interface Label { + id: number; + node_id: number; + name: string; + color: LabelColor; +} + +const COLOR_STYLES: Record = { + teal: { bg: 'bg-[var(--label-teal-bg)]', text: 'text-[var(--label-teal)]', border: 'border-[var(--label-teal)]/30', activeBg: 'bg-[var(--label-teal)]' }, + blue: { bg: 'bg-[var(--label-blue-bg)]', text: 'text-[var(--label-blue)]', border: 'border-[var(--label-blue)]/30', activeBg: 'bg-[var(--label-blue)]' }, + purple: { bg: 'bg-[var(--label-purple-bg)]', text: 'text-[var(--label-purple)]', border: 'border-[var(--label-purple)]/30', activeBg: 'bg-[var(--label-purple)]' }, + rose: { bg: 'bg-[var(--label-rose-bg)]', text: 'text-[var(--label-rose)]', border: 'border-[var(--label-rose)]/30', activeBg: 'bg-[var(--label-rose)]' }, + amber: { bg: 'bg-[var(--label-amber-bg)]', text: 'text-[var(--label-amber)]', border: 'border-[var(--label-amber)]/30', activeBg: 'bg-[var(--label-amber)]' }, + green: { bg: 'bg-[var(--label-green-bg)]', text: 'text-[var(--label-green)]', border: 'border-[var(--label-green)]/30', activeBg: 'bg-[var(--label-green)]' }, + orange: { bg: 'bg-[var(--label-orange-bg)]', text: 'text-[var(--label-orange)]', border: 'border-[var(--label-orange)]/30', activeBg: 'bg-[var(--label-orange)]' }, + pink: { bg: 'bg-[var(--label-pink-bg)]', text: 'text-[var(--label-pink)]', border: 'border-[var(--label-pink)]/30', activeBg: 'bg-[var(--label-pink)]' }, + cyan: { bg: 'bg-[var(--label-cyan-bg)]', text: 'text-[var(--label-cyan)]', border: 'border-[var(--label-cyan)]/30', activeBg: 'bg-[var(--label-cyan)]' }, + slate: { bg: 'bg-[var(--label-slate-bg)]', text: 'text-[var(--label-slate)]', border: 'border-[var(--label-slate)]/30', activeBg: 'bg-[var(--label-slate)]' }, +}; + +interface LabelPillProps { + label: Label; + active?: boolean; + onClick?: (e: MouseEvent) => void; + onContextMenu?: (e: MouseEvent) => void; + size?: 'sm' | 'md'; + children?: ReactNode; +} + +export function LabelPill({ label, active, onClick, onContextMenu, size = 'md', children }: LabelPillProps) { + const styles = COLOR_STYLES[label.color] ?? COLOR_STYLES.slate; + const sizeClasses = size === 'sm' ? 'text-[10px] px-1.5 py-0' : 'text-[11px] px-2 py-0.5'; + + return ( + + ); +} + +export function LabelDot({ color }: { color: LabelColor }) { + return ( + + ); +} + +export { COLOR_STYLES }; diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index fc7c1ebd..90223364 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -12,7 +12,7 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { Shield, Activity, Bell, Code, Server, Package, - Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, + Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -32,6 +32,7 @@ import { AppStoreSection, SupportSection, AboutSection, + LabelsSection, DEFAULT_SETTINGS, } from './settings'; import type { PatchableSettings, SectionId } from './settings'; @@ -39,18 +40,23 @@ import type { PatchableSettings, SectionId } from './settings'; interface SettingsModalProps { isOpen: boolean; onClose: () => void; + initialSection?: SectionId; } -export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { +export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModalProps) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); const { license, isPro } = useLicense(); const isRemote = activeNode?.type === 'remote'; - const [activeSection, setActiveSection] = useState('account'); + const [activeSection, setActiveSection] = useState(initialSection || 'account'); + + useEffect(() => { + if (isOpen && initialSection) setActiveSection(initialSection); + }, [isOpen, initialSection]); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'registries' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { + if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'registries' || activeSection === 'labels' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -247,6 +253,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { return ; case 'registries': return ; + case 'labels': + return ; case 'system': return ( } label="Registries" locked={!isTeamPro} /> )} + {!isRemote && ( + } label="Labels" locked={!isPro} /> + )} {!isRemote && isAdmin && } diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx new file mode 100644 index 00000000..c78a678a --- /dev/null +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Plus, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { ProGate } from '../ProGate'; +import { LabelDot, type Label, type LabelColor } from '../LabelPill'; + +const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate']; + +export function LabelsSection() { + const [labels, setLabels] = useState([]); + const [loading, setLoading] = useState(true); + const [assignmentCounts, setAssignmentCounts] = useState>({}); + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [editingLabel, setEditingLabel] = useState