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