diff --git a/backend/src/__tests__/proxy-mount-order.test.ts b/backend/src/__tests__/proxy-mount-order.test.ts index 804b5e87..bbbbed9d 100644 --- a/backend/src/__tests__/proxy-mount-order.test.ts +++ b/backend/src/__tests__/proxy-mount-order.test.ts @@ -76,6 +76,21 @@ describe('remote proxy mount order', () => { expect(res.status).not.toBe(502); }); + it('short-circuits /api/stacks/statuses (the sidebar status poll) for remote nodes', async () => { + // The sidebar polls /api/stacks/statuses every few seconds; if a future + // refactor accidentally mounted a local fast-path before the proxy, every + // operator viewing a remote node would silently see the central instance's + // own statuses. Asserts the same 502 short-circuit as /api/stacks. + const res = await request(app) + .get('/api/stacks/statuses') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(502); + expect(res.headers['x-sencho-proxy']).toBeUndefined(); + expect(res.body?.error).toMatch(/unreachable/i); + }); + it('handles proxy-exempt paths locally even when x-node-id targets a remote', async () => { // /api/nodes/:id is in PROXY_EXEMPT_PREFIXES. The proxy must never catch // gateway-level concerns; otherwise a user whose default node is remote diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index e0442f80..f42fc198 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -85,7 +85,7 @@ Right-click any stack (or open the kebab that appears on hover) for its context - **Destructive**: **Delete**. - **Auto-Heal** and **Schedule task** require a **Skipper** or **Admiral** license. + **Auto-Heal** requires a **Skipper** or **Admiral** license. **Schedule task** requires a **Skipper** or **Admiral** license and an **admin** role. @@ -143,4 +143,7 @@ The footer surfaces the most recent stack lifecycle event on the node. Each tick Click the **+** icon to the right of the search box to bring them back. The chip row collapses to a thin **−** / **+** toggle, and the state is remembered in your browser, so an earlier collapse persists across reloads until you expand it again. + + Scheduling requires a **Skipper** or **Admiral** license and an **admin** role. Ask an admin on this node to schedule the task for you, or sign in with an admin account. + diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 808925ae..6d885ba7 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -176,6 +176,7 @@ export default function EditorLayout() { activeNode, isPaid, isAdmiral, + isAdmin, can, }); diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 5c2756fc..c69da470 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -21,6 +21,7 @@ interface UseSidebarContextMenuOptions { activeNode: Node | null | undefined; isPaid: boolean; isAdmiral: boolean; + isAdmin: boolean; can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; } @@ -32,6 +33,7 @@ export function useSidebarContextMenu({ activeNode, isPaid, isAdmiral, + isAdmin, can, }: UseSidebarContextMenuOptions) { const buildMenuCtx = useCallback((file: string): StackMenuCtx => { @@ -42,6 +44,7 @@ export function useSidebarContextMenu({ isBusy: stackListState.isStackBusy(file), isPaid, isAdmiral, + isAdmin, canDelete: can('stack:delete', 'stack', sName), canEditLabels: can('stack:edit', 'stack', sName), // POST /api/labels (the inline "New label" entry) is guarded by the @@ -121,9 +124,13 @@ export function useSidebarContextMenu({ navState.setActiveView('scheduled-ops'); }, }; + // Handlers from useStackActions, useOverlayState, useViewNavigationState are + // useCallback-stabilized at their owner hooks, so listing the menu surface + // values (status maps, role/tier flags, pin state) is sufficient. Exhaustive + // deps would force a rebuild on every parent render and defeat the memo. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral, + stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral, isAdmin, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, stackListState.pin, stackListState.unpin, ]); diff --git a/frontend/src/components/sidebar/SidebarSearch.tsx b/frontend/src/components/sidebar/SidebarSearch.tsx index 993ed6b4..6cf7f4ce 100644 --- a/frontend/src/components/sidebar/SidebarSearch.tsx +++ b/frontend/src/components/sidebar/SidebarSearch.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from 'react'; import { CommandInput } from '@/components/ui/command'; interface SidebarSearchProps { @@ -5,13 +6,44 @@ interface SidebarSearchProps { onValueChange: (v: string) => void; } +// 120ms feels instant to a typist (still under the ~150ms human reaction +// floor) while collapsing a burst of keystrokes into one filter rebuild. +// `` means useStackListState owns the actual +// filter pass; debouncing here directly cuts its rebuild count. +const DEBOUNCE_MS = 120; + export function SidebarSearch({ value, onValueChange }: SidebarSearchProps) { + const [local, setLocal] = useState(value); + const timerRef = useRef | null>(null); + const lastEmittedRef = useRef(value); + + useEffect(() => { + // Parent value can move for two reasons: + // 1. Echo of our own debounced emit (lastEmittedRef matches): skip. + // 2. External reset (e.g., clear-on-filter-change): adopt it. + if (value === lastEmittedRef.current) return; + setLocal(value); + }, [value]); + + useEffect(() => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, []); + + const handleChange = (next: string) => { + setLocal(next); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + lastEmittedRef.current = next; + onValueChange(next); + }, DEBOUNCE_MS); + }; + return (
diff --git a/frontend/src/components/sidebar/__tests__/SidebarSearch.test.tsx b/frontend/src/components/sidebar/__tests__/SidebarSearch.test.tsx new file mode 100644 index 00000000..1ff19592 --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/SidebarSearch.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, fireEvent, cleanup } from '@testing-library/react'; +import { Command } from '@/components/ui/command'; +import { SidebarSearch } from '../SidebarSearch'; + +function renderInsideCommand(props: { value: string; onValueChange: (v: string) => void }) { + return render( + + + , + ); +} + +describe('SidebarSearch', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + it('reflects typing immediately in the input but does not emit until the debounce window closes', () => { + const onValueChange = vi.fn(); + const { getByPlaceholderText } = renderInsideCommand({ value: '', onValueChange }); + const input = getByPlaceholderText('Search stacks...') as HTMLInputElement; + + act(() => { + fireEvent.input(input, { target: { value: 'n' } }); + fireEvent.input(input, { target: { value: 'ng' } }); + fireEvent.input(input, { target: { value: 'ngi' } }); + fireEvent.input(input, { target: { value: 'ngin' } }); + fireEvent.input(input, { target: { value: 'nginx' } }); + }); + + expect(input.value).toBe('nginx'); + expect(onValueChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(120); + }); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenLastCalledWith('nginx'); + }); + + it('does not clobber in-flight typing when the parent value echoes back the previous debounced emit', () => { + const onValueChange = vi.fn(); + const { getByPlaceholderText, rerender } = renderInsideCommand({ value: '', onValueChange }); + const input = getByPlaceholderText('Search stacks...') as HTMLInputElement; + + act(() => { + fireEvent.input(input, { target: { value: 'web' } }); + }); + act(() => { + vi.advanceTimersByTime(120); + }); + expect(onValueChange).toHaveBeenLastCalledWith('web'); + + // Parent now propagates 'web' back as the controlled value. + rerender( + + + , + ); + + // User keeps typing before the parent's echo settles. + act(() => { + fireEvent.input(input, { target: { value: 'web-api' } }); + }); + + // The echo of 'web' must not overwrite 'web-api' on the input. + expect(input.value).toBe('web-api'); + }); + + it('adopts an external reset of the parent value (e.g., clear)', () => { + const onValueChange = vi.fn(); + const { getByPlaceholderText, rerender } = renderInsideCommand({ value: 'old-query', onValueChange }); + const input = getByPlaceholderText('Search stacks...') as HTMLInputElement; + expect(input.value).toBe('old-query'); + + rerender( + + + , + ); + + expect(input.value).toBe(''); + }); +}); diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index fcaa5961..d9d2c3db 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -27,6 +27,7 @@ export interface StackMenuCtx { isBusy: boolean; isPaid: boolean; isAdmiral: boolean; + isAdmin: boolean; canDelete: boolean; canEditLabels: boolean; canCreateLabels: boolean; diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index f72c1edb..d89c0c85 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -11,6 +11,7 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { isBusy: false, isPaid: true, isAdmiral: false, + isAdmin: true, canDelete: true, canEditLabels: true, canCreateLabels: true, @@ -94,6 +95,12 @@ describe('useStackMenuItems', () => { expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true); }); + it('hides Schedule task when paid but not admin', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, isAdmin: false }))); + const lifecycle = result.current.find(g => g.id === 'lifecycle'); + expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy(); + }); + it('keeps label assignment available when !isPaid', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false, diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 7218ed94..96446f89 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -18,7 +18,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] { const { - stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels, + stackStatus, hasPort, isBusy, isPaid, isAdmin, canDelete, canEditLabels, isPinned, labels, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, menuVisibility, openScheduleTask, @@ -68,7 +68,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy }); if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy }); if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy }); - if (isPaid) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); + if (isPaid && isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle }); if (canDelete) { @@ -80,7 +80,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] return groups; }, [ - stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels, + stackStatus, hasPort, isBusy, isPaid, isAdmin, canDelete, canEditLabels, isPinned, labels, showDeploy, showStop, showRestart, showUpdate, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,