mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
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:
@@ -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.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user