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.`);
}