mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
@@ -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 “{bulkActionLabel?.name}” stacks?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will {bulkAction} all stacks labeled “{bulkActionLabel?.name}”.
|
||||
{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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 “{deleteTarget?.name}”?</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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user