feat(labels): add stack labels for organizing, filtering, and bulk actions (#341)

* 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
This commit is contained in:
Anso
2026-04-02 19:25:43 -04:00
committed by GitHub
parent e1cd1cf7d4
commit 28e7be652c
23 changed files with 1173 additions and 20 deletions
+8
View File
@@ -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
+185
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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) => {
+92
View File
@@ -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<string, Label[]> {
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<string, Label[]> = {};
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);
}
}
+1
View File
@@ -103,6 +103,7 @@
"features/host-console",
"features/multi-node",
"features/fleet-view",
"features/stack-labels",
"features/alerts-notifications",
"features/webhooks",
"features/rbac",
+96
View File
@@ -0,0 +1,96 @@
---
title: Stack Labels
description: Organize your stacks with colored labels for quick filtering, grouping, and bulk actions.
---
<Note>
Stack Labels require a Sencho **Skipper** or **Admiral** license.
</Note>
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.
<Frame>
<img src="/images/stack-labels/sidebar-with-label-dots.png" alt="Sidebar showing stacks with colored label dots" />
</Frame>
## Creating labels
### From Settings
1. Open **Settings → Labels**
2. Click **New Label**
3. Enter a name and pick a color
4. Click **Create**
<Frame>
<img src="/images/stack-labels/settings-labels-fixed.png" alt="Settings Labels section with label management" />
</Frame>
### 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
<Frame>
<img src="/images/stack-labels/context-menu-label-submenu-open.png" alt="Right-click context menu with Labels sub-menu" />
</Frame>
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.
<Frame>
<img src="/images/stack-labels/sidebar-filtered-by-label.png" alt="Sidebar filtered by label showing only matching stacks" />
</Frame>
### 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
<AccordionGroup>
<Accordion title="Labels aren't visible in the sidebar">
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.
</Accordion>
<Accordion title="Labels disappeared after a downgrade">
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.
</Accordion>
<Accordion title="Bulk action failed for some stacks">
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**.
</Accordion>
<Accordion title="Duplicate label name error">
Label names must be unique per node. If you see a &ldquo;label with that name already exists&rdquo; error, choose a different name or edit the existing label.
</Accordion>
</AccordionGroup>
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+192 -4
View File
@@ -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<StackStatus>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [activeLabelFilters, setActiveLabelFilters] = useState<Set<number>>(new Set());
const [bulkActionLabel, setBulkActionLabel] = useState<StackLabel | null>(null);
const [bulkAction, setBulkAction] = useState<string>('');
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<Notification[]>([]);
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"
/>
</div>
{isPro && labels.length > 0 && (
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
{labels.map(label => (
<ContextMenu key={label.id}>
<ContextMenuTrigger asChild>
<div>
<LabelPill
label={label}
size="sm"
active={activeLabelFilters.has(label.id)}
onClick={() => {
setActiveLabelFilters(prev => {
const next = new Set(prev);
if (next.has(label.id)) next.delete(label.id);
else next.add(label.id);
return next;
});
}}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}>
<Play className="h-4 w-4 mr-2" strokeWidth={1.5} />
Deploy all
</ContextMenuItem>
<ContextMenuItem onClick={() => { setBulkActionLabel(label); setBulkAction('stop'); setBulkActionOpen(true); }}>
<Square className="h-4 w-4 mr-2" strokeWidth={1.5} />
Stop all
</ContextMenuItem>
<ContextMenuItem onClick={() => { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}>
<RotateCw className="h-4 w-4 mr-2" strokeWidth={1.5} />
Restart all
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-icon px-4 py-2 mt-2 flex-none">STACKS</h3>
<ScrollArea className="flex-1 px-2 pb-2">
<div data-stacks-loaded={isLoading ? "false" : "true"}>
@@ -1281,6 +1350,13 @@ export default function EditorLayout() {
{stackStatuses[file] === 'running' ? 'UP' : stackStatuses[file] === 'exited' ? 'DN' : '--'}
</span>
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
{isPro && stackLabelMap[file]?.length > 0 && (
<span className="flex items-center gap-0.5 shrink-0 ml-1">
{stackLabelMap[file].map(l => (
<LabelDot key={l.id} color={l.color} />
))}
</span>
)}
{stackUpdates[file] && (
<span
@@ -1301,6 +1377,19 @@ export default function EditorLayout() {
<BellRing className="h-4 w-4 mr-2" />
Alerts
</DropdownMenuItem>
{isPro && (
<LabelAssignPopover
stackName={file}
allLabels={labels}
assignedLabelIds={(stackLabelMap[file] || []).map(l => l.id)}
onLabelsChanged={refreshLabels}
>
<DropdownMenuItem onSelect={e => e.preventDefault()}>
<Tag className="h-4 w-4 mr-2" strokeWidth={1.5} />
Labels
</DropdownMenuItem>
</LabelAssignPopover>
)}
<DropdownMenuItem onClick={() => checkUpdatesForStack()}>
<RefreshCw className="h-4 w-4 mr-2" />
Check for updates
@@ -1349,6 +1438,47 @@ export default function EditorLayout() {
<BellRing className="h-4 w-4 mr-2" />
Alerts
</ContextMenuItem>
{isPro && (
<ContextMenuSub>
<ContextMenuSubTrigger>
<Tag className="h-4 w-4 mr-2" strokeWidth={1.5} />
Labels
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
{labels.map(label => {
const assigned = (stackLabelMap[file] || []).some(l => l.id === label.id);
return (
<ContextMenuItem
key={label.id}
onClick={async () => {
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.'); }
}}
>
<LabelDot color={label.color} />
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
</ContextMenuItem>
);
})}
{labels.length === 0 && (
<ContextMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); }}>
<Plus className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuItem onClick={() => checkUpdatesForStack()}>
<RefreshCw className="h-4 w-4 mr-2" />
Check for updates
@@ -1912,6 +2042,63 @@ export default function EditorLayout() {
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={bulkActionOpen} onOpenChange={setBulkActionOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all &ldquo;{bulkActionLabel?.name}&rdquo; stacks?
</AlertDialogTitle>
<AlertDialogDescription>
This will {bulkAction} all stacks labeled &ldquo;{bulkActionLabel?.name}&rdquo;.
{stackLabelMap && bulkActionLabel && (
<span className="block mt-2 font-mono text-xs">
Affected: {Object.entries(stackLabelMap)
.filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id))
.map(([name]) => name)
.join(', ') || 'none'}
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkActionRunning}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={bulkActionRunning}
onClick={async (e) => {
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`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bash Exec Modal */}
{selectedContainer && (
<BashExecModal
@@ -1936,7 +2123,8 @@ export default function EditorLayout() {
{/* Settings Modal */}
<SettingsModal
isOpen={settingsModalOpen}
onClose={() => setSettingsModalOpen(false)}
onClose={() => { setSettingsModalOpen(false); setSettingsInitialSection('account'); }}
initialSection={settingsInitialSection}
/>
{/* Stack Alert Sheet */}
+68 -4
View File
@@ -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<string, StackLabel[]>;
}) {
const [expanded, setExpanded] = useState(false);
const [containers, setContainers] = useState<StackContainer[] | null>(null);
@@ -228,6 +230,13 @@ function StackSection({ stackName, nodeId, onNavigate }: {
{expanded ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronRight className="w-3 h-3 shrink-0" />}
<Layers className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="truncate flex-1">{stackName}</span>
{labelMap?.[stackName]?.length ? (
<span className="flex items-center gap-0.5 shrink-0">
{labelMap[stackName].map(l => (
<LabelDot key={l.id} color={l.color} />
))}
</span>
) : null}
{containers !== null && (
<span className="text-[10px] text-muted-foreground shrink-0">
{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<string, StackLabel[]> }) {
const { isPro } = useLicense();
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
@@ -414,6 +423,7 @@ function NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId:
stackName={stack}
nodeId={node.id}
onNavigate={onNavigate}
labelMap={labelMap}
/>
))}
</div>
@@ -440,6 +450,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [prefs, setPrefs] = useState<FleetPreferences>(loadPreferences);
const [fleetLabels, setFleetLabels] = useState<StackLabel[]>([]);
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
const { isPro } = useLicense();
const updatePrefs = useCallback((update: Partial<FleetPreferences>) => {
@@ -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 (
<div className="h-full overflow-auto p-6">
@@ -730,6 +768,30 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<AlertTriangle className="w-3 h-3 mr-1" />
Critical Only
</Button>
{fleetLabels.length > 0 && (
<>
<div className="w-px h-5 bg-border mx-1" />
<div className="flex items-center gap-1.5 flex-wrap">
{fleetLabels.map(label => (
<LabelPill
key={label.id}
label={label}
size="sm"
active={labelFilters.has(label.id)}
onClick={() => {
setLabelFilters(prev => {
const next = new Set(prev);
if (next.has(label.id)) next.delete(label.id);
else next.add(label.id);
return next;
});
}}
/>
))}
</div>
</>
)}
</div>
)}
@@ -741,6 +803,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
key={node.id}
node={node}
onNavigate={onNavigateToNode}
labelMap={fleetStackLabelMap}
/>
))}
</div>
@@ -756,6 +819,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
onClick={() => {
setSearchQuery('');
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
setLabelFilters(new Set());
}}
>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
@@ -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<LabelColor>('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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
<PopoverContent
className="w-56 p-2 backdrop-blur-[10px] backdrop-saturate-[1.15]"
align="start"
>
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Labels</div>
<div className="max-h-[200px] overflow-y-auto">
{allLabels.map(label => (
<button
key={label.id}
type="button"
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => toggleLabel(label.id)}
>
<LabelDot color={label.color} />
<span className="flex-1 text-left font-mono text-[12px] truncate">{label.name}</span>
{assignedLabelIds.includes(label.id) && (
<Check className="w-3.5 h-3.5 text-success shrink-0" strokeWidth={1.5} />
)}
</button>
))}
{allLabels.length === 0 && !creating && (
<div className="text-xs text-muted-foreground px-2 py-2">No labels yet.</div>
)}
</div>
{creating ? (
<div className="border-t border-border mt-1 pt-2 px-1 space-y-2">
<Input
placeholder="Label name"
value={newName}
onChange={e => 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); }}
/>
<div className="flex flex-wrap gap-1">
{LABEL_COLORS.map(c => (
<button
key={c}
type="button"
className={`w-5 h-5 rounded-full border-2 transition-colors ${c === newColor ? 'border-foreground' : 'border-transparent'}`}
style={{ backgroundColor: `var(--label-${c})` }}
onClick={() => setNewColor(c)}
/>
))}
</div>
<div className="flex gap-1">
<Button size="sm" className="h-6 text-xs flex-1" onClick={createAndAssign} disabled={saving || !newName.trim()}>
{saving ? 'Creating...' : 'Create'}
</Button>
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => setCreating(false)}>
Cancel
</Button>
</div>
</div>
) : (
<button
type="button"
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-accent/50 transition-colors mt-1 border-t border-border pt-2 cursor-pointer"
onClick={() => setCreating(true)}
>
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
Create new label
</button>
)}
</PopoverContent>
</Popover>
);
}
+67
View File
@@ -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<LabelColor, { bg: string; text: string; border: string; activeBg: string }> = {
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 (
<button
type="button"
onClick={onClick}
onContextMenu={onContextMenu}
className={`
inline-flex items-center gap-1 rounded-md border font-mono
${sizeClasses}
${active
? `${styles.activeBg} text-white border-transparent`
: `${styles.bg} ${styles.text} ${styles.border} hover:border-[var(--label-${label.color})]/60`
}
transition-colors cursor-pointer shrink-0
`}
>
{children ?? label.name}
</button>
);
}
export function LabelDot({ color }: { color: LabelColor }) {
return (
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: `var(--label-${color})` }}
/>
);
}
export { COLOR_STYLES };
+15 -4
View File
@@ -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<SectionId>('account');
const [activeSection, setActiveSection] = useState<SectionId>(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 <ApiTokensSection />;
case 'registries':
return <RegistriesSection />;
case 'labels':
return <LabelsSection />;
case 'system':
return (
<SystemSection
@@ -332,6 +340,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{!isRemote && isAdmin && (
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isTeamPro} />
)}
{!isRemote && (
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPro} />
)}
{!isRemote && isAdmin && <Separator className="my-1.5" />}
@@ -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<Label[]>([]);
const [loading, setLoading] = useState(true);
const [assignmentCounts, setAssignmentCounts] = useState<Record<number, number>>({});
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [formName, setFormName] = useState('');
const [formColor, setFormColor] = useState<LabelColor>('teal');
const [saving, setSaving] = useState(false);
// Delete state
const [deleteTarget, setDeleteTarget] = useState<Label | null>(null);
const fetchLabels = useCallback(async () => {
try {
const [labelsRes, assignmentsRes] = await Promise.all([
apiFetch('/labels'),
apiFetch('/labels/assignments'),
]);
if (labelsRes.ok) setLabels(await labelsRes.json());
if (assignmentsRes.ok) {
const map: Record<string, Label[]> = await assignmentsRes.json();
const counts: Record<number, number> = {};
for (const stackLabels of Object.values(map)) {
for (const l of stackLabels) {
counts[l.id] = (counts[l.id] || 0) + 1;
}
}
setAssignmentCounts(counts);
}
} catch {
toast.error('Failed to load labels.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchLabels(); }, [fetchLabels]);
const openCreate = () => {
setEditingLabel(null);
setFormName('');
setFormColor('teal');
setDialogOpen(true);
};
const openEdit = (label: Label) => {
setEditingLabel(label);
setFormName(label.name);
setFormColor(label.color);
setDialogOpen(true);
};
const handleSave = async () => {
if (!formName.trim()) return;
setSaving(true);
try {
const url = editingLabel ? `/labels/${editingLabel.id}` : '/labels';
const method = editingLabel ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
body: JSON.stringify({ name: formName.trim(), color: formColor }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `Failed to ${editingLabel ? 'update' : 'create'} label.`);
}
toast.success(`Label ${editingLabel ? 'updated' : 'created'}.`);
setDialogOpen(false);
fetchLabels();
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Something went wrong.');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
const res = await apiFetch(`/labels/${deleteTarget.id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Failed to delete label.');
}
toast.success('Label deleted.');
setDeleteTarget(null);
fetchLabels();
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Something went wrong.');
}
};
return (
<ProGate featureName="Stack Labels">
<div className="space-y-4">
<div className="flex items-center justify-between pr-8">
<div>
<h2 className="text-lg font-semibold tracking-tight">Stack Labels</h2>
<p className="text-sm text-muted-foreground">Organize stacks with colored labels for filtering and bulk actions.</p>
</div>
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
New Label
</Button>
</div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">Loading...</div>
) : labels.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No labels yet. Create one to start organizing your stacks.
</div>
) : (
<div className="divide-y divide-border">
{labels.map(label => (
<div key={label.id} className="flex items-center gap-3 px-4 py-3 group transition-colors hover:bg-accent/5">
<LabelDot color={label.color} />
<span className="font-mono text-[13px] flex-1">{label.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => openEdit(label)}
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setDeleteTarget(label)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Create / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[380px]">
<DialogTitle>{editingLabel ? 'Edit Label' : 'Create Label'}</DialogTitle>
<VisuallyHidden><DialogDescription>Manage label properties</DialogDescription></VisuallyHidden>
<div className="space-y-4 mt-2">
<Input
placeholder="Label name"
value={formName}
onChange={e => setFormName(e.target.value)}
className="font-mono"
maxLength={30}
autoFocus
onKeyDown={e => { if (e.key === 'Enter') handleSave(); }}
/>
<div>
<div className="text-xs text-muted-foreground mb-2">Color</div>
<div className="flex flex-wrap gap-2">
{LABEL_COLORS.map(c => (
<button
key={c}
type="button"
className={`w-7 h-7 rounded-full border-2 transition-colors ${c === formColor ? 'border-foreground scale-110' : 'border-transparent hover:border-muted-foreground/30'}`}
style={{ backgroundColor: `var(--label-${c})` }}
onClick={() => setFormColor(c)}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving || !formName.trim()}>
{saving ? 'Saving...' : editingLabel ? 'Save' : 'Create'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteTarget} onOpenChange={open => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete label &ldquo;{deleteTarget?.name}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the label from all stacks. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ProGate>
);
}
@@ -8,5 +8,6 @@ export { DeveloperSection } from './DeveloperSection';
export { AppStoreSection } from './AppStoreSection';
export { SupportSection } from './SupportSection';
export { AboutSection } from './AboutSection';
export { LabelsSection } from './LabelsSection';
export { DEFAULT_SETTINGS } from './types';
export type { PatchableSettings, SectionId, Agent } from './types';
@@ -33,6 +33,7 @@ export type SectionId =
| 'sso'
| 'api-tokens'
| 'registries'
| 'labels'
| 'system'
| 'notifications'
| 'webhooks'
+10 -8
View File
@@ -41,14 +41,16 @@ const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-glass-border bg-popover p-1 text-popover-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
+44
View File
@@ -77,6 +77,28 @@
--card-border-top: oklch(0 0 0 / 0.12);
--card-border-hover: oklch(0 0 0 / 0.15);
/* Label palette */
--label-teal: oklch(0.55 0.12 192);
--label-teal-bg: oklch(0.55 0.12 192 / 0.12);
--label-blue: oklch(0.55 0.14 250);
--label-blue-bg: oklch(0.55 0.14 250 / 0.12);
--label-purple: oklch(0.55 0.14 300);
--label-purple-bg: oklch(0.55 0.14 300 / 0.12);
--label-rose: oklch(0.55 0.16 10);
--label-rose-bg: oklch(0.55 0.16 10 / 0.12);
--label-amber: oklch(0.60 0.16 60);
--label-amber-bg: oklch(0.60 0.16 60 / 0.12);
--label-green: oklch(0.55 0.15 150);
--label-green-bg: oklch(0.55 0.15 150 / 0.12);
--label-orange: oklch(0.58 0.16 45);
--label-orange-bg: oklch(0.58 0.16 45 / 0.12);
--label-pink: oklch(0.58 0.16 340);
--label-pink-bg: oklch(0.58 0.16 340 / 0.12);
--label-cyan: oklch(0.58 0.12 210);
--label-cyan-bg: oklch(0.58 0.12 210 / 0.12);
--label-slate: oklch(0.50 0.01 250);
--label-slate-bg: oklch(0.50 0.01 250 / 0.12);
/* Chart grid & axis */
--chart-grid: oklch(0 0 0 / 0.06);
--chart-tick: oklch(0 0 0 / 0.45);
@@ -194,6 +216,28 @@
--card-border-top: oklch(1 0 0 / 0.14);
--card-border-hover: oklch(1 0 0 / 0.14);
/* Label palette */
--label-teal: oklch(0.75 0.10 192);
--label-teal-bg: oklch(0.75 0.10 192 / 0.15);
--label-blue: oklch(0.72 0.12 250);
--label-blue-bg: oklch(0.72 0.12 250 / 0.15);
--label-purple: oklch(0.72 0.12 300);
--label-purple-bg: oklch(0.72 0.12 300 / 0.15);
--label-rose: oklch(0.72 0.14 10);
--label-rose-bg: oklch(0.72 0.14 10 / 0.15);
--label-amber: oklch(0.78 0.14 60);
--label-amber-bg: oklch(0.78 0.14 60 / 0.15);
--label-green: oklch(0.72 0.13 150);
--label-green-bg: oklch(0.72 0.13 150 / 0.15);
--label-orange: oklch(0.75 0.14 45);
--label-orange-bg: oklch(0.75 0.14 45 / 0.15);
--label-pink: oklch(0.75 0.14 340);
--label-pink-bg: oklch(0.75 0.14 340 / 0.15);
--label-cyan: oklch(0.75 0.10 210);
--label-cyan-bg: oklch(0.75 0.10 210 / 0.15);
--label-slate: oklch(0.65 0.01 250);
--label-slate-bg: oklch(0.65 0.01 250 / 0.15);
/* Chart grid & axis */
--chart-grid: oklch(1 0 0 / 0.05);
--chart-tick: oklch(1 0 0 / 0.40);