diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 5264b0d4..ea1a4c9a 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -14,7 +14,7 @@ Selecting a stack in the sidebar opens the editor cockpit, a two-column workspac The top card on the left holds the stack's identity and primary controls. - Command Center card with breadcrumb, stack title, running status pill, image and digest, action bar, and the plex container row with live stats + Command Center card with breadcrumb, stack title, running status pill, action bar, and the plex container row with live stats | Element | What it shows | @@ -22,7 +22,6 @@ The top card on the left holds the stack's identity and primary controls. | **Breadcrumb** | ` › stacks › ` rendered in caps. Confirms which node the stack lives on. | | **Stack title** | The stack name in display italic. | | **Status pill** | Aggregate state: `running · healthy`, `running · starting`, `running · unhealthy`, plain `running` (no healthcheck defined), or `exited` (any non-running state, including stopped, created, and paused). The dot pulses while the stack is running. | -| **Image and digest** | First container's image and the leading 12 characters of its image digest, with a copy button for the full SHA. | ### Stack action bar @@ -56,7 +55,7 @@ Below the action bar, the **CONTAINERS** section lists every container the stack | **Uptime / state** | `up 2h 15m` for running containers, the raw state for everything else. | | **Healthcheck label** | `healthcheck passing`, `healthcheck failing`, or `healthcheck starting`, only when a healthcheck is defined. | | **Port mapping** | The first detected web-UI port, formatted `host → container/proto`. The mapping itself is a link that opens the service in a new tab, with a **Copy URL** button beside it. The address uses the active node's host and switches to `https` for port 443. | -| **Action buttons** | **View logs**, **Open bash shell** (admin only), **Service actions**. | +| **Action buttons** | **Image source links**, **View logs**, **Open bash shell** (admin only), **Service actions**. Per-container image references and registry links live here, not in the header. | | **Live stats** | CPU, memory, and net I/O with rolling sparklines. Only rendered while the container is running. Stats refresh on the same 1500 ms cadence as the dashboard. | ### Service actions diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index e044c4c3..f1e14e63 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -175,10 +175,10 @@ Click any group header to collapse or expand it. The group's stack count appears Opening a stack puts identity and state front and center. - Stack identity header showing breadcrumb, title, state pill, image and digest, and action row + Stack identity header showing breadcrumb, title, state pill, and action row -The header answers three questions at a glance: +The header answers two questions at a glance: - **What is this?** A breadcrumb (`LOCAL · STACKS · NAME`) and the stack name as the title. - **Is it healthy?** A state pill to the right of the title reports the live state: @@ -187,21 +187,20 @@ The header answers three questions at a glance: - `running · starting` (amber) during the Docker healthcheck start period. - `running` (green) when no healthcheck is defined. - `exited` (red) when no containers are up. -- **What does it ship?** A mono line under the title shows the primary image tag and the short image digest, with a copy button to grab the full digest. -The action row to the right of the header keeps everyday lifecycle controls visible. The **More actions** overflow holds Rollback, Scan config, and Delete; the next sections cover both surfaces. +The action row below the title keeps everyday lifecycle controls visible. The **More actions** overflow holds Rollback, Scan config, and Delete; the next sections cover both surfaces. If Sencho discovers its own compose project as a stack (because the deployment directory lives inside `COMPOSE_DIR`), that stack is treated as the running Sencho instance: deploy, update, stop, down, and delete are disabled in the UI and refused by the API. Use **Fleet → Node Update** to upgrade Sencho. Compose Doctor flags the condition during preflight. ### Image source links -Next to the image line is a links button that turns the image reference into somewhere useful to go. It opens a small menu with: +Each container row includes a links button that turns the image reference into somewhere useful to go. It opens a small menu with: - **Open on the registry.** Docker Hub images link to their Docker Hub page; GitHub Container Registry (`ghcr.io`) images link to the owner on GitHub. Images from a private or unrecognized registry show the registry host instead of a link, so you never land on a broken guessed URL. - **Copy image reference.** Grabs the full reference for use elsewhere. - **Source, homepage, documentation, and revision links** when the image declares them. These come from the image's own [OCI labels](https://github.com/opencontainers/image-spec/blob/main/annotations.md) (`org.opencontainers.image.source`, `.url`, `.documentation`, `.revision`, `.version`) and are read locally from the image, so they need no internet access and appear only when the image ships them. A revision links to its commit when the source is a GitHub repository; the version is shown as plain text. -The same links button appears on each container row and on the update cards in [Auto-Update Policies](/features/auto-update-policies), so you can review what an image ships and where it comes from before approving an update. +The same links button appears on update cards in [Auto-Update Policies](/features/auto-update-policies), so you can review what an image ships and where it comes from before approving an update. ## Container health strip diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index 4c01e813..c6ac6442 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -48,7 +48,7 @@ Stacks can be pinned and grouped by label. Each row carries a compact status mar Opening a stack gives you the day-to-day workspace: -- The identity header shows the node, stack name, runtime state, primary image, digest, and main action buttons. +- The identity header shows the node, stack name, runtime state, and main action buttons. - Running stacks expose **Restart**, **Stop**, and **Update**; stopped stacks expose **Start** and **Update**. The overflow menu holds less frequent actions such as rollback, config scan, and delete. - Container rows show health, uptime, published ports, live CPU and memory, network activity, logs, and service actions. - The logs panel can run in **Structured** mode or **Raw terminal** mode. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index f2b74cdd..1455121f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -87,8 +87,6 @@ export default function EditorLayout() { const editorState = useEditorViewState(); const { stackMisconfigScanning, - copiedDigest, setCopiedDigest, - copiedDigestTimerRef, content, setContent, envContent, setEnvContent, envExists, @@ -465,14 +463,12 @@ export default function EditorLayout() { isEditing={isEditing} editingCompose={editingCompose} logsMode={logsMode} - copiedDigest={copiedDigest} loadingAction={loadingAction} stackMisconfigScanning={stackMisconfigScanning} can={can} isAdmin={isAdmin} trivy={trivy} activeNode={activeNode} - copiedDigestTimerRef={copiedDigestTimerRef} deployStack={stackActions.deployStack} restartStack={stackActions.restartStack} stopStack={stackActions.stopStack} @@ -493,7 +489,6 @@ export default function EditorLayout() { setLogsMode={setLogsMode} setEditingCompose={setEditingCompose} setGitSourceOpen={setGitSourceOpen} - setCopiedDigest={setCopiedDigest} requestDeleteStack={stackActions.requestDeleteStack} isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false} recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 831bc2ad..51643b3e 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -142,7 +142,6 @@ export interface EditorViewProps { isEditing: boolean; editingCompose: boolean; logsMode: 'structured' | 'raw'; - copiedDigest: string | null; loadingAction: StackAction | null; stackMisconfigScanning: boolean; @@ -152,9 +151,6 @@ export interface EditorViewProps { trivy: { available: boolean }; activeNode: Node | null; - // Refs - copiedDigestTimerRef: React.MutableRefObject; - // Stack actions deployStack: (e: React.MouseEvent) => Promise; restartStack: (e: React.MouseEvent) => Promise; @@ -185,7 +181,6 @@ export interface EditorViewProps { setLogsMode: (mode: 'structured' | 'raw') => void; setEditingCompose: (open: boolean) => void; setGitSourceOpen: (open: boolean) => void; - setCopiedDigest: React.Dispatch>; // Composed action: wraps setStackToDelete + setDeleteDialogOpen requestDeleteStack: () => void; @@ -242,14 +237,12 @@ export function EditorView(props: EditorViewProps) { isEditing, editingCompose, logsMode, - copiedDigest, loadingAction, stackMisconfigScanning, can, isAdmin, trivy, activeNode, - copiedDigestTimerRef, deployStack, restartStack, stopStack, @@ -270,7 +263,6 @@ export function EditorView(props: EditorViewProps) { setLogsMode, setEditingCompose, setGitSourceOpen, - setCopiedDigest, requestDeleteStack, isSelfStack, recoveryResult, @@ -385,9 +377,6 @@ export function EditorView(props: EditorViewProps) { activeNode={activeNode} safeContainers={safeContainers} isRunning={isRunning} - copiedDigest={copiedDigest} - setCopiedDigest={setCopiedDigest} - copiedDigestTimerRef={copiedDigestTimerRef} can={can} isAdmin={isAdmin} trivy={trivy} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx index e313e969..1f0dad70 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx @@ -47,7 +47,6 @@ function makeProps(over: Partial = {}): EditorViewProps { isFileLoading: false, gitSourcePendingMap: {}, notifications: [], - copiedDigest: null, loadingAction: null, stackMisconfigScanning: false, can: () => true, @@ -57,7 +56,6 @@ function makeProps(over: Partial = {}): EditorViewProps { logsMode: 'structured', activeTab: 'compose', editingCompose: false, - copiedDigestTimerRef: { current: null }, deployStack: vi.fn(), restartStack: vi.fn(), stopStack: vi.fn(), @@ -76,7 +74,6 @@ function makeProps(over: Partial = {}): EditorViewProps { setActiveTab: vi.fn(), setEditingCompose: vi.fn(), setGitSourceOpen: vi.fn(), - setCopiedDigest: vi.fn(), requestDeleteStack: vi.fn(), onMobileBack: vi.fn(), onCloseEditor: vi.fn(), diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 4066c9df..15c202e8 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -39,7 +39,6 @@ export function MobileStackDetail(props: EditorViewProps) { isFileLoading, gitSourcePendingMap, notifications, - copiedDigest, loadingAction, stackMisconfigScanning, can, @@ -49,7 +48,6 @@ export function MobileStackDetail(props: EditorViewProps) { logsMode, activeTab, editingCompose, - copiedDigestTimerRef, deployStack, restartStack, stopStack, @@ -68,7 +66,6 @@ export function MobileStackDetail(props: EditorViewProps) { setActiveTab, setEditingCompose, setGitSourceOpen, - setCopiedDigest, requestDeleteStack, isSelfStack = false, onMobileBack, @@ -138,9 +135,6 @@ export function MobileStackDetail(props: EditorViewProps) { activeNode={activeNode} safeContainers={safeContainers} isRunning={isRunning} - copiedDigest={copiedDigest} - setCopiedDigest={setCopiedDigest} - copiedDigestTimerRef={copiedDigestTimerRef} can={can} isAdmin={isAdmin} trivy={trivy} diff --git a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx index c1e34141..21314a73 100644 --- a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx @@ -45,7 +45,6 @@ function makeProps(over: Partial = {}): EditorViewProps { backupInfo: { exists: false, timestamp: null }, gitSourcePendingMap: {}, notifications: [], - copiedDigest: null, loadingAction: null, stackMisconfigScanning: false, activeTab: 'compose', @@ -55,7 +54,6 @@ function makeProps(over: Partial = {}): EditorViewProps { can: () => true, isAdmin: false, trivy: { available: false }, - copiedDigestTimerRef: { current: null }, deployStack: vi.fn(), restartStack: vi.fn(), stopStack: vi.fn(), @@ -76,7 +74,6 @@ function makeProps(over: Partial = {}): EditorViewProps { setLogsMode: vi.fn(), setEditingCompose: vi.fn(), setGitSourceOpen: vi.fn(), - setCopiedDigest: vi.fn(), requestDeleteStack: vi.fn(), onRefreshState: vi.fn(), onDismissRecovery: vi.fn(), diff --git a/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx new file mode 100644 index 00000000..04a72465 --- /dev/null +++ b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { StackIdentityHeader } from '../editor-view-blocks'; +import type { ContainerInfo } from '../EditorView'; + +vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../../Terminal', () => ({ default: () => null })); +vi.mock('../../StructuredLogViewer', () => ({ default: () => null })); +vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => null })); + +const CONTAINERS: ContainerInfo[] = [ + { + Id: 'abc123', + Names: ['/web'], + State: 'running', + Status: 'Up 2 hours', + Image: 'nginx:alpine', + ImageID: 'sha256:deadbeef', + healthStatus: 'healthy', + } as ContainerInfo, + { + Id: 'def456', + Names: ['/api'], + State: 'running', + Status: 'Up 2 hours', + Image: 'node:20', + ImageID: 'sha256:cafebabe', + healthStatus: 'healthy', + } as ContainerInfo, +]; + +function renderHeader(over: Partial> = {}) { + return render( + true} + isAdmin + trivy={{ available: false }} + backupInfo={{ exists: false, timestamp: null }} + loadingAction={null} + stackMisconfigScanning={false} + deployStack={vi.fn()} + restartStack={vi.fn()} + stopStack={vi.fn()} + updateStack={vi.fn()} + rollbackStack={vi.fn()} + scanStackConfig={vi.fn()} + requestDeleteStack={vi.fn()} + {...over} + />, + ); +} + +describe('StackIdentityHeader', () => { + it('renders stack identity and stack-wide actions without a header image line', () => { + renderHeader(); + + expect(screen.getByText('plex')).toBeInTheDocument(); + expect(screen.getByText(/running · healthy/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Restart' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument(); + + expect(screen.queryByText(/^image$/i)).not.toBeInTheDocument(); + expect(screen.queryByText('nginx:alpine')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Copy digest' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx index e2f56736..ea3b6ac8 100644 --- a/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx @@ -35,12 +35,6 @@ describe('useEditorViewState', () => { expect(result.current.gitSourcePendingMap).toEqual({}); }); - it('defaults nullable fields to null', () => { - const { result } = renderHook(() => useEditorViewState()); - expect(result.current.copiedDigest).toBeNull(); - expect(result.current.copiedDigestTimerRef.current).toBeNull(); - }); - it('defaults activeTab to compose', () => { const { result } = renderHook(() => useEditorViewState()); expect(result.current.activeTab).toBe('compose'); @@ -103,21 +97,4 @@ describe('useEditorViewState', () => { expect(window.localStorage.getItem(LOGS_MODE_STORAGE_KEY)).toBe('raw'); }); }); - - describe('copiedDigestTimerRef cleanup', () => { - it('clears a pending timer on unmount', () => { - const clearSpy = vi.spyOn(window, 'clearTimeout'); - const { result, unmount } = renderHook(() => useEditorViewState()); - result.current.copiedDigestTimerRef.current = 4242; - unmount(); - expect(clearSpy).toHaveBeenCalledWith(4242); - }); - - it('does nothing on unmount when no timer is pending', () => { - const clearSpy = vi.spyOn(window, 'clearTimeout'); - const { unmount } = renderHook(() => useEditorViewState()); - unmount(); - expect(clearSpy).not.toHaveBeenCalled(); - }); - }); }); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index 43464b7f..c6c8d6cc 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -120,9 +120,6 @@ export interface StackIdentityHeaderProps { activeNode: Node | null; safeContainers: ContainerInfo[]; isRunning: boolean; - copiedDigest: string | null; - setCopiedDigest: React.Dispatch>; - copiedDigestTimerRef: React.MutableRefObject; can: ReturnType['can']; isAdmin: boolean; trivy: { available: boolean }; @@ -141,16 +138,13 @@ export interface StackIdentityHeaderProps { stackMuteActions?: ReturnType; } -// Breadcrumb + serif title + state pill + image ref + action bar. The action -// buttons grow to a 44px touch target below md without changing desktop. +// Breadcrumb + serif title + state pill + action bar. The action buttons grow +// to a 44px touch target below md without changing desktop. export function StackIdentityHeader({ stackName, activeNode, safeContainers, isRunning, - copiedDigest, - setCopiedDigest, - copiedDigestTimerRef, can, isAdmin, trivy, @@ -191,54 +185,6 @@ export function StackIdentityHeader({ ); })()} - {(() => { - const first = safeContainers[0]; - if (!first?.Image) return null; - const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : ''; - return ( -
- image · {first.Image} - {digest && first.ImageID && ( - <> - · - digest {digest} - - - - - - Copy digest - - - - )} - -
- ); - })()} {/* Action Bar: deploy and delete affordances render against their own backend permissions so a delete-only or deploy-only persona sees diff --git a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts index 81251fc2..835362ee 100644 --- a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts +++ b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import type { ContainerInfo } from '../EditorView'; export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode'; @@ -19,15 +19,6 @@ function readLogsMode(): LogsMode { export function useEditorViewState() { const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false); - const [copiedDigest, setCopiedDigest] = useState(null); - const copiedDigestTimerRef = useRef(null); - useEffect(() => { - return () => { - if (copiedDigestTimerRef.current !== null) { - window.clearTimeout(copiedDigestTimerRef.current); - } - }; - }, []); const [content, setContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); @@ -55,8 +46,6 @@ export function useEditorViewState() { return { stackMisconfigScanning, setStackMisconfigScanning, - copiedDigest, setCopiedDigest, - copiedDigestTimerRef, content, setContent, originalContent, setOriginalContent, composeEtag, setComposeEtag,