fix(labels): harden stack labels with nodeId filtering, concurrency guard, and test coverage (#552)

* fix(labels): add nodeId filter, existence check, stale cleanup, concurrency guard, and body validation

- Fix getStacksForLabel to filter by node_id (prevents cross-node data leak)
- Add getLabel(id, nodeId) for single-label existence check
- Add getLabelCount(nodeId) for enforcing per-node label limit (50)
- Add cleanupStaleAssignments to remove orphaned assignments for deleted stacks
- Add label/assignment cleanup to deleteNode transaction
- Add label existence check on bulk action endpoint (returns 404 for missing labels)
- Add concurrency guard on bulk actions (returns 429 if already in-flight)
- Add requireBody guard on all mutation endpoints
- Extract isSqliteUniqueViolation helper to deduplicate constraint checks
- Add MAX_LABELS_PER_NODE constant (50) with limit enforcement on create
- Add diagnostic logging on all label endpoints (gated behind developer_mode)
- Add operational log line for bulk action results

* fix(labels): use ScrollArea, show failure details, add loading feedback, deduplicate constants

- Replace overflow-y-auto div with ScrollArea in LabelAssignPopover (design system)
- Show failed stack names in bulk action error toast
- Add loading toast for context menu label toggle
- Disable bulk action menu items while a bulk action is running
- Disable "New Label" button at 50-label limit with "Limit reached" text
- Export LABEL_COLORS and MAX_LABELS_PER_NODE from LabelPill (single source of truth)
- Import shared constants in LabelAssignPopover and LabelsSection (remove duplicates)
- Add BulkActionResult interface to replace inline type assertion

* test(labels): add comprehensive coverage for label CRUD, assignments, and bulk edge cases

42 tests covering:
- getLabels: empty, ordered, node isolation
- getLabel: found, wrong node, nonexistent
- createLabel: returns with ID, duplicate name constraint
- getLabelCount: correct count, zero for empty node
- updateLabel: name, color, both, not found, wrong node
- deleteLabel: removes label, cascades assignments, wrong node no-op
- setStackLabels: assign, replace, clear, invalid ID throws
- getLabelsForStacks: correct mapping, empty result
- getStacksForLabel: correct results, node filter, empty for nonexistent
- cleanupStaleAssignments: removes stale, preserves valid, handles empty
- deleteNode: cascades labels and assignments
- Edge cases: atomicity, cascade across stacks, multi-label assignment

* docs(labels): document 50-label limit and bulk action failure details

* fix(labels): add missing LabelColor type imports and explicit parameter types

* refactor(labels): extract label types and constants to label-types.ts

Moves LabelColor, Label, LABEL_COLORS, and MAX_LABELS_PER_NODE out of
LabelPill.tsx into a dedicated non-component file. This fixes the
react-refresh/only-export-components lint error caused by mixing
constant exports with component exports.
This commit is contained in:
Anso
2026-04-13 12:49:03 -04:00
committed by GitHub
parent faabbea350
commit a695251f38
10 changed files with 805 additions and 77 deletions
+16 -7
View File
@@ -23,7 +23,8 @@ 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, Tag, Check, ChevronDown } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill';
import { LabelPill, LabelDot } from './LabelPill';
import { type Label as StackLabel } from './label-types';
import { LabelAssignPopover } from './LabelAssignPopover';
import { UserProfileDropdown } from './UserProfileDropdown';
import { apiFetch, fetchForNode } from '@/lib/api';
@@ -78,6 +79,12 @@ interface StackStatusInfo {
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
interface BulkActionResult {
stackName: string;
success: boolean;
error?: string;
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
@@ -1478,15 +1485,15 @@ export default function EditorLayout() {
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}>
<ContextMenuItem disabled={bulkActionRunning} 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); }}>
<ContextMenuItem disabled={bulkActionRunning} 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); }}>
<ContextMenuItem disabled={bulkActionRunning} onClick={() => { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}>
<RotateCw className="h-4 w-4 mr-2" strokeWidth={1.5} />
Restart all
</ContextMenuItem>
@@ -1669,11 +1676,12 @@ export default function EditorLayout() {
onClick={async () => {
const currentIds = (stackLabelMap[file] || []).map(l => l.id);
const newIds = assigned ? currentIds.filter(id => id !== label.id) : [...currentIds, label.id];
const loadingId = toast.loading('Updating labels...');
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.'); }
} catch (err: unknown) { toast.error((err as Error)?.message || 'Failed to update labels.'); } finally { toast.dismiss(loadingId); }
}}
>
<LabelDot color={label.color} />
@@ -2361,9 +2369,10 @@ export default function EditorLayout() {
throw new Error(data?.error || `Bulk ${bulkAction} failed.`);
}
const data = await res.json();
const failed = data.results?.filter((r: { success: boolean }) => !r.success) || [];
const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success);
if (failed.length > 0) {
toast.error(`${failed.length} stack(s) failed to ${bulkAction}.`);
const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', ');
toast.error(`Failed to ${bulkAction}: ${failedNames}`);
} else {
toast.success(`All stacks ${bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'} successfully.`);
}
+2 -1
View File
@@ -25,7 +25,8 @@ import { useLicense } from '@/context/LicenseContext';
import { PaidGate } from './PaidGate';
import FleetSnapshots from './FleetSnapshots';
import { toast } from '@/components/ui/toast-store';
import { LabelDot, type Label as StackLabel } from './LabelPill';
import { LabelDot } from './LabelPill';
import { type Label as StackLabel } from './label-types';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { formatVersion } from '@/lib/version';
import { CursorProvider, Cursor, CursorFollow, CursorContainer } from '@/components/animate-ui/primitives/animate/cursor';
+26 -24
View File
@@ -3,11 +3,11 @@ 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 { ScrollArea } from '@/components/ui/scroll-area';
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'];
import { LabelDot } from './LabelPill';
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from './label-types';
interface LabelAssignPopoverProps {
stackName: string;
@@ -89,25 +89,27 @@ export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onL
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>
<ScrollArea className="max-h-[200px]">
<div>
{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>
</ScrollArea>
{creating ? (
<div className="border-t border-border mt-1 pt-2 px-1 space-y-2">
<Input
@@ -139,7 +141,7 @@ export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onL
</Button>
</div>
</div>
) : (
) : allLabels.length < MAX_LABELS_PER_NODE ? (
<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"
@@ -148,7 +150,7 @@ export function LabelAssignPopover({ stackName, allLabels, assignedLabelIds, onL
<Plus className="w-3.5 h-3.5" strokeWidth={1.5} />
Create new label
</button>
)}
) : null}
</PopoverContent>
</Popover>
);
+1 -10
View File
@@ -1,13 +1,5 @@
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;
}
import { type LabelColor, type Label } from './label-types';
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)]' },
@@ -64,4 +56,3 @@ export function LabelDot({ color }: { color: LabelColor }) {
);
}
export { COLOR_STYLES };
+12
View File
@@ -0,0 +1,12 @@
export type LabelColor = 'teal' | 'blue' | 'purple' | 'rose' | 'amber' | 'green' | 'orange' | 'pink' | 'cyan' | 'slate';
export const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
export const MAX_LABELS_PER_NODE = 50;
export interface Label {
id: number;
node_id: number;
name: string;
color: LabelColor;
}
@@ -23,9 +23,8 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { PaidGate } from '../PaidGate';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
import { LabelDot } from '../LabelPill';
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types';
export function LabelsSection() {
const [labels, setLabels] = useState<Label[]>([]);
@@ -131,9 +130,9 @@ export function LabelsSection() {
<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}>
<Button size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
New Label
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New Label'}
</Button>
</div>