mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
chore: merge main into fix/ui-polish
This commit is contained in:
+3
-3
@@ -3,9 +3,9 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/sencho-logo-dark.png" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/png" href="/sencho-logo-light.png" media="(prefers-color-scheme: light)" />
|
||||
<link rel="apple-touch-icon" href="/sencho-logo-light.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/sencho-logo-dark.svg" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="icon" type="image/svg+xml" href="/sencho-logo-light.svg" media="(prefers-color-scheme: light)" />
|
||||
<link rel="apple-touch-icon" href="/sencho-logo-light.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sencho</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
Generated
+583
-578
File diff suppressed because it is too large
Load Diff
+26
-25
@@ -18,21 +18,21 @@
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.17",
|
||||
"@radix-ui/react-checkbox": "^1.3.5",
|
||||
"@radix-ui/react-context-menu": "^2.3.1",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-hover-card": "^1.1.17",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-scroll-area": "^1.2.12",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-separator": "^1.1.10",
|
||||
"@radix-ui/react-slider": "^1.4.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.18",
|
||||
"@radix-ui/react-checkbox": "^1.3.6",
|
||||
"@radix-ui/react-context-menu": "^2.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||
"@radix-ui/react-hover-card": "^1.1.18",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "^1.1.18",
|
||||
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slider": "^1.4.2",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"@radix-ui/react-tooltip": "^1.2.11",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
@@ -41,42 +41,43 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cronstrue": "^3.21.0",
|
||||
"cronstrue": "^3.24.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"fflate": "^0.8.2",
|
||||
"geist": "^1.7.2",
|
||||
"lucide-react": "^1.21.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"motion": "^12.41.0",
|
||||
"motion": "^12.42.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"radix-ui": "^1.6.0",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-is": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^3.9.0",
|
||||
"recharts": "^3.9.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "^3.4.0"
|
||||
"dompurify": "^3.4.11",
|
||||
"@babel/core": "^7.29.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
@@ -84,8 +85,8 @@
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.0",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^8.1.2",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.8 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
@@ -229,7 +229,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
rowCount={logRows.length}
|
||||
errorMessage={errorMessage}
|
||||
countdown={countdown}
|
||||
showCountdown={!gateHoldsOpen}
|
||||
showCountdown={canAutoClose}
|
||||
gateStatus={healthGate?.status ?? null}
|
||||
/>
|
||||
<Button
|
||||
@@ -413,7 +413,7 @@ interface StatusIndicatorProps {
|
||||
rowCount: number;
|
||||
errorMessage?: string;
|
||||
countdown: number;
|
||||
/** False while a health gate is observing or terminal-unhealthy (no auto-close). */
|
||||
/** False when auto-close is not eligible: inline style, or while a gate is observing or terminal-unhealthy. */
|
||||
showCountdown: boolean;
|
||||
/** Active health gate status, or null when no gate was started. */
|
||||
gateStatus: 'observing' | 'passed' | 'failed' | 'unknown' | null;
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { MobileTabBar } from './MobileTabBar';
|
||||
@@ -179,11 +180,14 @@ export default function EditorLayout() {
|
||||
fleetTab, setFleetTab,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill,
|
||||
muteRulePrefill,
|
||||
mobileNavOpen, setMobileNavOpen,
|
||||
handleOpenSettings,
|
||||
handlePrefillConsumed,
|
||||
handleMutePrefillConsumed,
|
||||
handleNavigate,
|
||||
navItems,
|
||||
openMuteRulesWithPrefill,
|
||||
} = navState;
|
||||
|
||||
const {
|
||||
@@ -284,6 +288,8 @@ export default function EditorLayout() {
|
||||
|
||||
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
||||
const stackName = selectedFile || '';
|
||||
const stackDisplayName = selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : '';
|
||||
const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill);
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
@@ -500,6 +506,7 @@ export default function EditorLayout() {
|
||||
onMobileBack={goToMobileList}
|
||||
onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))}
|
||||
hasUnsavedChanges={stackActions.hasUnsavedChanges}
|
||||
stackMuteActions={selectedFile ? stackMuteActions : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -680,6 +687,7 @@ export default function EditorLayout() {
|
||||
},
|
||||
filterChip,
|
||||
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
|
||||
openMuteRulesWithPrefill,
|
||||
}}
|
||||
activitySummary={activitySummary}
|
||||
onActivityAction={handleActivityAction}
|
||||
@@ -755,9 +763,12 @@ export default function EditorLayout() {
|
||||
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
|
||||
schedulePrefill={schedulePrefill}
|
||||
onPrefillConsumed={handlePrefillConsumed}
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={handleMutePrefillConsumed}
|
||||
notifications={notifications}
|
||||
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
||||
onOpenSettingsSection={(section) => openSettings(section)}
|
||||
onOpenMuteRulesWithPrefill={openMuteRulesWithPrefill}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { retryHandlerFor } from './recovery-retry';
|
||||
import type { NotificationItem } from '../dashboard/types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { useAuth } from '@/context/AuthContext';
|
||||
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
export interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -214,6 +215,8 @@ export interface EditorViewProps {
|
||||
// Mobile-only: notifications + more-menu cluster for the detail header right
|
||||
// slot (the global TopBar is dropped on the full-screen detail surface).
|
||||
headerActions?: React.ReactNode;
|
||||
|
||||
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||
}
|
||||
|
||||
export function EditorView(props: EditorViewProps) {
|
||||
@@ -270,6 +273,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
onRefreshState,
|
||||
onDismissRecovery,
|
||||
panelStartedAt,
|
||||
stackMuteActions,
|
||||
} = props;
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
@@ -376,6 +380,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
rollbackStack={rollbackStack}
|
||||
scanStackConfig={scanStackConfig}
|
||||
requestDeleteStack={requestDeleteStack}
|
||||
stackMuteActions={stackMuteActions}
|
||||
/>
|
||||
</div>
|
||||
{recoveryResult && loadingAction == null && (
|
||||
@@ -616,6 +621,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
applying={loadingAction === 'update'}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
notifications={notifications}
|
||||
stackMuteActions={stackMuteActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
onRefreshState,
|
||||
onDismissRecovery,
|
||||
panelStartedAt,
|
||||
stackMuteActions,
|
||||
} = props;
|
||||
|
||||
const [segment, setSegment] = useState<Segment>('logs');
|
||||
@@ -152,6 +153,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
rollbackStack={rollbackStack}
|
||||
scanStackConfig={scanStackConfig}
|
||||
requestDeleteStack={requestDeleteStack}
|
||||
stackMuteActions={stackMuteActions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -243,6 +245,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
applying={loadingAction === 'update'}
|
||||
canEdit={canEditStack}
|
||||
notifications={notifications}
|
||||
stackMuteActions={stackMuteActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import ResourcesView from '../ResourcesView';
|
||||
import HomeDashboard from '../HomeDashboard';
|
||||
import type { NotificationItem } from '../dashboard/types';
|
||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
|
||||
@@ -81,9 +82,12 @@ export interface ViewRouterProps {
|
||||
onClearScheduledOpsFilter: () => void;
|
||||
schedulePrefill: ScheduleTaskPrefill | null;
|
||||
onPrefillConsumed: () => void;
|
||||
muteRulePrefill: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed: () => void;
|
||||
notifications: NotificationItem[];
|
||||
onNavigateToStack: (stackFile: string) => void;
|
||||
onOpenSettingsSection: (section: SectionId) => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
onClearNotifications: () => void;
|
||||
securityTab: SecurityTab;
|
||||
onSecurityTabChange: (tab: SecurityTab) => void;
|
||||
@@ -110,9 +114,12 @@ export function ViewRouter({
|
||||
onClearScheduledOpsFilter,
|
||||
schedulePrefill,
|
||||
onPrefillConsumed,
|
||||
muteRulePrefill,
|
||||
onMutePrefillConsumed,
|
||||
notifications,
|
||||
onNavigateToStack,
|
||||
onOpenSettingsSection,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
onClearNotifications,
|
||||
securityTab,
|
||||
onSecurityTabChange,
|
||||
@@ -128,6 +135,9 @@ export function ViewRouter({
|
||||
<SettingsPage
|
||||
currentSection={settingsSection}
|
||||
onSectionChange={onSettingsSectionChange}
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -186,6 +196,7 @@ export function ViewRouter({
|
||||
<FleetView
|
||||
onNavigateToNode={onFleetNavigateToNode}
|
||||
onOpenSettingsSection={onOpenSettingsSection}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
|
||||
fleetTab={fleetTab}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { StackMuteSubmenu } from '@/components/mute/MuteMenuItems';
|
||||
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import { Sparkline } from '../ui/sparkline';
|
||||
import { ImageSourceMenu } from '../ImageSourceMenu';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -129,6 +131,7 @@ export interface StackIdentityHeaderProps {
|
||||
rollbackStack: () => Promise<void>;
|
||||
scanStackConfig: () => Promise<void>;
|
||||
requestDeleteStack: () => void;
|
||||
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||
}
|
||||
|
||||
// Breadcrumb + serif title + state pill + image ref + action bar. The action
|
||||
@@ -154,6 +157,7 @@ export function StackIdentityHeader({
|
||||
rollbackStack,
|
||||
scanStackConfig,
|
||||
requestDeleteStack,
|
||||
stackMuteActions,
|
||||
}: StackIdentityHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -228,8 +232,9 @@ export function StackIdentityHeader({
|
||||
const canDelete = can('stack:delete', 'stack', stackName);
|
||||
const canRollback = canDeploy && backupInfo.exists;
|
||||
const canScan = trivy.available && isAdmin;
|
||||
const canMute = stackMuteActions?.canMute ?? false;
|
||||
const hasOverflowExtras = canRollback || canScan;
|
||||
const hasOverflow = hasOverflowExtras || canDelete;
|
||||
const hasOverflow = hasOverflowExtras || canDelete || canMute;
|
||||
if (!canDeploy && !hasOverflow) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -287,7 +292,8 @@ export function StackIdentityHeader({
|
||||
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasOverflowExtras && canDelete && <DropdownMenuSeparator />}
|
||||
{stackMuteActions && <StackMuteSubmenu actions={stackMuteActions} />}
|
||||
{(canRollback || canScan || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
|
||||
@@ -3,6 +3,10 @@ import { renderHook } from '@testing-library/react';
|
||||
import { useSidebarContextMenu } from './useSidebarContextMenu';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ hasCapability: () => false }),
|
||||
}));
|
||||
|
||||
// buildMenuCtx derives canOpenApp from the active node plus the stack's
|
||||
// published port; only the fields it reads need to be real, the handler
|
||||
// closures are never invoked here.
|
||||
|
||||
@@ -2,6 +2,15 @@ import { useCallback } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { buildServiceUrl } from '@/lib/serviceUrl';
|
||||
import {
|
||||
createMuteRuleWithToast,
|
||||
stackMuteAllDraft,
|
||||
stackMuteDeploySuccessDraft,
|
||||
stackMuteMonitorDraft,
|
||||
labelMuteAllDraft,
|
||||
labelMuteExternalDraft,
|
||||
labelMuteLowPriorityDraft,
|
||||
} from '@/lib/muteRules';
|
||||
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
|
||||
import type { Label as StackLabel, LabelColor } from '../../label-types';
|
||||
import type { OverlayState } from './useOverlayState';
|
||||
@@ -9,6 +18,7 @@ import type { StackActionsHook } from './useStackActions';
|
||||
import type { useStackListState } from './useStackListState';
|
||||
import type { useViewNavigationState } from './useViewNavigationState';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { PermissionAction } from '@/context/AuthContext';
|
||||
|
||||
type StackListState = ReturnType<typeof useStackListState>;
|
||||
@@ -33,6 +43,7 @@ export function useSidebarContextMenu({
|
||||
isAdmin,
|
||||
can,
|
||||
}: UseSidebarContextMenuOptions) {
|
||||
const { hasCapability } = useNodes();
|
||||
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
|
||||
const sName = file.replace(/\.(yml|yaml)$/, '');
|
||||
const mainPort = stackListState.stackPorts[file];
|
||||
@@ -40,6 +51,8 @@ export function useSidebarContextMenu({
|
||||
// lifecycle affordances; the menu's status union stays three-state.
|
||||
const rawStatus = stackListState.stackStatuses[file] ?? 'unknown';
|
||||
const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus;
|
||||
const nodeId = activeNode?.id ?? null;
|
||||
const canMuteNotifications = isAdmin && hasCapability('notification-suppression');
|
||||
return {
|
||||
stackStatus,
|
||||
// Only offer "Open App" when a browser-reachable URL can actually be built
|
||||
@@ -125,6 +138,31 @@ export function useSidebarContextMenu({
|
||||
navState.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null });
|
||||
navState.setActiveView('scheduled-ops');
|
||||
},
|
||||
canMuteNotifications,
|
||||
muteStackAll: () => {
|
||||
void createMuteRuleWithToast(stackMuteAllDraft(sName, nodeId));
|
||||
},
|
||||
muteStackDeploySuccess: () => {
|
||||
void createMuteRuleWithToast(stackMuteDeploySuccessDraft(sName, nodeId));
|
||||
},
|
||||
muteStackMonitor: () => {
|
||||
void createMuteRuleWithToast(stackMuteMonitorDraft(sName, nodeId));
|
||||
},
|
||||
openStackMuteRules: () => {
|
||||
navState.openMuteRulesWithPrefill(stackMuteAllDraft(sName, nodeId));
|
||||
},
|
||||
muteLabelAll: (labelId: number, labelName: string) => {
|
||||
void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId));
|
||||
},
|
||||
muteLabelExternal: (labelId: number, labelName: string) => {
|
||||
void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId));
|
||||
},
|
||||
muteLabelLowPriority: (labelId: number, labelName: string) => {
|
||||
void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId));
|
||||
},
|
||||
openLabelMuteRules: (labelId: number, labelName: string) => {
|
||||
navState.openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId));
|
||||
},
|
||||
};
|
||||
// Handlers from useStackActions, useOverlayState, useViewNavigationState are
|
||||
// useCallback-stabilized at their owner hooks, so listing the menu surface
|
||||
@@ -134,7 +172,8 @@ export function useSidebarContextMenu({
|
||||
}, [
|
||||
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
|
||||
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
|
||||
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url,
|
||||
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id,
|
||||
hasCapability, navState.openMuteRulesWithPrefill,
|
||||
]);
|
||||
|
||||
return buildMenuCtx;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { SenchoNavigateDetail } from '@/components/NodeManager';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
|
||||
export type ActiveView =
|
||||
| 'dashboard'
|
||||
@@ -64,6 +65,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
|
||||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
||||
const [muteRulePrefill, setMuteRulePrefill] = useState<MuteRuleDraft | null>(null);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
const handleOpenSettings = useCallback((section?: SectionId) => {
|
||||
@@ -73,6 +75,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
}, []);
|
||||
|
||||
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
|
||||
const handleMutePrefillConsumed = useCallback(() => setMuteRulePrefill(null), []);
|
||||
|
||||
const openMuteRulesWithPrefill = useCallback((draft: MuteRuleDraft) => {
|
||||
setMuteRulePrefill(draft);
|
||||
setSettingsSection('notification-suppression');
|
||||
setActiveView('settings');
|
||||
setFilterNodeId(null);
|
||||
}, []);
|
||||
|
||||
const handleNavigate = useCallback((value: string) => {
|
||||
if (value === activeView) return;
|
||||
@@ -166,9 +176,12 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
fleetTab, setFleetTab,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill, setSchedulePrefill,
|
||||
muteRulePrefill, setMuteRulePrefill,
|
||||
mobileNavOpen, setMobileNavOpen,
|
||||
handleOpenSettings,
|
||||
handlePrefillConsumed,
|
||||
handleMutePrefillConsumed,
|
||||
openMuteRulesWithPrefill,
|
||||
handleNavigate,
|
||||
navItems,
|
||||
} as const;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import {
|
||||
RefreshCw, Camera, FileDown,
|
||||
Network, SlidersHorizontal,
|
||||
Send, KeyRound, ArrowLeftRight, Wrench, Workflow,
|
||||
Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||
@@ -21,6 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
|
||||
import { springs } from '@/lib/motion';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
@@ -30,14 +31,17 @@ import { DeploymentsTab } from './blueprints/DeploymentsTab';
|
||||
import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab';
|
||||
import { SecretsTab } from './fleet/secrets/SecretsTab';
|
||||
import { DependencyMapTab } from './fleet/DependencyMapTab';
|
||||
import { ContainerLabelsTab } from './fleet/ContainerLabelsTab';
|
||||
import { useNodeActions } from './nodes/useNodeActions';
|
||||
import type { FleetTab } from '@/lib/events';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
|
||||
interface FleetViewProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
|
||||
onOpenSettingsSection?: (section: SectionId) => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
||||
onFleetUpdatesIntentConsumed?: () => void;
|
||||
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
|
||||
@@ -45,9 +49,11 @@ interface FleetViewProps {
|
||||
onFleetTabConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
|
||||
export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { hasCapability } = useNodes();
|
||||
const containerLabelsEnabled = hasCapability('container-label-inventory');
|
||||
|
||||
const { prefs, updatePrefs } = useFleetPreferences();
|
||||
const updateStatus = useFleetUpdateStatus();
|
||||
@@ -131,6 +137,13 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
|
||||
<Workflow className="w-4 h-4 mr-1.5" />Map
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{containerLabelsEnabled && (
|
||||
<TabsHighlightItem value="container-labels">
|
||||
<TabsTrigger value="container-labels">
|
||||
<Tag className="w-4 h-4 mr-1.5" />Docker Labels
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="deployments">
|
||||
@@ -234,6 +247,7 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
|
||||
onCordonChange={() => { void overview.fetchOverview(true); }}
|
||||
onEditNode={isAdmin ? openEdit : undefined}
|
||||
onDeleteNode={isAdmin ? openDelete : undefined}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
|
||||
onCheckUpdates={updateStatus.checkUpdates}
|
||||
checkingUpdates={updateStatus.checkingUpdates}
|
||||
@@ -255,6 +269,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
|
||||
<TabsContent value="dependencies">
|
||||
<DependencyMapTab />
|
||||
</TabsContent>
|
||||
{containerLabelsEnabled && (
|
||||
<TabsContent value="container-labels">
|
||||
<ContainerLabelsTab onNavigateToNode={onNavigateToNode} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPaid && (
|
||||
<TabsContent value="deployments">
|
||||
<DeploymentsTab />
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { NodeMuteSubmenu } from '@/components/mute/MuteMenuItems';
|
||||
import { useNodeMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
@@ -42,6 +45,7 @@ export interface NodeCardProps {
|
||||
onCordonChange?: () => void;
|
||||
onEdit?: (node: Node) => void;
|
||||
onDelete?: (node: Node) => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
// --- Sub-Components ---
|
||||
@@ -59,7 +63,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||
|
||||
// --- Main Export ---
|
||||
|
||||
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) {
|
||||
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
|
||||
const [loadingStacks, setLoadingStacks] = useState(false);
|
||||
@@ -77,7 +81,12 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
|
||||
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const showMenu = canEdit || canDelete || canCordon;
|
||||
const nodeMuteActions = useNodeMuteActions(
|
||||
node.id,
|
||||
node.name,
|
||||
onOpenMuteRulesWithPrefill ?? (() => {}),
|
||||
);
|
||||
const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill));
|
||||
|
||||
const isOnline = node.status === 'online';
|
||||
const isLocal = node.type === 'local';
|
||||
@@ -182,6 +191,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenMuteRulesWithPrefill && <NodeMuteSubmenu actions={nodeMuteActions} />}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet-
|
||||
import type { Label as StackLabel } from '../label-types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
|
||||
interface OverviewTabProps {
|
||||
loading: boolean;
|
||||
@@ -35,6 +36,7 @@ interface OverviewTabProps {
|
||||
onCordonChange?: () => void;
|
||||
onEditNode?: (node: Node) => void;
|
||||
onDeleteNode?: (node: Node) => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
topologyMode: LayoutMode;
|
||||
onTopologyModeChange: (mode: LayoutMode) => void;
|
||||
topologyPositions: SavedPositions;
|
||||
@@ -70,6 +72,7 @@ export function OverviewTab({
|
||||
onCordonChange,
|
||||
onEditNode,
|
||||
onDeleteNode,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
topologyMode,
|
||||
onTopologyModeChange,
|
||||
topologyPositions,
|
||||
@@ -149,6 +152,7 @@ export function OverviewTab({
|
||||
onCordonChange={onCordonChange}
|
||||
onEdit={onEditNode}
|
||||
onDelete={onDeleteNode}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ function baseProps(node: FleetNode) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useNodesMock.mockReturnValue({ nodes: [] });
|
||||
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Trash2,
|
||||
SlidersHorizontal,
|
||||
CheckCheck,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,10 +23,18 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NotificationCategory, NotificationItem } from './dashboard/types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { createMuteFromNotification } from '@/lib/muteRules';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
||||
import { countVisibleUnread, filterPanelVisible } from '@/lib/notificationVisibility';
|
||||
import type { NotificationCategory, NotificationItem } from './dashboard/types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
const NODE_FILTER_ALL = 'all' as const;
|
||||
const CATEGORY_FILTER_ALL = 'all' as const;
|
||||
@@ -128,6 +137,7 @@ export function NotificationPanel({
|
||||
onNavigate,
|
||||
onNavigateChangelog,
|
||||
}: NotificationPanelProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [filter, setFilter] = useState<NotifFilter>('all');
|
||||
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
|
||||
@@ -369,6 +379,7 @@ export function NotificationPanel({
|
||||
onDelete={onDelete}
|
||||
onNavigate={onNavigate ? handleNavigate : undefined}
|
||||
onNavigateChangelog={onNavigateChangelog}
|
||||
canMute={isAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -386,9 +397,17 @@ interface NotificationRowProps {
|
||||
onDelete: (notif: NotificationItem) => void;
|
||||
onNavigate?: (notif: NotificationItem) => void;
|
||||
onNavigateChangelog?: (notif: NotificationItem) => void;
|
||||
canMute?: boolean;
|
||||
}
|
||||
|
||||
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) {
|
||||
async function createQuickSuppressionRule(
|
||||
notif: NotificationItem,
|
||||
mode: 'category' | 'similar' | 'stack',
|
||||
): Promise<void> {
|
||||
await createMuteFromNotification(notif, mode);
|
||||
}
|
||||
|
||||
function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog, canMute }: NotificationRowProps) {
|
||||
const config = LEVEL_CONFIG[notif.level];
|
||||
const Icon = config.icon;
|
||||
const isUnread = !notif.is_read;
|
||||
@@ -470,15 +489,48 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigate
|
||||
) : (
|
||||
<div className={surfaceClasses}>{content}</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-2 z-20 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onDelete(notif)}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<div className="absolute right-2 top-2 z-20 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{canMute ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title="Mute options"
|
||||
aria-label="Mute options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
{notif.category ? (
|
||||
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'category')}>
|
||||
Mute this category
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'similar')}>
|
||||
Mute notifications like this
|
||||
</DropdownMenuItem>
|
||||
{notif.stack_name ? (
|
||||
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'stack')}>
|
||||
Mute this stack
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => onDelete(notif)}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { excludeLikelySenchoContainers } from '@/lib/senchoContainerFilter';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
|
||||
@@ -48,6 +49,18 @@ const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = {
|
||||
const TIMELINE_WINDOW_HOURS = 24;
|
||||
const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000;
|
||||
|
||||
interface ContainerListItem {
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
State?: string;
|
||||
Image?: string;
|
||||
Labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
function containerDisplayName(c: ContainerListItem): string {
|
||||
return c.Names?.[0]?.replace(/^\//, '') || c.Id.slice(0, 12);
|
||||
}
|
||||
|
||||
function formatHourTick(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
@@ -108,8 +121,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const [runsTotal, setRunsTotal] = useState(0);
|
||||
const runsLimit = 20;
|
||||
|
||||
// Available stacks and nodes for selection
|
||||
// Available stacks, containers, and nodes for selection
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [containers, setContainers] = useState<ContainerListItem[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
|
||||
const filteredTasks = filterNodeId != null
|
||||
@@ -148,6 +162,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchContainers = useCallback(async (nodeId: string) => {
|
||||
try {
|
||||
const res = await fetchForNode('/containers?all=true', parseInt(nodeId, 10));
|
||||
if (res.ok) {
|
||||
const rows = (await res.json()) as ContainerListItem[];
|
||||
setContainers(excludeLikelySenchoContainers(rows));
|
||||
} else {
|
||||
setContainers([]);
|
||||
}
|
||||
} catch {
|
||||
setContainers([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes', { localOnly: true });
|
||||
@@ -194,7 +222,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
? await fetchForNode(endpoint, parseInt(formNodeId, 10))
|
||||
: await apiFetch(endpoint);
|
||||
if (res.ok && !cancelled) {
|
||||
setAvailableServices(await res.json());
|
||||
const services = (await res.json()) as string[];
|
||||
setAvailableServices(services);
|
||||
if (services.length <= 1) {
|
||||
setFormTargetServices([]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
@@ -204,17 +236,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
return () => { cancelled = true; };
|
||||
}, [formAction, formTargetId, formNodeId]);
|
||||
|
||||
// Re-fetch stacks when the node changes. Clearing a stale stack selection is
|
||||
// done in the Node picker's onValueChange (a user-driven change), not here, so
|
||||
// a prefilled or edited node keeps its stack instead of being wiped on open.
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
if (formNodeId) {
|
||||
const actionDef = getActionById(formAction);
|
||||
if (actionDef?.requiresContainer && formNodeId) {
|
||||
fetchContainers(formNodeId);
|
||||
fetchStacks(formNodeId);
|
||||
} else if (formNodeId) {
|
||||
fetchStacks(formNodeId);
|
||||
setContainers([]);
|
||||
} else {
|
||||
setStacks([]);
|
||||
setContainers([]);
|
||||
}
|
||||
}, [formNodeId, dialogOpen, fetchStacks]);
|
||||
}, [formNodeId, formAction, dialogOpen, fetchStacks, fetchContainers]);
|
||||
|
||||
const openCreate = (prefillData?: { stackName: string; nodeId: string }) => {
|
||||
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
|
||||
@@ -301,7 +336,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
enabled: formEnabled,
|
||||
delete_after_run: formDeleteAfterRun,
|
||||
run_at: runAt,
|
||||
target_id: actionDef.requiresStack ? formTargetId : null,
|
||||
target_id: (actionDef.requiresStack || actionDef.requiresContainer) ? formTargetId : null,
|
||||
node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null,
|
||||
prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null,
|
||||
target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null,
|
||||
@@ -455,13 +490,31 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
[nodes],
|
||||
);
|
||||
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
||||
const containerOptions = useMemo(
|
||||
() => containers.map(c => {
|
||||
const name = containerDisplayName(c);
|
||||
const state = c.State ?? 'unknown';
|
||||
const image = (c.Image ?? '').split('@')[0];
|
||||
return { value: name, label: `${name} · ${state} · ${image}` };
|
||||
}),
|
||||
[containers],
|
||||
);
|
||||
const selectedContainer = useMemo(
|
||||
() => containers.find(c => containerDisplayName(c) === formTargetId),
|
||||
[containers, formTargetId],
|
||||
);
|
||||
const selectedContainerStack = selectedContainer?.Labels?.['com.docker.compose.project'];
|
||||
const isUnmanagedContainer = !!selectedContainer && (
|
||||
!selectedContainerStack || !stacks.includes(selectedContainerStack)
|
||||
);
|
||||
const scheduleInvalid = scheduleMode === 'simple'
|
||||
? !!simpleCronError
|
||||
: (!formCron || !!cronFieldError);
|
||||
const isSaveDisabled =
|
||||
saving || !currentAction || !formName || scheduleInvalid
|
||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
||||
|| (!!currentAction?.requiresContainer && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && !formNodeId)
|
||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||
|
||||
const windowEnd = now + TIMELINE_WINDOW_MS;
|
||||
@@ -689,6 +742,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
? task.target_services
|
||||
? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})`
|
||||
: task.target_id
|
||||
: task.target_type === 'container'
|
||||
? task.target_id
|
||||
: task.action === 'update'
|
||||
? 'All eligible stacks'
|
||||
: task.target_type}
|
||||
@@ -817,6 +872,40 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentAction?.requiresContainer && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodeOptions}
|
||||
value={formNodeId}
|
||||
onValueChange={(val) => { setFormNodeId(val); setFormTargetId(''); }}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Container</Label>
|
||||
<Combobox
|
||||
options={containerOptions}
|
||||
value={formTargetId}
|
||||
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
|
||||
placeholder={formNodeId ? 'Select container...' : 'Select a node first'}
|
||||
disabled={!formNodeId}
|
||||
/>
|
||||
</div>
|
||||
{isUnmanagedContainer && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This container is not associated with a Sencho stack. The schedule will target the container by node and name.
|
||||
</p>
|
||||
)}
|
||||
{selectedContainerStack && stacks.includes(selectedContainerStack) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Part of stack: {selectedContainerStack}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresStack && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -833,12 +922,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<Combobox
|
||||
options={stacks.map(s => ({ value: s, label: s }))}
|
||||
value={formTargetId}
|
||||
onValueChange={setFormTargetId}
|
||||
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
|
||||
placeholder={formNodeId ? "Select stack..." : "Select a node first"}
|
||||
disabled={!formNodeId}
|
||||
/>
|
||||
</div>
|
||||
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && (
|
||||
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Services <span className="text-xs text-muted-foreground">(leave empty for all)</span></Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -871,7 +960,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && (
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
@@ -955,7 +1044,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="task-enabled" />
|
||||
<Label htmlFor="task-enabled">Enabled</Label>
|
||||
<label
|
||||
htmlFor="task-enabled"
|
||||
className="text-sm text-stat-value cursor-pointer select-none"
|
||||
>
|
||||
{formEnabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -738,13 +739,13 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0 border-b border-card-border/40 last:border-b-0 text-sm py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="font-mono text-foreground truncate">
|
||||
<div className="flex flex-col gap-0 border-b border-card-border/40 last:border-b-0 text-sm py-2 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="font-mono text-foreground break-words">
|
||||
{policy.service_name ?? <span className="text-muted-foreground font-sans">All services</span>}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<span className="text-muted-foreground text-xs break-words">
|
||||
Unhealthy for {policy.unhealthy_duration_mins} min
|
||||
• Cooldown: {policy.cooldown_mins} min
|
||||
• Max {policy.max_restarts_per_hour}/hr
|
||||
@@ -798,27 +799,35 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po
|
||||
</div>
|
||||
|
||||
{historyOpen && (
|
||||
<div className="border-t border-card-border/40 mt-2 pt-2 space-y-1.5">
|
||||
<div className="border-t border-card-border/40 mt-2 pt-2 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Recent activity</p>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">No history yet.</p>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<div key={entry.id} className="flex items-start gap-2 text-xs">
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums font-mono">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
<span className="font-mono text-foreground shrink-0 truncate max-w-[100px]">
|
||||
{entry.container_name}
|
||||
</span>
|
||||
<span className={`shrink-0 font-medium ${actionColorClass(entry.action)}`}>
|
||||
{actionLabel(entry.action)}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{entry.reason}
|
||||
</span>
|
||||
<ScrollArea className="h-[200px] pr-2">
|
||||
<div className="space-y-1.5">
|
||||
{history.map((entry) => (
|
||||
<div key={entry.id} className="py-1">
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className={`font-medium ${actionColorClass(entry.action)}`}>
|
||||
{actionLabel(entry.action)}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 tabular-nums font-mono text-[10px]">
|
||||
{new Date(entry.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 min-w-0 font-mono text-[11px] text-foreground break-all">
|
||||
{entry.container_name}
|
||||
</div>
|
||||
{entry.reason ? (
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground break-words">
|
||||
{entry.reason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,9 +15,12 @@ import DriftPanel from './stack/DriftPanel';
|
||||
import PreflightPanel from './stack/PreflightPanel';
|
||||
import StoragePanel from './stack/StoragePanel';
|
||||
import EnvironmentPanel from './stack/EnvironmentPanel';
|
||||
import ComposeLabelsPanel from './stack/ComposeLabelsPanel';
|
||||
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from '@/components/dashboard/types';
|
||||
import { ActivityMuteKebab } from '@/components/mute/MuteMenuItems';
|
||||
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
interface StackAnatomyPanelProps {
|
||||
stackName: string;
|
||||
@@ -32,6 +35,7 @@ interface StackAnatomyPanelProps {
|
||||
canEdit: boolean;
|
||||
applying?: boolean;
|
||||
notifications?: NotificationItem[];
|
||||
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||
}
|
||||
|
||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
@@ -82,6 +86,7 @@ export default function StackAnatomyPanel({
|
||||
canEdit,
|
||||
applying = false,
|
||||
notifications,
|
||||
stackMuteActions,
|
||||
}: StackAnatomyPanelProps) {
|
||||
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
||||
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
|
||||
@@ -97,6 +102,7 @@ export default function StackAnatomyPanel({
|
||||
const networkingEnabled = hasCapability('compose-networking');
|
||||
const storageEnabled = hasCapability('compose-storage');
|
||||
const envInventoryEnabled = hasCapability('env-inventory');
|
||||
const composeLabelsEnabled = hasCapability('container-label-inventory');
|
||||
|
||||
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null);
|
||||
// Merged effective facts (services/ports/volumes/networks/restart) for a
|
||||
@@ -366,6 +372,9 @@ export default function StackAnatomyPanel({
|
||||
{envInventoryEnabled && (
|
||||
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Environment</TabsTrigger>
|
||||
)}
|
||||
{composeLabelsEnabled && (
|
||||
<TabsTrigger value="compose-labels" data-testid="compose-labels-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Compose Labels</TabsTrigger>
|
||||
)}
|
||||
{networkingEnabled && (
|
||||
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
||||
)}
|
||||
@@ -409,6 +418,7 @@ export default function StackAnatomyPanel({
|
||||
edit
|
||||
</button>
|
||||
)}
|
||||
{stackMuteActions && <ActivityMuteKebab actions={stackMuteActions} />}
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
|
||||
@@ -618,6 +628,11 @@ export default function StackAnatomyPanel({
|
||||
<EnvironmentPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{composeLabelsEnabled && (
|
||||
<TabsContent value="compose-labels" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<ComposeLabelsPanel stackName={stackName} />
|
||||
</TabsContent>
|
||||
)}
|
||||
{doctorEnabled && (
|
||||
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PreflightPanel stackName={stackName} />
|
||||
|
||||
@@ -78,6 +78,20 @@ describe('DeployFeedbackModal Inline vs Modal style', () => {
|
||||
expect(onMinimize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inline style: succeeded state shows no auto-close countdown label', () => {
|
||||
mockStyle = 'inline';
|
||||
mockPanelState = panel({ status: 'succeeded' });
|
||||
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
expect(screen.queryByText(/closes in/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('modal style: succeeded state shows auto-close countdown label', () => {
|
||||
mockStyle = 'modal';
|
||||
mockPanelState = panel({ status: 'succeeded' });
|
||||
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
expect(screen.getByText(/closes in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('modal style owns the live terminal; inline style renders no terminal (single socket)', () => {
|
||||
mockStyle = 'modal';
|
||||
const view = render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* hub-local endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
|
||||
@@ -338,10 +338,42 @@ describe('ScheduledOperationsView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hides service checkboxes when the stack has only one service', async () => {
|
||||
mockedFetchForNode.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/services')) return jsonResponse(['mariadb']);
|
||||
return jsonResponse(['db-compose']);
|
||||
});
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'db-compose' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks/db-compose/services', 2),
|
||||
);
|
||||
expect(screen.queryByText(/^Services/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows service checkboxes when the stack has multiple services', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'web' }));
|
||||
|
||||
const servicesBlock = (await screen.findByText(/^Services/)).closest<HTMLElement>('.space-y-2');
|
||||
expect(within(servicesBlock!).getAllByRole('checkbox')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders the five registry category lanes in the timeline view', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
// Timeline is the default view; the lane track always renders.
|
||||
for (const lane of ['Stack lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
|
||||
for (const lane of ['Lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) {
|
||||
expect(await screen.findByText(lane)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
@@ -425,6 +457,54 @@ describe('ScheduledOperationsView', () => {
|
||||
await selectAction('Prune Node Resources');
|
||||
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
|
||||
// Container action: Node + Container, no Stack.
|
||||
await selectAction('Restart Container');
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('Container')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits container target payload for a container restart save', async () => {
|
||||
mockedFetchForNode.mockImplementation(async (url: string) => {
|
||||
if (url === '/stacks') return jsonResponse(['web']);
|
||||
if (url.startsWith('/containers')) {
|
||||
return jsonResponse([
|
||||
{ Id: 'abc', Names: ['/watchtower'], State: 'running', Image: 'containrrr/watchtower' },
|
||||
]);
|
||||
}
|
||||
return jsonResponse([]);
|
||||
});
|
||||
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'daily-watchtower');
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Restart Container' }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /watchtower/ }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST',
|
||||
);
|
||||
expect(postCall).toBeTruthy();
|
||||
const body = JSON.parse(postCall![1].body);
|
||||
expect(body).toMatchObject({
|
||||
name: 'daily-watchtower',
|
||||
target_type: 'container',
|
||||
action: 'restart',
|
||||
target_id: 'watchtower',
|
||||
node_id: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('emits node_id and target_id for a stack update save', async () => {
|
||||
|
||||
@@ -54,6 +54,11 @@ export type NotificationCategory =
|
||||
| 'autoheal_triggered'
|
||||
| 'monitor_alert'
|
||||
| 'scan_finding'
|
||||
| 'drift_detected'
|
||||
| 'drift_resolved'
|
||||
| 'update_started'
|
||||
| 'health_gate_passed'
|
||||
| 'health_gate_failed'
|
||||
| 'node_update_available'
|
||||
| 'system';
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment } from 'react';
|
||||
import { ChevronDown, ChevronRight, ExternalLink, Lock, RefreshCw, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
LABEL_DISAMBIGUATION_COPY,
|
||||
SOURCE_LABELS,
|
||||
SOURCE_FACET_LABELS,
|
||||
matchesSearch,
|
||||
sourcesPresent,
|
||||
type ContainerLabelRow,
|
||||
type FleetLabelInventoryResponse,
|
||||
type LabelIndexRow,
|
||||
type LabelSource,
|
||||
type LabelValue,
|
||||
} from '@/lib/labelInventory';
|
||||
|
||||
type ViewMode = 'container' | 'label';
|
||||
|
||||
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
|
||||
interface ContainerLabelsTabProps {
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
}
|
||||
|
||||
function LabelValueCell({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) {
|
||||
const { isAdmin } = useAuth();
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 font-mono text-xs text-foreground/90">
|
||||
{label.redacted && <Lock className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />}
|
||||
<span>{label.value}</span>
|
||||
{label.redacted && isAdmin && onReveal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReveal}
|
||||
className="font-mono text-[10px] uppercase tracking-wide text-brand hover:underline"
|
||||
>
|
||||
Reveal
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContainerLabelsTab({ onNavigateToNode }: ContainerLabelsTabProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('container');
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revealSecrets, setRevealSecrets] = useState(false);
|
||||
const [data, setData] = useState<FleetLabelInventoryResponse | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [excludedSources, setExcludedSources] = useState<Set<LabelSource>>(new Set());
|
||||
|
||||
const fetchInventory = useCallback(async (reveal = revealSecrets) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = reveal ? '?reveal=1' : '';
|
||||
const res = await apiFetch(`/fleet/container-labels${qs}`, { localOnly: true });
|
||||
if (!res.ok) throw new Error('Failed to load Docker label audit');
|
||||
setData(await res.json() as FleetLabelInventoryResponse);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load Docker label audit';
|
||||
toast.error(msg);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [revealSecrets]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchInventory();
|
||||
}, [fetchInventory]);
|
||||
|
||||
const allContainers = useMemo(() => {
|
||||
if (!data) return [] as Array<ContainerLabelRow & { nodeId: number; nodeName: string }>;
|
||||
const rows: Array<ContainerLabelRow & { nodeId: number; nodeName: string }> = [];
|
||||
for (const node of data.nodes) {
|
||||
if (node.status !== 'ok' || !node.inventory) continue;
|
||||
for (const c of node.inventory.containers) {
|
||||
rows.push({ ...c, nodeId: node.nodeId, nodeName: node.nodeName });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}, [data]);
|
||||
|
||||
const filteredContainers = useMemo(() => {
|
||||
return allContainers.filter(c =>
|
||||
matchesSearch(search, c.name, c.stack, c.service, c.state, c.nodeName)
|
||||
|| c.labels.some(l => matchesSearch(search, l.key, l.value)),
|
||||
);
|
||||
}, [allContainers, search]);
|
||||
|
||||
// Sources present across the aggregated index, for the data-driven facet row. Derived from
|
||||
// the unfiltered data so toggling a facet off never removes the facet itself.
|
||||
const labelSourceFacets = useMemo(
|
||||
() => sourcesPresent((data?.aggregatedByLabel ?? []).map(r => r.source)),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filteredByLabel = useMemo(() => {
|
||||
const source = data?.aggregatedByLabel ?? [];
|
||||
return source.filter(row =>
|
||||
!excludedSources.has(row.source)
|
||||
&& (matchesSearch(search, row.key, row.value)
|
||||
|| row.containers.some(c => matchesSearch(search, c.name, c.stack, c.nodeName))),
|
||||
);
|
||||
}, [data, search, excludedSources]);
|
||||
|
||||
const toggleSource = (s: LabelSource) => {
|
||||
setExcludedSources(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s); else next.add(s);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Nodes that were unreachable (nodeErrors) or whose inventory came back partial (some
|
||||
// containers or images could not be inspected). Named so the warning is truthful.
|
||||
const degradedNodes = useMemo(() => {
|
||||
const unreachable: string[] = [];
|
||||
const partial: string[] = [];
|
||||
if (data) {
|
||||
const nameById = new Map(data.nodes.map(n => [n.nodeId, n.nodeName] as const));
|
||||
for (const idStr of Object.keys(data.nodeErrors)) {
|
||||
unreachable.push(nameById.get(Number(idStr)) ?? `node ${idStr}`);
|
||||
}
|
||||
for (const n of data.nodes) {
|
||||
if (n.status === 'ok' && n.inventory?.partial) partial.push(n.nodeName);
|
||||
}
|
||||
}
|
||||
return { unreachable, partial };
|
||||
}, [data]);
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReveal = () => {
|
||||
setRevealSecrets(true);
|
||||
void fetchInventory(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="container-labels-tab">
|
||||
<p className="text-xs text-stat-subtitle leading-relaxed max-w-3xl">{LABEL_DISAMBIGUATION_COPY}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Docker label audit</p>
|
||||
<SegmentedControl
|
||||
className="mt-2"
|
||||
value={viewMode}
|
||||
onChange={(v) => setViewMode(v as ViewMode)}
|
||||
options={[
|
||||
{ value: 'container', label: 'By container' },
|
||||
{ value: 'label', label: 'By label' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter labels, containers, stacks..."
|
||||
className="h-9 w-56 pl-8 max-md:w-full"
|
||||
/>
|
||||
</div>
|
||||
{viewMode === 'label' && labelSourceFacets.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={excludedSources.size > 0 ? 'default' : 'outline'} size="sm" className="h-9 gap-2 shrink-0">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
{excludedSources.size > 0 && (
|
||||
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">{excludedSources.size}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className={FILTER_SECTION_LABEL_CLASS}>Defined by</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{labelSourceFacets.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={!excludedSources.has(s) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
aria-pressed={!excludedSources.has(s)}
|
||||
onClick={() => toggleSource(s)}
|
||||
>
|
||||
{SOURCE_FACET_LABELS[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{excludedSources.size > 0 && (
|
||||
<Button variant="ghost" size="sm" className="w-full h-8 text-xs" onClick={() => setExcludedSources(new Set())}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-9 w-9 p-0" onClick={() => void fetchInventory()} disabled={loading} aria-label="Refresh">
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !data && (
|
||||
<p className="text-sm text-stat-subtitle">Loading Docker label audit...</p>
|
||||
)}
|
||||
|
||||
{!loading && data && (degradedNodes.unreachable.length > 0 || degradedNodes.partial.length > 0) && (
|
||||
<p className="text-xs text-warning">
|
||||
{degradedNodes.unreachable.length > 0 && `Could not reach ${degradedNodes.unreachable.join(', ')}. `}
|
||||
{degradedNodes.partial.length > 0 && `Some containers or images could not be inspected on ${degradedNodes.partial.join(', ')}. `}
|
||||
Showing partial fleet data.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{viewMode === 'container' && (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden max-md:overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Container</TableHead>
|
||||
<TableHead>Stack</TableHead>
|
||||
<TableHead>Node</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead className="text-right">Labels</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredContainers.map((c) => {
|
||||
const rowKey = `${c.nodeId}:${c.id}`;
|
||||
const isOpen = expanded.has(rowKey);
|
||||
return (
|
||||
<Fragment key={rowKey}>
|
||||
<TableRow className="hover:bg-muted/30">
|
||||
<TableCell>
|
||||
<button type="button" onClick={() => toggleExpanded(rowKey)} className="text-muted-foreground">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.name || c.id.slice(0, 12)}</TableCell>
|
||||
<TableCell className="text-xs">{c.stack ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs">{c.nodeName}</TableCell>
|
||||
<TableCell className="text-xs">{c.state}</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">{c.labels.length}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{c.stack && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-[10px]" onClick={() => onNavigateToNode(c.nodeId, c.stack!)}>
|
||||
<ExternalLink className="h-3 w-3 mr-1" /> Open stack
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isOpen && (
|
||||
<TableRow key={`${rowKey}-detail`}>
|
||||
<TableCell colSpan={7} className="bg-card/40 p-0">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{c.labels.map((label) => (
|
||||
<TableRow key={`${rowKey}-${label.key}`}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground w-[40%]">{label.key}</TableCell>
|
||||
<TableCell>
|
||||
<LabelValueCell label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] text-stat-subtitle">{SOURCE_LABELS[label.source]}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{c.labels.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-xs text-stat-subtitle py-3">No labels on this container.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredContainers.length === 0 && !loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-sm text-stat-subtitle py-6 text-center">No containers match this filter.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'label' && (
|
||||
<p className="text-[10px] text-stat-subtitle leading-relaxed max-w-3xl">
|
||||
Fleet shows container-level provenance and cannot distinguish Compose-declared labels from other container-set labels, so those appear as "Present at runtime".
|
||||
</p>
|
||||
)}
|
||||
|
||||
{viewMode === 'label' && (
|
||||
<div className="space-y-3">
|
||||
{filteredByLabel.map((row) => (
|
||||
<LabelGroupCard key={`${row.key}=${row.value}=${row.source}`} row={row} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} onNavigateToNode={onNavigateToNode} />
|
||||
))}
|
||||
{filteredByLabel.length === 0 && !loading && (
|
||||
<p className="text-sm text-stat-subtitle py-4 text-center">No labels match this filter.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-stat-subtitle leading-relaxed max-w-3xl">
|
||||
Runtime labels are static until the container is recreated. Changes in Compose require save and redeploy.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelGroupCard({
|
||||
row,
|
||||
onReveal,
|
||||
onNavigateToNode,
|
||||
}: {
|
||||
row: LabelIndexRow;
|
||||
onReveal?: () => void;
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border bg-card/40 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className="font-mono text-xs font-medium">{row.key}</span>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<LabelValueCell label={{ key: row.key, value: row.value, source: row.source, redacted: row.redacted }} onReveal={onReveal} />
|
||||
<Badge variant="outline" className="text-[10px]">{SOURCE_LABELS[row.source]}</Badge>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{row.containers.map((c) => (
|
||||
<li key={`${c.nodeId ?? 'local'}:${c.id}`} className="flex flex-wrap items-center gap-2 text-xs text-stat-subtitle">
|
||||
<span className="font-mono text-foreground/80">{c.name}</span>
|
||||
{c.nodeName && <Badge variant="secondary" className="text-[10px] h-5">{c.nodeName}</Badge>}
|
||||
{c.stack && (
|
||||
<>
|
||||
<span>· {c.stack}</span>
|
||||
{c.nodeId !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-brand hover:underline font-mono text-[10px] uppercase"
|
||||
onClick={() => onNavigateToNode(c.nodeId!, c.stack!)}
|
||||
>
|
||||
Open stack
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ContainerLabelsTab } from '../ContainerLabelsTab';
|
||||
import { LABEL_DISAMBIGUATION_COPY } from '@/lib/labelInventory';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
const mockFleet = {
|
||||
nodes: [
|
||||
{
|
||||
nodeId: 1,
|
||||
nodeName: 'Local',
|
||||
status: 'ok' as const,
|
||||
error: null,
|
||||
inventory: {
|
||||
nodeId: 1,
|
||||
partial: false,
|
||||
generatedAt: Date.now(),
|
||||
containers: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'web-1',
|
||||
stack: 'demo',
|
||||
service: 'web',
|
||||
state: 'running',
|
||||
labels: [{ key: 'traefik.enable', value: 'true', source: 'runtime' as const }],
|
||||
},
|
||||
],
|
||||
byLabel: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
aggregatedByLabel: [
|
||||
{
|
||||
key: 'traefik.enable',
|
||||
value: 'true',
|
||||
source: 'runtime' as const,
|
||||
containers: [{ id: 'c1', name: 'web-1', stack: 'demo', service: 'web', nodeId: 1, nodeName: 'Local' }],
|
||||
},
|
||||
],
|
||||
nodeErrors: {},
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
// Two rows sharing the same key=value but distinct sources (image vs runtime).
|
||||
const dupMock = {
|
||||
nodes: [{ nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: false, generatedAt: Date.now(), containers: [], byLabel: [] } }],
|
||||
aggregatedByLabel: [
|
||||
{ key: 'dup.label', value: 'v', source: 'image' as const, containers: [{ id: 'c1', name: 'a-1', stack: 's', service: 'a', nodeId: 1, nodeName: 'Local' }] },
|
||||
{ key: 'dup.label', value: 'v', source: 'runtime' as const, containers: [{ id: 'c2', name: 'b-1', stack: 's', service: 'b', nodeId: 1, nodeName: 'Local' }] },
|
||||
],
|
||||
nodeErrors: {},
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
describe('ContainerLabelsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockFleet,
|
||||
} as Response);
|
||||
});
|
||||
|
||||
it('renders audit sections and toggles view mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument();
|
||||
expect(screen.getByText('Docker label audit')).toBeInTheDocument();
|
||||
expect(await screen.findByText('web-1')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('By label'));
|
||||
expect(await screen.findByText('traefik.enable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the same key=value distinct per source with its own badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
expect(screen.getAllByText('dup.label')).toHaveLength(2);
|
||||
// The misleading "External automation label" marker on non-Compose keys was removed.
|
||||
expect(screen.queryByText(/External automation label/)).toBeNull();
|
||||
});
|
||||
|
||||
it('filters the by-label list by search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
expect(screen.getByText('traefik.enable')).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope');
|
||||
expect(screen.queryByText('traefik.enable')).toBeNull();
|
||||
expect(screen.getByText('No labels match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters the by-container list by search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
expect(await screen.findByText('web-1')).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope');
|
||||
expect(screen.queryByText('web-1')).toBeNull();
|
||||
expect(screen.getByText('No containers match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes a facet in the Filters popover only for sources present, with exact labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
// mockFleet has only a runtime source.
|
||||
expect(screen.getByRole('button', { name: 'Runtime' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Image' })).toBeNull();
|
||||
});
|
||||
|
||||
it('filters the by-label list by source facet, hiding an unpressed source', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
expect(screen.getAllByText('dup.label')).toHaveLength(2);
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
const imageBtn = screen.getByRole('button', { name: 'Image' });
|
||||
expect(imageBtn).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await user.click(imageBtn);
|
||||
expect(screen.getAllByText('dup.label')).toHaveLength(1);
|
||||
// The facet stays present (just unpressed) because facets derive from unfiltered data.
|
||||
expect(screen.getByRole('button', { name: 'Image' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('shows the empty state when every source facet is turned off', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
await user.click(await screen.findByText('By label'));
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Image' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Runtime' }));
|
||||
expect(screen.getByText('No labels match this filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names unreachable and partial nodes in the warning', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nodes: [
|
||||
{ nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: true, generatedAt: Date.now(), containers: [], byLabel: [] } },
|
||||
{ nodeId: 2, nodeName: 'Edge', status: 'error' as const, error: 'boom', inventory: null },
|
||||
],
|
||||
aggregatedByLabel: [],
|
||||
nodeErrors: { 2: 'boom' },
|
||||
generatedAt: Date.now(),
|
||||
}),
|
||||
} as Response);
|
||||
render(<ContainerLabelsTab onNavigateToNode={vi.fn()} />);
|
||||
expect(await screen.findByText(/Could not reach Edge/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not be inspected on Local/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { BellOff, MoreVertical } from 'lucide-react';
|
||||
import type { MouseEvent } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { useStackMuteActions, useLabelMuteActions, useNodeMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
type StackMuteActions = ReturnType<typeof useStackMuteActions>;
|
||||
type LabelMuteActions = ReturnType<typeof useLabelMuteActions>;
|
||||
type NodeMuteActions = ReturnType<typeof useNodeMuteActions>;
|
||||
|
||||
export function StackMuteSubmenu({ actions }: { actions: StackMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Mute
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelMuteSubmenu({ actions }: { actions: LabelMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Mute
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeMuteSubmenu({ actions }: { actions: NodeMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Mute
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute node notifications</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteUpdates}>Mute update notifications for this node</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts on this node</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage node mute rules</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
export function LabelGroupMuteKebab({
|
||||
actions,
|
||||
}: {
|
||||
actions: LabelMuteActions;
|
||||
}) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Label mute actions"
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded text-stat-icon hover:text-foreground hover:bg-glass-highlight/40 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56" onClick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityMuteKebab({ actions }: { actions: StackMuteActions }) {
|
||||
if (!actions.canMute) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Mute stack notifications"
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<BellOff className="h-3 w-3" strokeWidth={1.5} />
|
||||
mute
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, BellOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
@@ -13,13 +13,17 @@ import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import { labelMuteAllDraft, type MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { useCanMuteNotifications } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
interface LabelsSectionProps {
|
||||
onLabelsChanged?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
const { can } = useAuth();
|
||||
export function LabelsSection({ onLabelsChanged, onOpenMuteRulesWithPrefill }: LabelsSectionProps = {}) {
|
||||
const { can, isAdmin } = useAuth();
|
||||
const canMute = useCanMuteNotifications();
|
||||
// Mirrors the backend `requirePermission('stack:edit')` guard on
|
||||
// POST/PUT/DELETE /api/labels, keeping a viewer/deployer/auditor from
|
||||
// clicking through to a guaranteed 403.
|
||||
@@ -161,6 +165,17 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{canMute && onOpenMuteRulesWithPrefill && isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Mute notifications for this label"
|
||||
onClick={() => onOpenMuteRulesWithPrefill(labelMuteAllDraft(label.id, label.name))}
|
||||
>
|
||||
<BellOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{canMutate && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import type { ComboboxOption } from '@/components/ui/combobox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import type { NotificationCategory } from '@/components/dashboard/types';
|
||||
import type { Label as StackLabel } from '@/components/label-types';
|
||||
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
||||
import { emitMuteRulesChanged, type MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { useMuteRulesRefresh } from '@/hooks/useMuteRulesRefresh';
|
||||
import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
|
||||
type NotificationLevel = 'info' | 'warning' | 'error';
|
||||
type AppliesTo = 'bell' | 'external' | 'both';
|
||||
type ExpirationPreset = 'forever' | '1h' | '24h' | 'custom';
|
||||
|
||||
interface NotificationSuppressionRule {
|
||||
id: number;
|
||||
name: string;
|
||||
node_id: number | null;
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: NotificationCategory[] | null;
|
||||
levels: NotificationLevel[] | null;
|
||||
applies_to: AppliesTo;
|
||||
enabled: boolean;
|
||||
expires_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<NotificationLevel, string> = {
|
||||
info: 'Info',
|
||||
warning: 'Warning',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
const APPLIES_TO_LABELS: Record<AppliesTo, string> = {
|
||||
bell: 'Bell only',
|
||||
external: 'External only',
|
||||
both: 'Bell and external',
|
||||
};
|
||||
|
||||
function expirationFromPreset(preset: ExpirationPreset, customMs: number | null): number | null {
|
||||
if (preset === 'forever') return null;
|
||||
if (preset === '1h') return Date.now() + 3_600_000;
|
||||
if (preset === '24h') return Date.now() + 86_400_000;
|
||||
return customMs;
|
||||
}
|
||||
|
||||
function presetFromExpiresAt(expires_at: number | null): { preset: ExpirationPreset; customMs: number | null } {
|
||||
if (expires_at == null) return { preset: 'forever', customMs: null };
|
||||
return { preset: 'custom', customMs: expires_at };
|
||||
}
|
||||
|
||||
function formatExpiry(expires_at: number | null): string {
|
||||
if (expires_at == null) return 'Never';
|
||||
if (expires_at <= Date.now()) return 'Expired';
|
||||
return new Date(expires_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function applyDraftToForm(
|
||||
draft: MuteRuleDraft,
|
||||
setters: {
|
||||
setFormName: (v: string) => void;
|
||||
setFormNodeId: (v: number | null) => void;
|
||||
setFormStacks: (v: string[]) => void;
|
||||
setFormLabelIds: (v: number[]) => void;
|
||||
setFormCategories: (v: NotificationCategory[]) => void;
|
||||
setFormLevels: (v: NotificationLevel[]) => void;
|
||||
setFormAppliesTo: (v: AppliesTo) => void;
|
||||
setFormEnabled: (v: boolean) => void;
|
||||
},
|
||||
) {
|
||||
setters.setFormName(draft.name);
|
||||
setters.setFormNodeId(draft.node_id ?? null);
|
||||
setters.setFormStacks(draft.stack_patterns ?? []);
|
||||
setters.setFormLabelIds(draft.label_ids ?? []);
|
||||
setters.setFormCategories(draft.categories ?? []);
|
||||
setters.setFormLevels(draft.levels ?? []);
|
||||
setters.setFormAppliesTo(draft.applies_to ?? 'both');
|
||||
setters.setFormEnabled(draft.enabled ?? true);
|
||||
}
|
||||
|
||||
interface NotificationSuppressionSectionProps {
|
||||
prefill?: MuteRuleDraft | null;
|
||||
onPrefillConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function NotificationSuppressionSection({
|
||||
prefill = null,
|
||||
onPrefillConsumed,
|
||||
}: NotificationSuppressionSectionProps) {
|
||||
const { nodes } = useNodes();
|
||||
const [rules, setRules] = useState<NotificationSuppressionRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [deleteRuleId, setDeleteRuleId] = useState<number | null>(null);
|
||||
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
|
||||
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
|
||||
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formNodeId, setFormNodeId] = useState<number | null>(null);
|
||||
const [formStacks, setFormStacks] = useState<string[]>([]);
|
||||
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
|
||||
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
|
||||
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
|
||||
const [formAppliesTo, setFormAppliesTo] = useState<AppliesTo>('both');
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
|
||||
const [formCustomExpiry, setFormCustomExpiry] = useState('');
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/notification-suppression-rules');
|
||||
if (res.ok) setRules(await res.json());
|
||||
} catch {
|
||||
toast.error('Failed to load mute rules.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStacks = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stacks');
|
||||
if (res.ok) {
|
||||
const data: string[] = await res.json();
|
||||
setStackOptions(data.map((s) => ({ value: s, label: s })));
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}, []);
|
||||
|
||||
const fetchLabels = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/labels');
|
||||
if (res.ok) setLabelOptions(await res.json());
|
||||
} catch { /* non-critical */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([fetchRules(), fetchStacks(), fetchLabels()]);
|
||||
}, [fetchRules, fetchStacks, fetchLabels]);
|
||||
|
||||
useMuteRulesRefresh(fetchRules);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefill) return;
|
||||
applyDraftToForm(prefill, {
|
||||
setFormName,
|
||||
setFormNodeId,
|
||||
setFormStacks,
|
||||
setFormLabelIds,
|
||||
setFormCategories,
|
||||
setFormLevels,
|
||||
setFormAppliesTo,
|
||||
setFormEnabled,
|
||||
});
|
||||
setFormExpirationPreset('forever');
|
||||
setFormCustomExpiry('');
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
onPrefillConsumed?.();
|
||||
}, [prefill, onPrefillConsumed]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormName('');
|
||||
setFormNodeId(null);
|
||||
setFormStacks([]);
|
||||
setFormLabelIds([]);
|
||||
setFormCategories([]);
|
||||
setFormLevels([]);
|
||||
setFormAppliesTo('both');
|
||||
setFormEnabled(true);
|
||||
setFormExpirationPreset('forever');
|
||||
setFormCustomExpiry('');
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const startEdit = (rule: NotificationSuppressionRule) => {
|
||||
const { preset, customMs } = presetFromExpiresAt(rule.expires_at);
|
||||
setEditingId(rule.id);
|
||||
setFormName(rule.name);
|
||||
setFormNodeId(rule.node_id);
|
||||
setFormStacks([...rule.stack_patterns]);
|
||||
setFormLabelIds(rule.label_ids ? [...rule.label_ids] : []);
|
||||
setFormCategories(rule.categories ? [...rule.categories] : []);
|
||||
setFormLevels(rule.levels ? [...rule.levels] : []);
|
||||
setFormAppliesTo(rule.applies_to);
|
||||
setFormEnabled(rule.enabled);
|
||||
setFormExpirationPreset(preset);
|
||||
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error('Name is required.'); return; }
|
||||
const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null;
|
||||
if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) {
|
||||
toast.error('Choose a valid custom expiration date.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: formName.trim(),
|
||||
node_id: formNodeId,
|
||||
stack_patterns: formStacks,
|
||||
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
|
||||
categories: formCategories.length > 0 ? formCategories : null,
|
||||
levels: formLevels.length > 0 ? formLevels : null,
|
||||
applies_to: formAppliesTo,
|
||||
enabled: formEnabled,
|
||||
expires_at: expirationFromPreset(formExpirationPreset, customMs),
|
||||
};
|
||||
|
||||
const url = editingId
|
||||
? `/notification-suppression-rules/${editingId}`
|
||||
: '/notification-suppression-rules';
|
||||
const res = await apiFetch(url, {
|
||||
method: editingId ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(editingId ? 'Mute rule updated.' : 'Mute rule created.');
|
||||
emitMuteRulesChanged();
|
||||
resetForm();
|
||||
fetchRules();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Something went wrong.');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deleteRuleId == null) return;
|
||||
try {
|
||||
const res = await apiFetch(`/notification-suppression-rules/${deleteRuleId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
toast.success('Mute rule deleted.');
|
||||
emitMuteRulesChanged();
|
||||
fetchRules();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Something went wrong.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error.');
|
||||
} finally {
|
||||
setDeleteRuleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (rule: NotificationSuppressionRule) => {
|
||||
try {
|
||||
const res = await apiFetch(`/notification-suppression-rules/${rule.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !rule.enabled }),
|
||||
});
|
||||
if (res.ok) {
|
||||
emitMuteRulesChanged();
|
||||
fetchRules();
|
||||
}
|
||||
else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Something went wrong.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error.');
|
||||
}
|
||||
};
|
||||
|
||||
const addStack = (stackName: string) => {
|
||||
if (stackName && !formStacks.includes(stackName)) setFormStacks((prev) => [...prev, stackName]);
|
||||
};
|
||||
const removeStack = (stackName: string) => setFormStacks((prev) => prev.filter((s) => s !== stackName));
|
||||
const addLabel = (idStr: string) => {
|
||||
const id = Number(idStr);
|
||||
if (!Number.isNaN(id) && id > 0 && !formLabelIds.includes(id)) setFormLabelIds((prev) => [...prev, id]);
|
||||
};
|
||||
const removeLabel = (id: number) => setFormLabelIds((prev) => prev.filter((l) => l !== id));
|
||||
const addCategory = (cat: string) => {
|
||||
const c = cat as NotificationCategory;
|
||||
if (c && !formCategories.includes(c)) setFormCategories((prev) => [...prev, c]);
|
||||
};
|
||||
const removeCategory = (cat: NotificationCategory) => setFormCategories((prev) => prev.filter((c) => c !== cat));
|
||||
const addLevel = (level: string) => {
|
||||
const l = level as NotificationLevel;
|
||||
if (l && !formLevels.includes(l)) setFormLevels((prev) => [...prev, l]);
|
||||
};
|
||||
const removeLevel = (level: NotificationLevel) => setFormLevels((prev) => prev.filter((x) => x !== level));
|
||||
|
||||
const enabledCount = rules.filter((r) => r.enabled && (r.expires_at == null || r.expires_at > Date.now())).length;
|
||||
useMastheadStats(
|
||||
loading ? null : [
|
||||
{ label: 'RULES', value: `${rules.length}` },
|
||||
{ label: 'ACTIVE', value: `${enabledCount}`, tone: enabledCount > 0 ? 'value' : 'subtitle' },
|
||||
],
|
||||
);
|
||||
|
||||
const availableStackOptions = stackOptions.filter((o) => !formStacks.includes(o.value));
|
||||
const availableLabelOptions = useMemo<ComboboxOption[]>(
|
||||
() => labelOptions.filter((l) => !formLabelIds.includes(l.id)).map((l) => ({ value: String(l.id), label: l.name })),
|
||||
[labelOptions, formLabelIds],
|
||||
);
|
||||
const availableCategoryOptions = useMemo<ComboboxOption[]>(
|
||||
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[])
|
||||
.filter((c) => !formCategories.includes(c))
|
||||
.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })),
|
||||
[formCategories],
|
||||
);
|
||||
const availableLevelOptions = useMemo<ComboboxOption[]>(
|
||||
() => (['info', 'warning', 'error'] as NotificationLevel[])
|
||||
.filter((l) => !formLevels.includes(l))
|
||||
.map((l) => ({ value: l, label: LEVEL_LABELS[l] })),
|
||||
[formLevels],
|
||||
);
|
||||
|
||||
const deleteTarget = deleteRuleId != null ? rules.find((r) => r.id === deleteRuleId) : null;
|
||||
|
||||
return (
|
||||
<CapabilityGate capability="notification-suppression" featureName="Mute Rules">
|
||||
<div className="space-y-6">
|
||||
<SettingsCallout
|
||||
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
|
||||
title="Mute rules vs routing"
|
||||
subtitle="Routing sends matching alerts to another channel. Mute rules hide or drop delivery to the bell, external channels, or both. Events still appear in stack activity history."
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<Plus className="w-4 h-4" /> Add mute rule
|
||||
</SettingsPrimaryButton>
|
||||
</div>
|
||||
|
||||
<Modal open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }} size="lg">
|
||||
<ModalHeader
|
||||
kicker={editingId ? 'MUTE RULES · EDIT RULE' : 'MUTE RULES · NEW RULE'}
|
||||
title={editingId ? 'Edit mute rule' : 'New mute rule'}
|
||||
description="Match alerts by node, stack, label, category, or severity, then choose where to mute delivery."
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input placeholder="e.g. Mute staging deploy noise" value={formName} onChange={(e) => setFormName(e.target.value)} maxLength={100} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node scope</Label>
|
||||
<Select
|
||||
value={formNodeId === null ? 'any' : String(formNodeId)}
|
||||
onValueChange={(v) => setFormNodeId(v === 'any' ? null : Number(v))}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any node</SelectItem>
|
||||
{nodes.map((n) => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Add a stack..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
|
||||
{formStacks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formStacks.map((s) => (
|
||||
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
|
||||
{s}
|
||||
<button type="button" onClick={() => removeStack(s)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Labels <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableLabelOptions} value="" onValueChange={addLabel} placeholder="Add a label..." searchPlaceholder="Search labels..." emptyText="No labels found." />
|
||||
{formLabelIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formLabelIds.map((id) => {
|
||||
const lbl = labelOptions.find((l) => l.id === id);
|
||||
return (
|
||||
<Badge key={id} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{lbl?.name ?? `Label ${id}`}
|
||||
<button type="button" onClick={() => removeLabel(id)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Categories <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableCategoryOptions} value="" onValueChange={addCategory} placeholder="Add a category..." searchPlaceholder="Search categories..." emptyText="No categories found." />
|
||||
{formCategories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formCategories.map((c) => (
|
||||
<Badge key={c} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{CATEGORY_LABELS[c]}
|
||||
<button type="button" onClick={() => removeCategory(c)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox options={availableLevelOptions} value="" onValueChange={addLevel} placeholder="Add a severity..." searchPlaceholder="Search..." emptyText="No levels left." />
|
||||
{formLevels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formLevels.map((l) => (
|
||||
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
|
||||
{LEVEL_LABELS[l]}
|
||||
<button type="button" onClick={() => removeLevel(l)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Leave matchers blank to match any value. All non-empty filters must match (AND).</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Apply to</Label>
|
||||
<SegmentedControl
|
||||
value={formAppliesTo}
|
||||
options={[
|
||||
{ value: 'bell', label: 'Bell' },
|
||||
{ value: 'external', label: 'External' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
]}
|
||||
onChange={setFormAppliesTo}
|
||||
ariaLabel="Suppression target"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Expiration</Label>
|
||||
<Select value={formExpirationPreset} onValueChange={(v) => setFormExpirationPreset(v as ExpirationPreset)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="forever">Forever</SelectItem>
|
||||
<SelectItem value="1h">1 hour</SelectItem>
|
||||
<SelectItem value="24h">24 hours</SelectItem>
|
||||
<SelectItem value="custom">Custom date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formExpirationPreset === 'custom' && (
|
||||
<Input type="datetime-local" value={formCustomExpiry} onChange={(e) => setFormCustomExpiry(e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
|
||||
<span className="text-sm text-stat-value select-none">
|
||||
{formEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>}
|
||||
primary={
|
||||
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
|
||||
</SettingsPrimaryButton>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rules.length === 0 && (
|
||||
<SettingsCallout
|
||||
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
|
||||
title="No mute rules configured"
|
||||
subtitle="Alerts follow your routing and global channels unless a mute rule matches."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && rules.map((rule) => (
|
||||
<div key={rule.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<BellOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm truncate">{rule.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{APPLIES_TO_LABELS[rule.applies_to]}</Badge>
|
||||
{rule.node_id !== null && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0 font-mono">
|
||||
{nodes.find((n) => n.id === rule.node_id)?.name ?? `node:${rule.node_id}`}
|
||||
</Badge>
|
||||
)}
|
||||
{!rule.enabled && <Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Disabled</Badge>}
|
||||
{rule.expires_at != null && rule.expires_at <= Date.now() && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(rule)} title="Edit"><Pencil className="w-4 h-4" strokeWidth={1.5} /></Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" title="Delete" onClick={() => setDeleteRuleId(rule.id)}>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{rule.stack_patterns.map((s) => <Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>)}
|
||||
{rule.label_ids?.map((id) => {
|
||||
const lbl = labelOptions.find((l) => l.id === id);
|
||||
return <Badge key={id} variant="outline" className="text-[10px]">{lbl?.name ?? `label:${id}`}</Badge>;
|
||||
})}
|
||||
{rule.categories?.map((c) => <Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c as NotificationCategory] ?? c}</Badge>)}
|
||||
{rule.levels?.map((l) => <Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>)}
|
||||
{rule.stack_patterns.length === 0 && !rule.label_ids?.length && !rule.categories?.length && !rule.levels?.length && (
|
||||
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ConfirmModal
|
||||
open={deleteRuleId != null}
|
||||
onOpenChange={(open) => { if (!open) setDeleteRuleId(null); }}
|
||||
variant="destructive"
|
||||
kicker="MUTE RULES · DELETE · IRREVERSIBLE"
|
||||
title="Delete mute rule"
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Deletes <span className="font-medium text-stat-value">{deleteTarget?.name ?? 'this rule'}</span>. Matching alerts will deliver normally again.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
scopeLabel,
|
||||
} from './index';
|
||||
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { SettingsSectionContent } from './SettingsSectionContent';
|
||||
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
||||
@@ -31,6 +32,9 @@ import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsCon
|
||||
interface SettingsPageProps {
|
||||
currentSection: SectionId;
|
||||
onSectionChange: (section: SectionId) => void;
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
export function SettingsPage(props: SettingsPageProps) {
|
||||
@@ -41,7 +45,13 @@ export function SettingsPage(props: SettingsPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProps) {
|
||||
function SettingsPageInner({
|
||||
currentSection,
|
||||
onSectionChange,
|
||||
muteRulePrefill = null,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
}: SettingsPageProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
@@ -197,6 +207,9 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
sectionId={safeSection}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
showDescription
|
||||
muteRulePrefill={muteRulePrefill}
|
||||
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getSettingsItem,
|
||||
} from './index';
|
||||
import type { SectionId } from './index';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
import { SectionGate } from './SectionGate';
|
||||
|
||||
@@ -44,6 +45,9 @@ const LabelsSection = lazy(() =>
|
||||
const NotificationRoutingSection = lazy(() =>
|
||||
import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })),
|
||||
);
|
||||
const NotificationSuppressionSection = lazy(() =>
|
||||
import('./NotificationSuppressionSection').then(m => ({ default: m.NotificationSuppressionSection })),
|
||||
);
|
||||
const CloudBackupSection = lazy(() =>
|
||||
import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })),
|
||||
);
|
||||
@@ -72,6 +76,9 @@ function SectionSkeleton() {
|
||||
function renderSection(
|
||||
sectionId: SectionId,
|
||||
onDirtyChange: (section: SectionId, dirty: boolean) => void,
|
||||
muteRulePrefill: MuteRuleDraft | null | undefined,
|
||||
onMutePrefillConsumed: (() => void) | undefined,
|
||||
onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined,
|
||||
) {
|
||||
switch (sectionId) {
|
||||
case 'account': return <AccountSection />;
|
||||
@@ -81,7 +88,7 @@ function renderSection(
|
||||
case 'sso': return <SSOSection />;
|
||||
case 'api-tokens': return <ApiTokensSection />;
|
||||
case 'registries': return <RegistriesSection />;
|
||||
case 'labels': return <LabelsSection />;
|
||||
case 'labels': return <LabelsSection onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} />;
|
||||
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
|
||||
case 'container-alerts': return <ContainerAlertsSection onDirtyChange={(d) => onDirtyChange('container-alerts', d)} />;
|
||||
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', d)} />;
|
||||
@@ -89,6 +96,12 @@ function renderSection(
|
||||
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
|
||||
case 'notifications': return <NotificationsSection />;
|
||||
case 'notification-routing': return <NotificationRoutingSection />;
|
||||
case 'notification-suppression': return (
|
||||
<NotificationSuppressionSection
|
||||
prefill={muteRulePrefill}
|
||||
onPrefillConsumed={onMutePrefillConsumed}
|
||||
/>
|
||||
);
|
||||
case 'webhooks': return <WebhooksSection />;
|
||||
case 'cloud-backup': return <CloudBackupSection />;
|
||||
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
|
||||
@@ -109,6 +122,9 @@ interface SettingsSectionContentProps {
|
||||
onDirtyChange: (section: SectionId, dirty: boolean) => void;
|
||||
/** Render the section's lead description paragraph above the content. */
|
||||
showDescription?: boolean;
|
||||
muteRulePrefill?: MuteRuleDraft | null;
|
||||
onMutePrefillConsumed?: () => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,14 +133,18 @@ interface SettingsSectionContentProps {
|
||||
* the desktop SettingsPage and the mobile settings screen so the section switch,
|
||||
* lazy splitting, and gating live in exactly one place.
|
||||
*/
|
||||
export function SettingsSectionContent({ sectionId, onDirtyChange, showDescription }: SettingsSectionContentProps) {
|
||||
export function SettingsSectionContent({
|
||||
sectionId,
|
||||
onDirtyChange,
|
||||
showDescription,
|
||||
muteRulePrefill,
|
||||
onMutePrefillConsumed,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
}: SettingsSectionContentProps) {
|
||||
const item = getSettingsItem(sectionId);
|
||||
// Memoize the section element so unrelated re-renders of the host page (the
|
||||
// command palette opening, a dirty-flag toggle) do not re-render the active
|
||||
// section. onDirtyChange is stable from both call sites.
|
||||
const element = useMemo(
|
||||
() => renderSection(sectionId, onDirtyChange),
|
||||
[sectionId, onDirtyChange],
|
||||
() => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill),
|
||||
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('settings registry', () => {
|
||||
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
|
||||
expect(byId.get('notifications')?.label).toBe('Channels');
|
||||
expect(byId.get('notification-routing')?.label).toBe('Routing');
|
||||
expect(byId.get('notification-suppression')?.label).toBe('Mute Rules');
|
||||
});
|
||||
|
||||
it('no longer registers the standalone Vulnerability Scanning section (moved to the Security page)', () => {
|
||||
|
||||
@@ -220,6 +220,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
id: 'notification-suppression',
|
||||
group: 'notifications',
|
||||
label: 'Mute Rules',
|
||||
description: 'Mute or drop matching alerts from the bell, external channels, or both.',
|
||||
keywords: ['mute', 'suppress', 'silence', 'hide', 'bell', 'rules'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
// Automation
|
||||
{
|
||||
id: 'image-updates',
|
||||
|
||||
@@ -69,6 +69,7 @@ export type SectionId =
|
||||
| 'app-store'
|
||||
| 'stacks'
|
||||
| 'notification-routing'
|
||||
| 'notification-suppression'
|
||||
| 'recovery'
|
||||
| 'support'
|
||||
| 'about';
|
||||
|
||||
@@ -6,7 +6,7 @@ export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 px-4 h-14 border-b border-glass-border">
|
||||
<img
|
||||
src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'}
|
||||
src={isDarkMode ? '/sencho-logo-dark.svg' : '/sencho-logo-light.svg'}
|
||||
alt=""
|
||||
className="w-9 h-9 shrink-0"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, Plus } from 'lucide-react';
|
||||
import { Check, Plus, BellOff } from 'lucide-react';
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
|
||||
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
|
||||
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
<ContextMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</ContextMenuItem>
|
||||
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
|
||||
<span className="text-xs">Mute label…</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[200px]">
|
||||
{ctx.labels.map(label => (
|
||||
<ContextMenuSub key={label.id}>
|
||||
<ContextMenuSubTrigger>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="font-mono text-[12px] ml-2">{label.name}</span>
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem onClick={() => ctx.muteLabelAll(label.id, label.name)}>
|
||||
<span className="text-xs">Mute notifications for this label</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => ctx.muteLabelExternal(label.id, label.name)}>
|
||||
<span className="text-xs">Mute external alerts for this label</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => ctx.muteLabelLowPriority(label.id, label.name)}>
|
||||
<span className="text-xs">Mute low-priority stack alerts</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => ctx.openLabelMuteRules(label.id, label.name)}>
|
||||
<span className="text-xs">Manage label mute rules</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
@@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: {
|
||||
item: MenuItem;
|
||||
MenuSub: typeof ContextMenuSub;
|
||||
MenuSubTrigger: typeof ContextMenuSubTrigger;
|
||||
MenuSubContent: typeof ContextMenuSubContent;
|
||||
MenuItem: typeof ContextMenuItem;
|
||||
}) {
|
||||
return (
|
||||
<MenuSub>
|
||||
<MenuSubTrigger>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubContent className="min-w-[220px]">
|
||||
{item.subItems?.map((sub) => (
|
||||
<MenuItem key={sub.id} onClick={() => sub.onSelect()}>
|
||||
<span className="text-xs">{sub.label}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuSubContent>
|
||||
</MenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
|
||||
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
|
||||
if (item.subItems?.length) {
|
||||
return (
|
||||
<SimpleSub
|
||||
key={item.id}
|
||||
item={item}
|
||||
MenuSub={ContextMenuSub}
|
||||
MenuSubTrigger={ContextMenuSubTrigger}
|
||||
MenuSubContent={ContextMenuSubContent}
|
||||
MenuItem={ContextMenuItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={item.id}
|
||||
|
||||
@@ -10,10 +10,11 @@ interface StackGroupProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
variant?: 'default' | 'pinned';
|
||||
headerActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) {
|
||||
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', headerActions, children }: StackGroupProps) {
|
||||
const isPinned = variant === 'pinned';
|
||||
const labelColor = isPinned ? 'text-brand/90' : 'text-stat-subtitle';
|
||||
return (
|
||||
@@ -32,6 +33,7 @@ export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'd
|
||||
{isPinned ? '★ ' : ''}{label}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{headerActions}
|
||||
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
|
||||
{collapsed
|
||||
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, Check, Plus } from 'lucide-react';
|
||||
import { MoreVertical, Check, Plus, BellOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
<DropdownMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</DropdownMenuItem>
|
||||
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
|
||||
<span className="text-xs">Mute label…</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[200px]">
|
||||
{ctx.labels.map(label => (
|
||||
<DropdownMenuSub key={label.id}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="font-mono text-[12px] ml-2">{label.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => ctx.muteLabelAll(label.id, label.name)}>
|
||||
<span className="text-xs">Mute notifications for this label</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => ctx.muteLabelExternal(label.id, label.name)}>
|
||||
<span className="text-xs">Mute external alerts for this label</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => ctx.muteLabelLowPriority(label.id, label.name)}>
|
||||
<span className="text-xs">Mute low-priority stack alerts</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => ctx.openLabelMuteRules(label.id, label.name)}>
|
||||
<span className="text-xs">Manage label mute rules</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
@@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: {
|
||||
item: MenuItem;
|
||||
MenuSub: typeof DropdownMenuSub;
|
||||
MenuSubTrigger: typeof DropdownMenuSubTrigger;
|
||||
MenuSubContent: typeof DropdownMenuSubContent;
|
||||
MenuItem: typeof DropdownMenuItem;
|
||||
}) {
|
||||
return (
|
||||
<MenuSub>
|
||||
<MenuSubTrigger>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</MenuSubTrigger>
|
||||
<MenuSubContent className="min-w-[220px]">
|
||||
{item.subItems?.map((sub) => (
|
||||
<MenuItem key={sub.id} onSelect={() => sub.onSelect()}>
|
||||
<span className="text-xs">{sub.label}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuSubContent>
|
||||
</MenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
|
||||
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
|
||||
if (item.subItems?.length) {
|
||||
return (
|
||||
<SimpleSub
|
||||
key={item.id}
|
||||
item={item}
|
||||
MenuSub={DropdownMenuSub}
|
||||
MenuSubTrigger={DropdownMenuSubTrigger}
|
||||
MenuSubContent={DropdownMenuSubContent}
|
||||
MenuItem={DropdownMenuItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { StackContextMenu } from './StackContextMenu';
|
||||
import { StackKebabMenu } from './StackKebabMenu';
|
||||
import { EmptyStackState } from './EmptyStackState';
|
||||
import type { StackMenuCtx, FilterChip } from './sidebar-types';
|
||||
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
|
||||
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
|
||||
|
||||
interface RemoteNodeResult {
|
||||
nodeId: number;
|
||||
@@ -54,6 +57,7 @@ export interface StackListProps {
|
||||
// Open the create dialog on a starting mode. Present only when the user can
|
||||
// create stacks; drives the zero-stacks empty state.
|
||||
onOpenCreate?: (mode: 'import' | 'empty') => void;
|
||||
openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
}
|
||||
|
||||
interface BuiltGroup {
|
||||
@@ -63,6 +67,8 @@ interface BuiltGroup {
|
||||
count: number;
|
||||
files: string[];
|
||||
variant?: 'default' | 'pinned';
|
||||
labelId?: number;
|
||||
labelName?: string;
|
||||
}
|
||||
|
||||
function buildGroups(
|
||||
@@ -101,6 +107,8 @@ function buildGroups(
|
||||
result.push({
|
||||
kind: 'labeled', id: `label:${label.id}`,
|
||||
label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles,
|
||||
labelId: label.id,
|
||||
labelName: label.name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +119,19 @@ function buildGroups(
|
||||
return result;
|
||||
}
|
||||
|
||||
function LabelGroupMuteActions({
|
||||
labelId,
|
||||
labelName,
|
||||
openMuteRulesWithPrefill,
|
||||
}: {
|
||||
labelId: number;
|
||||
labelName: string;
|
||||
openMuteRulesWithPrefill: (draft: MuteRuleDraft) => void;
|
||||
}) {
|
||||
const actions = useLabelMuteActions(labelId, labelName, openMuteRulesWithPrefill);
|
||||
return <LabelGroupMuteKebab actions={actions} />;
|
||||
}
|
||||
|
||||
interface StackListBulkProps {
|
||||
bulkMode: boolean;
|
||||
selectedFiles: Set<string>;
|
||||
@@ -125,6 +146,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
bulkMode, selectedFiles, onToggleSelect,
|
||||
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
|
||||
filterChip, onOpenCreate,
|
||||
openMuteRulesWithPrefill,
|
||||
} = props;
|
||||
|
||||
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
|
||||
@@ -164,6 +186,17 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
collapsed={isCollapsed(g.id)}
|
||||
onToggle={() => toggleCollapse(g.id)}
|
||||
variant={g.variant}
|
||||
headerActions={
|
||||
g.kind === 'labeled' && g.labelId != null && g.labelName && openMuteRulesWithPrefill
|
||||
? (
|
||||
<LabelGroupMuteActions
|
||||
labelId={g.labelId}
|
||||
labelName={g.labelName}
|
||||
openMuteRulesWithPrefill={openMuteRulesWithPrefill}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{g.files.map(file => {
|
||||
const ctx = buildMenuCtx(file);
|
||||
|
||||
@@ -46,6 +46,15 @@ export interface StackMenuCtx {
|
||||
unpin: () => void;
|
||||
toggleLabel: (labelId: number) => void;
|
||||
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
|
||||
canMuteNotifications: boolean;
|
||||
muteStackAll: () => void;
|
||||
muteStackDeploySuccess: () => void;
|
||||
muteStackMonitor: () => void;
|
||||
openStackMuteRules: () => void;
|
||||
muteLabelAll: (labelId: number, labelName: string) => void;
|
||||
muteLabelExternal: (labelId: number, labelName: string) => void;
|
||||
muteLabelLowPriority: (labelId: number, labelName: string) => void;
|
||||
openLabelMuteRules: (labelId: number, labelName: string) => void;
|
||||
openLabelManager: () => void;
|
||||
openScheduleTask: () => void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Info, Lock, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
LABEL_DISAMBIGUATION_COPY,
|
||||
SOURCE_LABELS,
|
||||
SOURCE_FACET_LABELS,
|
||||
matchesSearch,
|
||||
sourcesPresent,
|
||||
type LabelSource,
|
||||
type LabelValue,
|
||||
type StackLabelInventory,
|
||||
} from '@/lib/labelInventory';
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const CARD_CLASS = 'rounded-lg border border-muted px-3 py-2.5';
|
||||
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
|
||||
/**
|
||||
* A label is visible when its source is not excluded AND either the text matches its
|
||||
* key/value or the parent matched the search (a service by name, a replica by name or id).
|
||||
* Parent matches bypass text matching only, never the source facets.
|
||||
*/
|
||||
function labelVisible(label: LabelValue, search: string, excluded: Set<LabelSource>, parentHit: boolean): boolean {
|
||||
if (excluded.has(label.source)) return false;
|
||||
return parentHit || matchesSearch(search, label.key, label.value);
|
||||
}
|
||||
|
||||
function LabelRow({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) {
|
||||
const { isAdmin } = useAuth();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-muted py-2 first:border-t-0" data-testid="compose-label-row">
|
||||
<span className="font-mono text-[12px] font-medium text-foreground/90">{label.key}</span>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<span className="inline-flex items-center gap-1 font-mono text-xs text-foreground/80">
|
||||
{label.redacted && <Lock className="h-3 w-3 text-stat-subtitle" strokeWidth={1.5} />}
|
||||
{label.value}
|
||||
</span>
|
||||
<span className="rounded border border-muted px-1.5 py-0.5 font-mono text-[10px] text-stat-subtitle">
|
||||
{SOURCE_LABELS[label.source]}
|
||||
</span>
|
||||
{label.redacted && isAdmin && onReveal && (
|
||||
<button type="button" onClick={onReveal} className="font-mono text-[10px] uppercase text-brand hover:underline">
|
||||
Reveal
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MismatchBadge({ kind, count }: { kind: 'only-compose' | 'only-container' | 'both' | 'changed'; count: number }) {
|
||||
const labels = {
|
||||
'only-compose': 'only in Compose',
|
||||
'only-container': 'only on running container',
|
||||
both: 'present in both',
|
||||
changed: 'value changed',
|
||||
};
|
||||
const tones = {
|
||||
'only-compose': 'border-warning/40 bg-warning/[0.06] text-warning',
|
||||
'only-container': 'border-info/40 bg-info/[0.06] text-info',
|
||||
both: 'border-muted bg-card/40 text-stat-subtitle',
|
||||
changed: 'border-warning/40 bg-warning/[0.06] text-warning',
|
||||
};
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span className={cn('inline-flex rounded border px-1.5 py-0.5 font-mono text-[10px]', tones[kind])} data-testid={`mismatch-${kind}`}>
|
||||
{count} {labels[kind]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposeLabelsPanel({ stackName }: { stackName: string }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [inventory, setInventory] = useState<StackLabelInventory | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [revealSecrets, setRevealSecrets] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [excludedSources, setExcludedSources] = useState<Set<LabelSource>>(new Set());
|
||||
const [hiddenServices, setHiddenServices] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchInventory = useCallback(async (reveal = revealSecrets) => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const qs = reveal ? '?reveal=1' : '';
|
||||
const res = await apiFetch(`/stacks/${stackName}/label-inventory${qs}`);
|
||||
if (!res.ok) {
|
||||
setLoadError(true);
|
||||
throw new Error('Failed to load Compose label inventory');
|
||||
}
|
||||
setInventory(await res.json() as StackLabelInventory);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load Compose label inventory');
|
||||
setInventory(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stackName, revealSecrets]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchInventory();
|
||||
}, [fetchInventory]);
|
||||
|
||||
// Reset filters when the stack changes so selections never leak between stacks. Not keyed on
|
||||
// reveal/refetch, so filters are preserved across a reveal on the same stack.
|
||||
useEffect(() => {
|
||||
setSearch('');
|
||||
setExcludedSources(new Set());
|
||||
setHiddenServices(new Set());
|
||||
}, [stackName]);
|
||||
|
||||
const filtersActive = search.trim() !== '' || excludedSources.size > 0;
|
||||
// Count of active facet filters for the Filters popover badge (search is its own control).
|
||||
const filterCount = excludedSources.size + hiddenServices.size;
|
||||
|
||||
const sourceFacets = useMemo(() => {
|
||||
const srcs: LabelSource[] = [];
|
||||
for (const svc of inventory?.services ?? []) {
|
||||
for (const l of svc.declaredLabels) srcs.push(l.source);
|
||||
for (const rep of svc.replicas) for (const l of rep.runtimeLabels) srcs.push(l.source);
|
||||
}
|
||||
return sourcesPresent(srcs);
|
||||
}, [inventory]);
|
||||
|
||||
const serviceNames = useMemo(() => (inventory?.services ?? []).map(s => s.service), [inventory]);
|
||||
|
||||
// Filter-aware view model: computed before render so badges reflect only visible labels and
|
||||
// empty/failure states stay truthful. Per-service checkbox is the only thing that hides a card.
|
||||
const serviceViews = useMemo(() => {
|
||||
return (inventory?.services ?? [])
|
||||
.filter(svc => !hiddenServices.has(svc.service))
|
||||
.map(svc => {
|
||||
const serviceHit = matchesSearch(search, svc.service);
|
||||
const declaredVisible = svc.declaredLabels.filter(l => labelVisible(l, search, excludedSources, serviceHit));
|
||||
const declaredVisibleKeys = new Set(declaredVisible.map(l => l.key));
|
||||
const replicas = svc.replicas.map(rep => {
|
||||
if (rep.inspectFailed) {
|
||||
return { rep, inspectFailed: true as const, runtimeVisible: [] as LabelValue[], badges: { onlyInCompose: 0, onlyOnContainer: 0, inBoth: 0, changed: 0 }, render: true };
|
||||
}
|
||||
const replicaHit = serviceHit || matchesSearch(search, rep.name, rep.id);
|
||||
const runtimeVisible = rep.runtimeLabels.filter(l => labelVisible(l, search, excludedSources, replicaHit));
|
||||
const rtKeys = new Set(runtimeVisible.map(l => l.key));
|
||||
const badges = {
|
||||
onlyInCompose: rep.onlyInCompose.filter(k => declaredVisibleKeys.has(k)).length,
|
||||
onlyOnContainer: rep.onlyOnContainer.filter(k => rtKeys.has(k)).length,
|
||||
inBoth: rep.inBoth.filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length,
|
||||
changed: (rep.changed ?? []).filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length,
|
||||
};
|
||||
const anyBadge = badges.onlyInCompose + badges.onlyOnContainer + badges.inBoth + badges.changed > 0;
|
||||
const render = runtimeVisible.length > 0 || anyBadge || !filtersActive;
|
||||
return { rep, inspectFailed: false as const, runtimeVisible, badges, render };
|
||||
});
|
||||
const renderedReplicas = replicas.filter(r => r.render);
|
||||
const noRunning = svc.replicas.length === 0 && svc.declaredLabels.length > 0 && (!filtersActive || declaredVisible.length > 0);
|
||||
const hasContent = declaredVisible.length > 0 || renderedReplicas.length > 0 || noRunning;
|
||||
return { svc, declaredVisible, replicas: renderedReplicas, noRunning, hasContent };
|
||||
});
|
||||
}, [inventory, hiddenServices, search, excludedSources, filtersActive]);
|
||||
|
||||
const toggleSource = (s: LabelSource) => {
|
||||
setExcludedSources(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s); else next.add(s);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleService = (s: string) => {
|
||||
setHiddenServices(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s); else next.add(s);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReveal = () => {
|
||||
setRevealSecrets(true);
|
||||
void fetchInventory(true);
|
||||
};
|
||||
|
||||
if (loading && !inventory) {
|
||||
return <p className="px-3 py-3 font-mono text-[11px] text-stat-subtitle">Loading Compose labels…</p>;
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return <p className="px-3 py-3 font-mono text-[11px] text-destructive">Could not load Compose labels for this stack.</p>;
|
||||
}
|
||||
|
||||
const noServices = (inventory?.services.length ?? 0) === 0;
|
||||
const allServicesHidden = !noServices && serviceNames.every(s => hiddenServices.has(s));
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4" data-testid="compose-labels-panel">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={LABEL_CLASS}>compose labels</span>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-stat-subtitle">{LABEL_DISAMBIGUATION_COPY}</p>
|
||||
|
||||
{!noServices && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter labels, services..."
|
||||
className="h-9 pl-8"
|
||||
data-testid="compose-label-search"
|
||||
/>
|
||||
</div>
|
||||
{(sourceFacets.length > 0 || serviceNames.length > 1) && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={filterCount > 0 ? 'default' : 'outline'} size="sm" className="h-9 gap-2 shrink-0">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
{filterCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">{filterCount}</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 space-y-4">
|
||||
{sourceFacets.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className={FILTER_SECTION_LABEL_CLASS}>Defined by</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{sourceFacets.map(s => (
|
||||
<Button
|
||||
key={s}
|
||||
variant={!excludedSources.has(s) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
aria-pressed={!excludedSources.has(s)}
|
||||
onClick={() => toggleSource(s)}
|
||||
>
|
||||
{SOURCE_FACET_LABELS[s]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{serviceNames.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className={FILTER_SECTION_LABEL_CLASS}>Services</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{serviceNames.map(name => (
|
||||
<Button
|
||||
key={name}
|
||||
variant={!hiddenServices.has(name) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5 font-mono"
|
||||
aria-pressed={!hiddenServices.has(name)}
|
||||
onClick={() => toggleService(name)}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs"
|
||||
onClick={() => { setExcludedSources(new Set()); setHiddenServices(new Set()); }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inventory?.renderable && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-2 text-xs text-warning">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<span>Compose could not be fully rendered. Declared labels may be incomplete.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inventory?.partial && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-2 text-xs text-warning">
|
||||
<Info className="h-4 w-4 shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<span>Some containers or images could not be inspected. Label provenance may be incomplete.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noServices && (
|
||||
<p className="text-sm text-stat-subtitle">No Compose or runtime labels found for this stack.</p>
|
||||
)}
|
||||
|
||||
{allServicesHidden && (
|
||||
<p className="text-sm text-stat-subtitle" data-testid="compose-labels-no-services">No services selected.</p>
|
||||
)}
|
||||
|
||||
{serviceViews.map(({ svc, declaredVisible, replicas, noRunning, hasContent }) => (
|
||||
<div key={svc.service} className={CARD_CLASS} data-testid="compose-label-service">
|
||||
<p className={LABEL_CLASS}>{svc.service}</p>
|
||||
|
||||
{declaredVisible.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle mb-1">Declared in Compose</p>
|
||||
{declaredVisible.map((label) => (
|
||||
<LabelRow key={`decl-${label.key}`} label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replicas.map(({ rep, inspectFailed, runtimeVisible, badges }) => (
|
||||
<div key={rep.id || rep.name} className="mt-3 border-t border-muted pt-3 first:mt-0 first:border-t-0 first:pt-0">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle mb-1">
|
||||
{rep.name || rep.id.slice(0, 12)} · {rep.state}
|
||||
</p>
|
||||
{inspectFailed ? (
|
||||
<p className="text-xs text-warning">Runtime labels unavailable for this container.</p>
|
||||
) : (
|
||||
<>
|
||||
{(badges.onlyInCompose > 0 || badges.onlyOnContainer > 0 || badges.changed > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
<MismatchBadge kind="only-compose" count={badges.onlyInCompose} />
|
||||
<MismatchBadge kind="changed" count={badges.changed} />
|
||||
<MismatchBadge kind="only-container" count={badges.onlyOnContainer} />
|
||||
<MismatchBadge kind="both" count={badges.inBoth} />
|
||||
</div>
|
||||
)}
|
||||
{runtimeVisible.length > 0 ? (
|
||||
runtimeVisible.map((label) => (
|
||||
<LabelRow key={`rt-${label.key}`} label={label} onReveal={isAdmin && !revealSecrets ? handleReveal : undefined} />
|
||||
))
|
||||
) : (
|
||||
!filtersActive && <p className="text-xs text-stat-subtitle">No runtime labels on this container.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{noRunning && (
|
||||
<p className="mt-2 text-xs text-stat-subtitle">
|
||||
No running containers for this service. Container may need redeploy for Compose label changes to apply.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{filtersActive && !hasContent && (
|
||||
<p className="mt-2 text-xs text-stat-subtitle" data-testid="compose-label-service-no-match">No labels match the filter.</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="text-[10px] text-stat-subtitle leading-relaxed">
|
||||
Runtime labels are static until the container is recreated. Container may need redeploy for Compose label changes to apply.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
@@ -13,6 +15,12 @@ import type { NotificationItem } from '@/components/dashboard/types';
|
||||
type ActivityLevel = 'info' | 'warning' | 'error';
|
||||
const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error'];
|
||||
|
||||
interface SuppressionMatchSnapshot {
|
||||
rules: { id: number; name: string }[];
|
||||
bellSuppressed: boolean;
|
||||
externalSuppressed: boolean;
|
||||
}
|
||||
|
||||
interface ActivityEvent {
|
||||
id: number;
|
||||
level: ActivityLevel;
|
||||
@@ -21,6 +29,7 @@ interface ActivityEvent {
|
||||
timestamp: number;
|
||||
stack_name?: string;
|
||||
actor_username?: string | null;
|
||||
suppression_match?: string | SuppressionMatchSnapshot | null;
|
||||
}
|
||||
|
||||
interface StackActivityTimelineProps {
|
||||
@@ -61,6 +70,25 @@ function formatActor(actor: string): { label: string; isSystem: boolean } {
|
||||
return { label: SYSTEM_ACTOR_LABEL[actor] ?? actor, isSystem };
|
||||
}
|
||||
|
||||
function parseSuppressionMatch(raw: ActivityEvent['suppression_match']): SuppressionMatchSnapshot | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as SuppressionMatchSnapshot;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
if (!Array.isArray(parsed.rules)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function suppressionTooltip(match: SuppressionMatchSnapshot): string {
|
||||
const names = match.rules.map((r) => r.name).filter(Boolean);
|
||||
const ruleText = names.length > 0 ? names.join(', ') : 'Unnamed rule';
|
||||
return `Matched rule: ${ruleText}`;
|
||||
}
|
||||
|
||||
function isActivityEvent(value: unknown): value is ActivityEvent {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
@@ -261,6 +289,10 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
{g.events.map(e => {
|
||||
const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity;
|
||||
const actor = e.actor_username ? formatActor(e.actor_username) : null;
|
||||
const suppression = parseSuppressionMatch(e.suppression_match);
|
||||
const showSuppressed = Boolean(
|
||||
suppression && (suppression.bellSuppressed || suppression.externalSuppressed),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
@@ -275,6 +307,18 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
||||
{actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`}
|
||||
</span>
|
||||
)}
|
||||
{showSuppressed && suppression && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="ml-1.5 align-middle text-[9px] font-mono uppercase tracking-wide px-1 py-0 h-4">
|
||||
Suppressed
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="font-mono text-[10px] max-w-xs">
|
||||
{suppressionTooltip(suppression)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ComposeLabelsPanel from '../ComposeLabelsPanel';
|
||||
import { LABEL_DISAMBIGUATION_COPY } from '@/lib/labelInventory';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
const mockInventory = {
|
||||
stackName: 'demo',
|
||||
renderable: true,
|
||||
generatedAt: Date.now(),
|
||||
services: [
|
||||
{
|
||||
service: 'web',
|
||||
declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }],
|
||||
replicas: [
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'demo-web-1',
|
||||
state: 'running',
|
||||
runtimeLabels: [
|
||||
{ key: 'traefik.enable', value: 'true', source: 'runtime' as const },
|
||||
{ key: 'runtime.only', value: '1', source: 'runtime' as const },
|
||||
],
|
||||
onlyInCompose: [],
|
||||
onlyOnContainer: ['runtime.only'],
|
||||
inBoth: ['traefik.enable'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('ComposeLabelsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockInventory,
|
||||
} as Response);
|
||||
});
|
||||
|
||||
it('shows disambiguation copy and service labels', async () => {
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument();
|
||||
expect(await screen.findByText('web')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('traefik.enable')).toHaveLength(2);
|
||||
expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mismatch-both')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a value-changed badge when a label value drifted', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
|
||||
services: [{
|
||||
service: 'web',
|
||||
declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }],
|
||||
replicas: [{
|
||||
id: 'c1', name: 'demo-web-1', state: 'running',
|
||||
runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }],
|
||||
onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
} as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Runtime labels unavailable" for a replica whose inspect failed', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(),
|
||||
services: [{
|
||||
service: 'web',
|
||||
declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }],
|
||||
replicas: [{
|
||||
id: 'c1', name: 'demo-web-1', state: 'running',
|
||||
runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [],
|
||||
inspectFailed: true,
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
} as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
expect(await screen.findByText('Runtime labels unavailable for this container.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not be inspected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A service whose declared/runtime labels have distinctive keys so name-search can be isolated.
|
||||
const searchMock = {
|
||||
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
|
||||
services: [{
|
||||
service: 'web',
|
||||
declaredLabels: [{ key: 'aaa.declared', value: '1', source: 'compose' as const }],
|
||||
replicas: [{
|
||||
id: 'w1', name: 'demo-web-1', state: 'running',
|
||||
runtimeLabels: [{ key: 'zzz.runtime', value: 'q', source: 'runtime' as const }],
|
||||
onlyInCompose: ['aaa.declared'], onlyOnContainer: ['zzz.runtime'], inBoth: [], changed: [],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
it('service-name search exposes that service\'s declared and runtime labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
await user.type(await screen.findByTestId('compose-label-search'), 'web');
|
||||
expect(screen.getByText('aaa.declared')).toBeInTheDocument();
|
||||
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replica-name search exposes runtime labels only, not service-level declared labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
await user.type(await screen.findByTestId('compose-label-search'), 'demo-web-1');
|
||||
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
|
||||
expect(screen.queryByText('aaa.declared')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides inBoth and changed badges when Compose File is filtered out', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
|
||||
services: [{
|
||||
service: 'web',
|
||||
declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }],
|
||||
replicas: [{
|
||||
id: 'c1', name: 'demo-web-1', state: 'running',
|
||||
runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }],
|
||||
onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
} as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Compose File' }));
|
||||
expect(screen.queryByTestId('mismatch-changed')).toBeNull();
|
||||
});
|
||||
|
||||
it('a source facet hides that source; turning off Compose File hides declared labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
expect(await screen.findByText('aaa.declared')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Compose File' }));
|
||||
expect(screen.queryByText('aaa.declared')).toBeNull();
|
||||
expect(screen.getByText('zzz.runtime')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('recomputes badge counts from the visible set', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
// Search only the runtime key: onlyOnContainer stays 1, onlyInCompose drops to 0.
|
||||
await user.type(await screen.findByTestId('compose-label-search'), 'zzz.runtime');
|
||||
expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mismatch-only-compose')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the genuine "No runtime labels" for an empty replica when no filters are active', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true, json: async () => ({
|
||||
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
|
||||
services: [{
|
||||
service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }],
|
||||
replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: ['a'], onlyOnContainer: [], inBoth: [], changed: [] }],
|
||||
}],
|
||||
}),
|
||||
} as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
expect(await screen.findByText('No runtime labels on this container.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the inspectFailed warning under an active filter but hides it when the service is unchecked', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true, json: async () => ({
|
||||
stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(),
|
||||
services: [
|
||||
{ service: 'web', declaredLabels: [], replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [], inspectFailed: true }] },
|
||||
{ service: 'db', declaredLabels: [{ key: 'x', value: '1', source: 'compose' as const }], replicas: [] },
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
// Active text filter that matches nothing: the inspectFailed warning must still show.
|
||||
await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz');
|
||||
expect(screen.getByText('Runtime labels unavailable for this container.')).toBeInTheDocument();
|
||||
// Hiding the web service (via the Filters popover) removes its card and the warning.
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'web' }));
|
||||
expect(screen.queryByText('Runtime labels unavailable for this container.')).toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes genuine-empty inventory from filtered-empty (no duplicate messages)', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Genuine empty inventory.
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true, json: async () => ({ stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), services: [] }),
|
||||
} as Response);
|
||||
const { unmount } = render(<ComposeLabelsPanel stackName="demo" />);
|
||||
expect(await screen.findByText('No Compose or runtime labels found for this stack.')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
// Filtered-empty: one per-card message, no panel-level duplicate.
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz');
|
||||
expect(screen.getByTestId('compose-label-service-no-match')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No Compose or runtime labels found for this stack.')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "No services selected" when every service checkbox is unchecked', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(apiFetch).mockResolvedValue({
|
||||
ok: true, json: async () => ({
|
||||
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
|
||||
services: [
|
||||
{ service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }], replicas: [] },
|
||||
{ service: 'db', declaredLabels: [{ key: 'b', value: '2', source: 'compose' as const }], replicas: [] },
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
await user.click(await screen.findByRole('button', { name: /Filters/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'web' }));
|
||||
await user.click(screen.getByRole('button', { name: 'db' }));
|
||||
expect(screen.getByTestId('compose-labels-no-services')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves filters across a reveal on the same stack', async () => {
|
||||
const user = userEvent.setup();
|
||||
const revealMock = {
|
||||
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
|
||||
services: [{
|
||||
service: 'web', declaredLabels: [],
|
||||
replicas: [{
|
||||
id: 'w1', name: 'demo-web-1', state: 'running',
|
||||
runtimeLabels: [
|
||||
{ key: 'api.token', value: '[redacted]', source: 'runtime' as const, redacted: true },
|
||||
{ key: 'plain.label', value: 'v', source: 'runtime' as const },
|
||||
],
|
||||
onlyInCompose: [], onlyOnContainer: ['api.token', 'plain.label'], inBoth: [], changed: [],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => revealMock } as Response);
|
||||
render(<ComposeLabelsPanel stackName="demo" />);
|
||||
await user.type(await screen.findByTestId('compose-label-search'), 'api.token');
|
||||
expect(screen.getByText('api.token')).toBeInTheDocument();
|
||||
expect(screen.queryByText('plain.label')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /reveal/i }));
|
||||
// The reset effect is keyed on stackName only, so the reveal-driven refetch keeps the filter.
|
||||
expect((screen.getByTestId('compose-label-search') as HTMLInputElement).value).toBe('api.token');
|
||||
expect(screen.queryByText('plain.label')).toBeNull();
|
||||
});
|
||||
|
||||
it('resets search, source, and service filters when the stack changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const twoServiceMock = {
|
||||
stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(),
|
||||
services: [
|
||||
{ service: 'web', declaredLabels: [{ key: 'web.owner', value: '1', source: 'compose' as const }], replicas: [] },
|
||||
{ service: 'db', declaredLabels: [{ key: 'db.owner', value: '2', source: 'compose' as const }], replicas: [] },
|
||||
],
|
||||
};
|
||||
vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => twoServiceMock } as Response);
|
||||
const { rerender } = render(<ComposeLabelsPanel stackName="demo" />);
|
||||
const input = await screen.findByTestId('compose-label-search') as HTMLInputElement;
|
||||
await user.type(input, 'zzz');
|
||||
// Hide the db service and exclude the Compose File source via the Filters popover.
|
||||
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'db' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Compose File' }));
|
||||
expect(input.value).toBe('zzz');
|
||||
expect(screen.queryByText('web.owner')).toBeNull();
|
||||
expect(screen.queryByText('db.owner')).toBeNull();
|
||||
|
||||
rerender(<ComposeLabelsPanel stackName="other" />);
|
||||
// Filters reset: search cleared, both services and the source shown again.
|
||||
expect((await screen.findByTestId('compose-label-search') as HTMLInputElement).value).toBe('');
|
||||
expect(await screen.findByText('web.owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('db.owner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,15 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
createAndAssignLabel: vi.fn(),
|
||||
openLabelManager: vi.fn(),
|
||||
openScheduleTask: vi.fn(),
|
||||
canMuteNotifications: false,
|
||||
muteStackAll: vi.fn(),
|
||||
muteStackDeploySuccess: vi.fn(),
|
||||
muteStackMonitor: vi.fn(),
|
||||
openStackMuteRules: vi.fn(),
|
||||
muteLabelAll: vi.fn(),
|
||||
muteLabelExternal: vi.fn(),
|
||||
muteLabelLowPriority: vi.fn(),
|
||||
openLabelMuteRules: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -111,6 +120,25 @@ describe('useStackMenuItems', () => {
|
||||
expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('includes Mute submenu in Inspect when canMuteNotifications', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: true })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
const muteItem = inspect.items.find(i => i.id === 'mute');
|
||||
expect(muteItem).toBeDefined();
|
||||
expect(muteItem?.subItems?.map(s => s.id)).toEqual([
|
||||
'mute-stack-all',
|
||||
'mute-stack-deploy',
|
||||
'mute-stack-monitor',
|
||||
'mute-stack-manage',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Mute submenu when canMuteNotifications is false', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: false })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'mute')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps label assignment available for any tier', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
||||
labels: [{ id: 1, node_id: 0, name: 'prod', color: 'teal' }],
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import {
|
||||
createMuteRuleWithToast,
|
||||
stackMuteAllDraft,
|
||||
stackMuteDeploySuccessDraft,
|
||||
stackMuteMonitorDraft,
|
||||
labelMuteAllDraft,
|
||||
labelMuteExternalDraft,
|
||||
labelMuteLowPriorityDraft,
|
||||
nodeMuteAllDraft,
|
||||
nodeMuteUpdatesDraft,
|
||||
nodeMuteMonitorDraft,
|
||||
type MuteRuleDraft,
|
||||
} from '@/lib/muteRules';
|
||||
|
||||
export type OpenMuteRulesPrefill = (draft: MuteRuleDraft) => void;
|
||||
|
||||
export function useCanMuteNotifications(): boolean {
|
||||
const { isAdmin } = useAuth();
|
||||
const { hasCapability } = useNodes();
|
||||
return isAdmin && hasCapability('notification-suppression');
|
||||
}
|
||||
|
||||
export function useStackMuteActions(stackName: string, openMuteRulesWithPrefill: OpenMuteRulesPrefill) {
|
||||
const { activeNode } = useNodes();
|
||||
const canMute = useCanMuteNotifications();
|
||||
const nodeId = activeNode?.id ?? null;
|
||||
|
||||
const muteAll = useCallback(() => {
|
||||
void createMuteRuleWithToast(stackMuteAllDraft(stackName, nodeId));
|
||||
}, [stackName, nodeId]);
|
||||
|
||||
const muteDeploySuccess = useCallback(() => {
|
||||
void createMuteRuleWithToast(stackMuteDeploySuccessDraft(stackName, nodeId));
|
||||
}, [stackName, nodeId]);
|
||||
|
||||
const muteMonitor = useCallback(() => {
|
||||
void createMuteRuleWithToast(stackMuteMonitorDraft(stackName, nodeId));
|
||||
}, [stackName, nodeId]);
|
||||
|
||||
const manage = useCallback(() => {
|
||||
openMuteRulesWithPrefill(stackMuteAllDraft(stackName, nodeId));
|
||||
}, [stackName, nodeId, openMuteRulesWithPrefill]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ canMute, muteAll, muteDeploySuccess, muteMonitor, manage }),
|
||||
[canMute, muteAll, muteDeploySuccess, muteMonitor, manage],
|
||||
);
|
||||
}
|
||||
|
||||
export function useLabelMuteActions(
|
||||
labelId: number,
|
||||
labelName: string,
|
||||
openMuteRulesWithPrefill: OpenMuteRulesPrefill,
|
||||
) {
|
||||
const { activeNode } = useNodes();
|
||||
const canMute = useCanMuteNotifications();
|
||||
const nodeId = activeNode?.id ?? null;
|
||||
|
||||
const muteAll = useCallback(() => {
|
||||
void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId));
|
||||
}, [labelId, labelName, nodeId]);
|
||||
|
||||
const muteExternal = useCallback(() => {
|
||||
void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId));
|
||||
}, [labelId, labelName, nodeId]);
|
||||
|
||||
const muteLowPriority = useCallback(() => {
|
||||
void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId));
|
||||
}, [labelId, labelName, nodeId]);
|
||||
|
||||
const manage = useCallback(() => {
|
||||
openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId));
|
||||
}, [labelId, labelName, nodeId, openMuteRulesWithPrefill]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ canMute, muteAll, muteExternal, muteLowPriority, manage }),
|
||||
[canMute, muteAll, muteExternal, muteLowPriority, manage],
|
||||
);
|
||||
}
|
||||
|
||||
export function useNodeMuteActions(nodeId: number, nodeName: string, openMuteRulesWithPrefill: OpenMuteRulesPrefill) {
|
||||
const canMute = useCanMuteNotifications();
|
||||
|
||||
const muteAll = useCallback(() => {
|
||||
void createMuteRuleWithToast(nodeMuteAllDraft(nodeId, nodeName));
|
||||
}, [nodeId, nodeName]);
|
||||
|
||||
const muteUpdates = useCallback(() => {
|
||||
void createMuteRuleWithToast(nodeMuteUpdatesDraft(nodeId, nodeName));
|
||||
}, [nodeId, nodeName]);
|
||||
|
||||
const muteMonitor = useCallback(() => {
|
||||
void createMuteRuleWithToast(nodeMuteMonitorDraft(nodeId, nodeName));
|
||||
}, [nodeId, nodeName]);
|
||||
|
||||
const manage = useCallback(() => {
|
||||
openMuteRulesWithPrefill(nodeMuteAllDraft(nodeId, nodeName));
|
||||
}, [nodeId, nodeName, openMuteRulesWithPrefill]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ canMute, muteAll, muteUpdates, muteMonitor, manage }),
|
||||
[canMute, muteAll, muteUpdates, muteMonitor, manage],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MUTE_RULES_CHANGED_EVENT } from '@/lib/muteRules';
|
||||
|
||||
/** Refetch mute rules when another surface creates or updates a rule. */
|
||||
export function useMuteRulesRefresh(onRefresh: () => void): void {
|
||||
useEffect(() => {
|
||||
const handler = () => { onRefresh(); };
|
||||
window.addEventListener(MUTE_RULES_CHANGED_EVENT, handler);
|
||||
return () => window.removeEventListener(MUTE_RULES_CHANGED_EVENT, handler);
|
||||
}, [onRefresh]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
BellOff,
|
||||
BellRing,
|
||||
CalendarClock,
|
||||
Download,
|
||||
@@ -22,6 +23,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
|
||||
menuVisibility, openScheduleTask,
|
||||
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
|
||||
} = ctx;
|
||||
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
|
||||
|
||||
@@ -36,6 +38,20 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
if (stackStatus === 'running' && canOpenApp) {
|
||||
inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
|
||||
}
|
||||
if (canMuteNotifications) {
|
||||
inspect.push({
|
||||
id: 'mute',
|
||||
label: 'Mute',
|
||||
icon: BellOff,
|
||||
onSelect: () => {},
|
||||
subItems: [
|
||||
{ id: 'mute-stack-all', label: 'Mute notifications for this stack', icon: BellOff, onSelect: muteStackAll },
|
||||
{ id: 'mute-stack-deploy', label: 'Mute deploy success noise', icon: BellOff, onSelect: muteStackDeploySuccess },
|
||||
{ id: 'mute-stack-monitor', label: 'Mute monitor alerts for this stack', icon: BellOff, onSelect: muteStackMonitor },
|
||||
{ id: 'mute-stack-manage', label: 'Manage stack mute rules', icon: BellOff, onSelect: openStackMuteRules },
|
||||
],
|
||||
});
|
||||
}
|
||||
groups.push({ id: 'inspect', items: inspect });
|
||||
|
||||
const organize: MenuItem[] = [];
|
||||
@@ -82,5 +98,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
||||
showDeploy, showStop, showRestart, showUpdate,
|
||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
|
||||
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sourcesPresent, matchesSearch, type LabelSource } from '@/lib/labelInventory';
|
||||
|
||||
describe('sourcesPresent', () => {
|
||||
it('returns distinct sources in the fixed display order regardless of input order', () => {
|
||||
const input: LabelSource[] = ['runtime', 'unknown', 'compose', 'image', 'compose-system'];
|
||||
expect(sourcesPresent(input)).toEqual(['compose', 'image', 'runtime', 'compose-system', 'unknown']);
|
||||
});
|
||||
|
||||
it('de-duplicates and omits sources not present', () => {
|
||||
expect(sourcesPresent(['runtime', 'runtime', 'image'])).toEqual(['image', 'runtime']);
|
||||
expect(sourcesPresent([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesSearch', () => {
|
||||
it('matches when any part contains the (case-insensitive) query', () => {
|
||||
expect(matchesSearch('WATCH', 'com.centurylinklabs.watchtower.enable')).toBe(true);
|
||||
expect(matchesSearch('nope', 'traefik.enable', 'true')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty or whitespace query as matching everything', () => {
|
||||
expect(matchesSearch('', 'anything')).toBe(true);
|
||||
expect(matchesSearch(' ', null)).toBe(true);
|
||||
});
|
||||
|
||||
it('trims the query and tolerates null/undefined parts', () => {
|
||||
expect(matchesSearch(' enable ', 'traefik.enable')).toBe(true);
|
||||
expect(matchesSearch('x', null, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,12 @@ describe('scheduledActions registry', () => {
|
||||
expect(def?.id).toBe('update');
|
||||
});
|
||||
|
||||
it('maps container target types to container UI entries', () => {
|
||||
expect(resolveTaskAction({ action: 'restart', target_type: 'container' })?.id).toBe('container-restart');
|
||||
expect(resolveTaskAction({ action: 'auto_stop', target_type: 'container' })?.id).toBe('container-stop');
|
||||
expect(resolveTaskAction({ action: 'auto_start', target_type: 'container' })?.id).toBe('container-start');
|
||||
});
|
||||
|
||||
it('maps a non-aliased action to its direct entry', () => {
|
||||
expect(resolveTaskAction({ action: 'restart', target_type: 'stack' })?.id).toBe('restart');
|
||||
expect(resolveTaskAction({ action: 'snapshot', target_type: 'fleet' })?.id).toBe('snapshot');
|
||||
@@ -90,6 +96,7 @@ describe('scheduledActions registry', () => {
|
||||
// Verify the exact order: lifecycle first, then updates, security, maintenance, backups.
|
||||
expect(ids).toEqual([
|
||||
'auto_backup', 'auto_start', 'restart', 'auto_stop', 'auto_down',
|
||||
'container-restart', 'container-stop', 'container-start',
|
||||
'update', 'update-fleet',
|
||||
'scan',
|
||||
'prune',
|
||||
@@ -111,6 +118,9 @@ describe('scheduledActions registry', () => {
|
||||
'restart': 'Restarts containers in place. Running services are stopped and started again on the same configuration.',
|
||||
'auto_stop': 'Stops containers but keeps them in place for a faster start later.',
|
||||
'auto_down': 'Runs compose down. Containers are removed, but compose files remain on disk.',
|
||||
'container-restart': 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.',
|
||||
'container-stop': 'Stops a single container by name. The container remains on disk for a faster start later.',
|
||||
'container-start': 'Starts a stopped container by name on the selected node.',
|
||||
'update': "Checks this stack's images and recreates the stack only when newer images are available.",
|
||||
'update-fleet': 'Checks every stack on the selected node and updates stacks with newer images.',
|
||||
'scan': 'Runs Trivy against images on the selected local node and records the findings.',
|
||||
@@ -132,6 +142,9 @@ describe('scheduledActions registry', () => {
|
||||
'restart': 'interruptive',
|
||||
'auto_stop': 'interruptive',
|
||||
'auto_down': 'removes-containers',
|
||||
'container-restart': 'interruptive',
|
||||
'container-stop': 'interruptive',
|
||||
'container-start': 'runtime-change',
|
||||
'update': 'runtime-change',
|
||||
'update-fleet': 'runtime-change',
|
||||
'scan': 'read-only',
|
||||
@@ -212,6 +225,16 @@ describe('scheduledActions registry', () => {
|
||||
expect(scheduleTargetDescriptor(task)).toBe('Entire fleet');
|
||||
});
|
||||
|
||||
it('shows the container name for container actions', () => {
|
||||
const task: TargetTask = {
|
||||
action: 'restart',
|
||||
target_type: 'container',
|
||||
target_id: 'watchtower',
|
||||
name: 'Daily watchtower restart',
|
||||
};
|
||||
expect(scheduleTargetDescriptor(task, 'hub')).toBe('watchtower');
|
||||
});
|
||||
|
||||
it('shows the node for system actions (prune / scan), with a fallback', () => {
|
||||
const scan: TargetTask = { action: 'scan', target_type: 'system', target_id: null, name: 'Vul Scan' };
|
||||
const prune: TargetTask = { action: 'prune', target_type: 'system', target_id: null, name: 'Nightly Prune' };
|
||||
|
||||
@@ -13,6 +13,7 @@ export const CAPABILITIES = [
|
||||
'network-topology',
|
||||
'notifications',
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
'host-console',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
@@ -27,6 +28,7 @@ export const CAPABILITIES = [
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
'container-label-inventory',
|
||||
'project-env-files',
|
||||
'compose-storage',
|
||||
'cross-node-rbac',
|
||||
|
||||
@@ -42,6 +42,7 @@ export type FleetTab =
|
||||
| 'snapshots'
|
||||
| 'configuration'
|
||||
| 'dependencies'
|
||||
| 'container-labels'
|
||||
| 'deployments'
|
||||
| 'routing'
|
||||
| 'federation'
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export type LabelSource = 'compose' | 'runtime' | 'image' | 'compose-system' | 'unknown';
|
||||
|
||||
export interface LabelValue {
|
||||
key: string;
|
||||
value: string;
|
||||
source: LabelSource;
|
||||
redacted?: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerLabelRow {
|
||||
id: string;
|
||||
name: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
state: string;
|
||||
labels: LabelValue[];
|
||||
}
|
||||
|
||||
export interface LabelIndexContainerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
nodeId?: number;
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
export interface LabelIndexRow {
|
||||
key: string;
|
||||
value: string;
|
||||
redacted?: boolean;
|
||||
source: LabelSource;
|
||||
containers: LabelIndexContainerRef[];
|
||||
}
|
||||
|
||||
export interface FleetLabelInventoryResponse {
|
||||
nodes: Array<{
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
status: 'ok' | 'error';
|
||||
inventory: {
|
||||
nodeId: number;
|
||||
containers: ContainerLabelRow[];
|
||||
byLabel: LabelIndexRow[];
|
||||
partial: boolean;
|
||||
generatedAt: number;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
aggregatedByLabel: LabelIndexRow[];
|
||||
nodeErrors: Record<number, string>;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export interface StackLabelReplica {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
runtimeLabels: LabelValue[];
|
||||
onlyInCompose: string[];
|
||||
onlyOnContainer: string[];
|
||||
inBoth: string[];
|
||||
// Optional so older nodes (pre-provenance) still typecheck during mixed-version operation.
|
||||
changed?: string[];
|
||||
inspectFailed?: boolean;
|
||||
}
|
||||
|
||||
export interface StackServiceLabelRow {
|
||||
service: string;
|
||||
declaredLabels: LabelValue[];
|
||||
replicas: StackLabelReplica[];
|
||||
}
|
||||
|
||||
export interface StackLabelInventory {
|
||||
stackName: string;
|
||||
renderable: boolean;
|
||||
services: StackServiceLabelRow[];
|
||||
// Optional so older nodes still typecheck during mixed-version operation.
|
||||
partial?: boolean;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export const LABEL_DISAMBIGUATION_COPY =
|
||||
'Docker labels are metadata declared on Compose services or attached to running containers. They are different from Sencho Stack Labels used for organizing stacks and Node Labels used for Blueprint placement.';
|
||||
|
||||
export const SOURCE_LABELS: Record<LabelSource, string> = {
|
||||
compose: 'Declared in Compose',
|
||||
runtime: 'Present at runtime',
|
||||
image: 'Image',
|
||||
'compose-system': 'Docker Compose system label',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
/** Concise facet-chip labels (concept-aligned with SOURCE_LABELS, not a rename of the badges). */
|
||||
export const SOURCE_FACET_LABELS: Record<LabelSource, string> = {
|
||||
compose: 'Compose File',
|
||||
runtime: 'Runtime',
|
||||
image: 'Image',
|
||||
'compose-system': 'System',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
/** Stable display order for source facets. */
|
||||
const SOURCE_ORDER: LabelSource[] = ['compose', 'image', 'runtime', 'compose-system', 'unknown'];
|
||||
|
||||
/** The distinct sources present, in stable display order, for building data-driven facet rows. */
|
||||
export function sourcesPresent(sources: Iterable<LabelSource>): LabelSource[] {
|
||||
const present = new Set(sources);
|
||||
return SOURCE_ORDER.filter(s => present.has(s));
|
||||
}
|
||||
|
||||
/** Case-insensitive substring match across a variable list of fields; empty query matches all. */
|
||||
export function matchesSearch(query: string, ...parts: (string | null | undefined)[]): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return parts.some(p => (p ?? '').toLowerCase().includes(q));
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
||||
import type { NotificationCategory, NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
export type MuteRuleLevel = 'info' | 'warning' | 'error';
|
||||
export type MuteRuleAppliesTo = 'bell' | 'external' | 'both';
|
||||
|
||||
export type MuteRuleDraft = {
|
||||
name: string;
|
||||
node_id?: number | null;
|
||||
stack_patterns?: string[];
|
||||
label_ids?: number[] | null;
|
||||
categories?: NotificationCategory[] | null;
|
||||
levels?: MuteRuleLevel[] | null;
|
||||
applies_to?: MuteRuleAppliesTo;
|
||||
enabled?: boolean;
|
||||
expires_at?: number | null;
|
||||
};
|
||||
|
||||
export const MUTE_RULES_CHANGED_EVENT = 'sencho:mute-rules-changed';
|
||||
|
||||
export function emitMuteRulesChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(MUTE_RULES_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
const DEFAULT_BODY = {
|
||||
applies_to: 'both' as MuteRuleAppliesTo,
|
||||
enabled: true,
|
||||
expires_at: null as number | null,
|
||||
node_id: null as number | null,
|
||||
stack_patterns: [] as string[],
|
||||
label_ids: null as number[] | null,
|
||||
categories: null as NotificationCategory[] | null,
|
||||
levels: null as MuteRuleLevel[] | null,
|
||||
};
|
||||
|
||||
export async function createMuteRule(draft: MuteRuleDraft): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await apiFetch('/notification-suppression-rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...DEFAULT_BODY, ...draft }),
|
||||
});
|
||||
if (res.ok) {
|
||||
emitMuteRulesChanged();
|
||||
return { ok: true };
|
||||
}
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err as { error?: string })?.error || 'Failed to create mute rule.' };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMuteRuleWithToast(
|
||||
draft: MuteRuleDraft,
|
||||
successMessage = 'Mute rule created. Open Settings · Mute Rules to edit it.',
|
||||
): Promise<boolean> {
|
||||
const result = await createMuteRule(draft);
|
||||
if (result.ok) {
|
||||
toast.success(successMessage);
|
||||
return true;
|
||||
}
|
||||
toast.error(result.error || 'Failed to create mute rule.');
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stackMuteAllDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName}`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
};
|
||||
}
|
||||
|
||||
export function stackMuteDeploySuccessDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName} deploy success`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
categories: ['deploy_success'],
|
||||
};
|
||||
}
|
||||
|
||||
export function stackMuteMonitorDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName} monitor alerts`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
categories: ['monitor_alert'],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteAllDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName}`,
|
||||
node_id: nodeId,
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteUpdatesDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName} update notifications`,
|
||||
node_id: nodeId,
|
||||
categories: ['image_update_available', 'node_update_available', 'update_started'],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteMonitorDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName} monitor alerts`,
|
||||
node_id: nodeId,
|
||||
categories: ['monitor_alert'],
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteAllDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
applies_to: 'both',
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteExternalDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute external for label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
applies_to: 'external',
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteLowPriorityDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute low-priority alerts for label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
levels: ['info', 'warning'],
|
||||
};
|
||||
}
|
||||
|
||||
export type BellMuteMode = 'category' | 'similar' | 'stack';
|
||||
|
||||
export function muteDraftFromNotification(
|
||||
notif: NotificationItem,
|
||||
mode: BellMuteMode,
|
||||
): MuteRuleDraft | null {
|
||||
const categoryLabel = notif.category ? CATEGORY_LABELS[notif.category as NotificationCategory] : 'alert';
|
||||
|
||||
if (mode === 'category' && notif.category) {
|
||||
return {
|
||||
name: `Mute ${categoryLabel}`,
|
||||
node_id: notif.nodeId ?? null,
|
||||
categories: [notif.category as NotificationCategory],
|
||||
};
|
||||
}
|
||||
if (mode === 'stack' && notif.stack_name) {
|
||||
return {
|
||||
name: `Mute ${notif.stack_name}`,
|
||||
node_id: notif.nodeId ?? null,
|
||||
stack_patterns: [notif.stack_name],
|
||||
};
|
||||
}
|
||||
if (mode === 'similar') {
|
||||
return {
|
||||
name: 'Mute similar alerts',
|
||||
node_id: notif.nodeId ?? null,
|
||||
categories: notif.category ? [notif.category as NotificationCategory] : null,
|
||||
levels: [notif.level],
|
||||
stack_patterns: notif.stack_name ? [notif.stack_name] : [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createMuteFromNotification(
|
||||
notif: NotificationItem,
|
||||
mode: BellMuteMode,
|
||||
): Promise<boolean> {
|
||||
const draft = muteDraftFromNotification(notif, mode);
|
||||
if (!draft) {
|
||||
toast.error('This notification cannot be muted with that shortcut.');
|
||||
return false;
|
||||
}
|
||||
return createMuteRuleWithToast(draft);
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
|
||||
autoheal_triggered: 'Auto-heal',
|
||||
monitor_alert: 'Monitor alert',
|
||||
scan_finding: 'Scan finding',
|
||||
drift_detected: 'Drift detected',
|
||||
drift_resolved: 'Drift resolved',
|
||||
update_started: 'Update started',
|
||||
health_gate_passed: 'Health gate passed',
|
||||
health_gate_failed: 'Health gate failed',
|
||||
node_update_available: 'Node update',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ export type BackendAction = ScheduledTask['action'];
|
||||
* UI action ids. `update-fleet` is a frontend-only alias for `update` with
|
||||
* `target_type: 'fleet'`; it never reaches the backend.
|
||||
*/
|
||||
export type ScheduledActionId = BackendAction | 'update-fleet';
|
||||
export type ScheduledActionId = BackendAction | 'update-fleet' | 'container-restart' | 'container-stop' | 'container-start';
|
||||
|
||||
export type ScheduledActionCategory = 'lifecycle' | 'updates' | 'security' | 'maintenance' | 'backups';
|
||||
export type ScheduledActionTone = 'success' | 'warning' | 'destructive' | 'brand';
|
||||
@@ -79,6 +79,7 @@ export interface ScheduledActionDefinition {
|
||||
tone: ScheduledActionTone;
|
||||
requiresNode: boolean;
|
||||
requiresStack: boolean;
|
||||
requiresContainer: boolean;
|
||||
supportsServiceSelection: boolean;
|
||||
nodeScope?: 'local';
|
||||
/** One-line explanation shown below the action picker in the create/edit form. */
|
||||
@@ -93,20 +94,23 @@ export const DEFAULT_SCHEDULED_ACTION_ID: ScheduledActionId = 'restart';
|
||||
/** Ordered for the create-flow action picker, grouped by category. */
|
||||
export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [
|
||||
// Lifecycle
|
||||
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' },
|
||||
{ id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' },
|
||||
{ id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' },
|
||||
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' },
|
||||
{ id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' },
|
||||
{ id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' },
|
||||
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' },
|
||||
{ id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive' },
|
||||
{ id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive' },
|
||||
{ id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change' },
|
||||
// Updates
|
||||
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' },
|
||||
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' },
|
||||
// Security
|
||||
{ id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' },
|
||||
{ id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' },
|
||||
// Maintenance
|
||||
{ id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' },
|
||||
{ id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' },
|
||||
// Backups
|
||||
{ id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' },
|
||||
{ id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' },
|
||||
];
|
||||
|
||||
const ACTION_BY_ID = new Map<string, ScheduledActionDefinition>(SCHEDULED_ACTIONS.map(a => [a.id, a]));
|
||||
@@ -126,6 +130,11 @@ export function resolveTaskAction(
|
||||
if (task.action === 'update' && task.target_type === 'fleet') {
|
||||
return getActionById('update-fleet');
|
||||
}
|
||||
if (task.target_type === 'container') {
|
||||
if (task.action === 'restart') return getActionById('container-restart');
|
||||
if (task.action === 'auto_stop') return getActionById('container-stop');
|
||||
if (task.action === 'auto_start') return getActionById('container-start');
|
||||
}
|
||||
return getActionById(task.action);
|
||||
}
|
||||
|
||||
@@ -153,6 +162,8 @@ export function scheduleTargetDescriptor(
|
||||
: 'Entire fleet';
|
||||
case 'system':
|
||||
return nodeName ?? 'Selected node';
|
||||
case 'container':
|
||||
return task.target_id ?? task.name;
|
||||
default: {
|
||||
const exhaustive: never = task.target_type;
|
||||
return exhaustive;
|
||||
@@ -169,7 +180,7 @@ export interface ScheduledActionCategoryLane {
|
||||
|
||||
/** Ordered Timeline lanes; each scheduled action maps to one lane by category. */
|
||||
export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [
|
||||
{ key: 'lifecycle', label: 'Stack lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' },
|
||||
{ key: 'lifecycle', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' },
|
||||
{ key: 'updates', label: 'Updates', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)' },
|
||||
{ key: 'security', label: 'Security', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)' },
|
||||
{ key: 'maintenance', label: 'Maintenance', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)' },
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { excludeLikelySenchoContainers, isLikelySenchoManagementContainer } from '../lib/senchoContainerFilter';
|
||||
|
||||
describe('senchoContainerFilter', () => {
|
||||
it('detects sencho management containers by name and image', () => {
|
||||
expect(isLikelySenchoManagementContainer({ Id: '1', Names: ['/sencho'], Image: 'saelix/sencho:latest' })).toBe(true);
|
||||
expect(isLikelySenchoManagementContainer({ Id: '2', Names: ['/sencho-agent'] })).toBe(true);
|
||||
expect(isLikelySenchoManagementContainer({ Id: '3', Names: ['/mariadb'], Image: 'lscr.io/linuxserver/mariadb:latest' })).toBe(false);
|
||||
});
|
||||
|
||||
it('excludeLikelySenchoContainers keeps user containers', () => {
|
||||
const rows = excludeLikelySenchoContainers([
|
||||
{ Id: '1', Names: ['/sencho'], Image: 'ghcr.io/studio-saelix/sencho:dev' },
|
||||
{ Id: '2', Names: ['/mariadb'], Image: 'lscr.io/linuxserver/mariadb:latest' },
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].Names).toEqual(['/mariadb']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Mirrors backend/helpers/excludeSelfContainers.ts heuristics for proxied remote lists. */
|
||||
|
||||
export interface ContainerPickerRow {
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
Image?: string;
|
||||
}
|
||||
|
||||
function isPublishedSenchoImage(image: string): boolean {
|
||||
const lower = image.toLowerCase();
|
||||
return /(?:^|\/)saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower)
|
||||
|| /studio-saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower);
|
||||
}
|
||||
|
||||
export function isLikelySenchoManagementContainer(c: ContainerPickerRow): boolean {
|
||||
const name = c.Names?.[0]?.replace(/^\//, '').toLowerCase() ?? '';
|
||||
if (name === 'sencho' || name === 'sencho-agent') return true;
|
||||
if (c.Image && isPublishedSenchoImage(c.Image)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function excludeLikelySenchoContainers<T extends ContainerPickerRow>(containers: T[]): T[] {
|
||||
return containers.filter(c => !isLikelySenchoManagementContainer(c));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export interface ScheduledTask {
|
||||
id: number;
|
||||
name: string;
|
||||
target_type: 'stack' | 'fleet' | 'system';
|
||||
target_type: 'stack' | 'fleet' | 'system' | 'container';
|
||||
target_id: string | null;
|
||||
node_id: number | null;
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
|
||||
|
||||
Reference in New Issue
Block a user