mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
fix(editor): remove misleading image line above stack actions (#1584)
Closes #1580. The Command Center header showed the first container's image directly above stack-wide Start/Stop/Update controls, which implied those buttons targeted one image. Remove the header image/digest row; per-container ImageSourceMenu on each row remains.
This commit is contained in:
@@ -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.
|
The top card on the left holds the stack's identity and primary controls.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/editor/command-center.png" alt="Command Center card with breadcrumb, stack title, running status pill, image and digest, action bar, and the plex container row with live stats" />
|
<img src="/images/editor/command-center.png" alt="Command Center card with breadcrumb, stack title, running status pill, action bar, and the plex container row with live stats" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
| Element | What it shows |
|
| Element | What it shows |
|
||||||
@@ -22,7 +22,6 @@ The top card on the left holds the stack's identity and primary controls.
|
|||||||
| **Breadcrumb** | `<node> › stacks › <stack>` rendered in caps. Confirms which node the stack lives on. |
|
| **Breadcrumb** | `<node> › stacks › <stack>` rendered in caps. Confirms which node the stack lives on. |
|
||||||
| **Stack title** | The stack name in display italic. |
|
| **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. |
|
| **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
|
### 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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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
|
### Service actions
|
||||||
|
|||||||
@@ -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.
|
Opening a stack puts identity and state front and center.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/stack-view/identity-header.png" alt="Stack identity header showing breadcrumb, title, state pill, image and digest, and action row" />
|
<img src="/images/stack-view/identity-header.png" alt="Stack identity header showing breadcrumb, title, state pill, and action row" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
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.
|
- **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:
|
- **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 · starting` (amber) during the Docker healthcheck start period.
|
||||||
- `running` (green) when no healthcheck is defined.
|
- `running` (green) when no healthcheck is defined.
|
||||||
- `exited` (red) when no containers are up.
|
- `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.
|
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
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
## Container health strip
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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.
|
- 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.
|
- 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.
|
- The logs panel can run in **Structured** mode or **Raw terminal** mode.
|
||||||
|
|||||||
@@ -87,8 +87,6 @@ export default function EditorLayout() {
|
|||||||
const editorState = useEditorViewState();
|
const editorState = useEditorViewState();
|
||||||
const {
|
const {
|
||||||
stackMisconfigScanning,
|
stackMisconfigScanning,
|
||||||
copiedDigest, setCopiedDigest,
|
|
||||||
copiedDigestTimerRef,
|
|
||||||
content, setContent,
|
content, setContent,
|
||||||
envContent, setEnvContent,
|
envContent, setEnvContent,
|
||||||
envExists,
|
envExists,
|
||||||
@@ -465,14 +463,12 @@ export default function EditorLayout() {
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
editingCompose={editingCompose}
|
editingCompose={editingCompose}
|
||||||
logsMode={logsMode}
|
logsMode={logsMode}
|
||||||
copiedDigest={copiedDigest}
|
|
||||||
loadingAction={loadingAction}
|
loadingAction={loadingAction}
|
||||||
stackMisconfigScanning={stackMisconfigScanning}
|
stackMisconfigScanning={stackMisconfigScanning}
|
||||||
can={can}
|
can={can}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
trivy={trivy}
|
trivy={trivy}
|
||||||
activeNode={activeNode}
|
activeNode={activeNode}
|
||||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
|
||||||
deployStack={stackActions.deployStack}
|
deployStack={stackActions.deployStack}
|
||||||
restartStack={stackActions.restartStack}
|
restartStack={stackActions.restartStack}
|
||||||
stopStack={stackActions.stopStack}
|
stopStack={stackActions.stopStack}
|
||||||
@@ -493,7 +489,6 @@ export default function EditorLayout() {
|
|||||||
setLogsMode={setLogsMode}
|
setLogsMode={setLogsMode}
|
||||||
setEditingCompose={setEditingCompose}
|
setEditingCompose={setEditingCompose}
|
||||||
setGitSourceOpen={setGitSourceOpen}
|
setGitSourceOpen={setGitSourceOpen}
|
||||||
setCopiedDigest={setCopiedDigest}
|
|
||||||
requestDeleteStack={stackActions.requestDeleteStack}
|
requestDeleteStack={stackActions.requestDeleteStack}
|
||||||
isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false}
|
isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false}
|
||||||
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
|
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export interface EditorViewProps {
|
|||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
editingCompose: boolean;
|
editingCompose: boolean;
|
||||||
logsMode: 'structured' | 'raw';
|
logsMode: 'structured' | 'raw';
|
||||||
copiedDigest: string | null;
|
|
||||||
loadingAction: StackAction | null;
|
loadingAction: StackAction | null;
|
||||||
stackMisconfigScanning: boolean;
|
stackMisconfigScanning: boolean;
|
||||||
|
|
||||||
@@ -152,9 +151,6 @@ export interface EditorViewProps {
|
|||||||
trivy: { available: boolean };
|
trivy: { available: boolean };
|
||||||
activeNode: Node | null;
|
activeNode: Node | null;
|
||||||
|
|
||||||
// Refs
|
|
||||||
copiedDigestTimerRef: React.MutableRefObject<number | null>;
|
|
||||||
|
|
||||||
// Stack actions
|
// Stack actions
|
||||||
deployStack: (e: React.MouseEvent) => Promise<void>;
|
deployStack: (e: React.MouseEvent) => Promise<void>;
|
||||||
restartStack: (e: React.MouseEvent) => Promise<void>;
|
restartStack: (e: React.MouseEvent) => Promise<void>;
|
||||||
@@ -185,7 +181,6 @@ export interface EditorViewProps {
|
|||||||
setLogsMode: (mode: 'structured' | 'raw') => void;
|
setLogsMode: (mode: 'structured' | 'raw') => void;
|
||||||
setEditingCompose: (open: boolean) => void;
|
setEditingCompose: (open: boolean) => void;
|
||||||
setGitSourceOpen: (open: boolean) => void;
|
setGitSourceOpen: (open: boolean) => void;
|
||||||
setCopiedDigest: React.Dispatch<React.SetStateAction<string | null>>;
|
|
||||||
|
|
||||||
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
|
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
|
||||||
requestDeleteStack: () => void;
|
requestDeleteStack: () => void;
|
||||||
@@ -242,14 +237,12 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
isEditing,
|
isEditing,
|
||||||
editingCompose,
|
editingCompose,
|
||||||
logsMode,
|
logsMode,
|
||||||
copiedDigest,
|
|
||||||
loadingAction,
|
loadingAction,
|
||||||
stackMisconfigScanning,
|
stackMisconfigScanning,
|
||||||
can,
|
can,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
trivy,
|
trivy,
|
||||||
activeNode,
|
activeNode,
|
||||||
copiedDigestTimerRef,
|
|
||||||
deployStack,
|
deployStack,
|
||||||
restartStack,
|
restartStack,
|
||||||
stopStack,
|
stopStack,
|
||||||
@@ -270,7 +263,6 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
setLogsMode,
|
setLogsMode,
|
||||||
setEditingCompose,
|
setEditingCompose,
|
||||||
setGitSourceOpen,
|
setGitSourceOpen,
|
||||||
setCopiedDigest,
|
|
||||||
requestDeleteStack,
|
requestDeleteStack,
|
||||||
isSelfStack,
|
isSelfStack,
|
||||||
recoveryResult,
|
recoveryResult,
|
||||||
@@ -385,9 +377,6 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
activeNode={activeNode}
|
activeNode={activeNode}
|
||||||
safeContainers={safeContainers}
|
safeContainers={safeContainers}
|
||||||
isRunning={isRunning}
|
isRunning={isRunning}
|
||||||
copiedDigest={copiedDigest}
|
|
||||||
setCopiedDigest={setCopiedDigest}
|
|
||||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
|
||||||
can={can}
|
can={can}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
trivy={trivy}
|
trivy={trivy}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
|||||||
isFileLoading: false,
|
isFileLoading: false,
|
||||||
gitSourcePendingMap: {},
|
gitSourcePendingMap: {},
|
||||||
notifications: [],
|
notifications: [],
|
||||||
copiedDigest: null,
|
|
||||||
loadingAction: null,
|
loadingAction: null,
|
||||||
stackMisconfigScanning: false,
|
stackMisconfigScanning: false,
|
||||||
can: () => true,
|
can: () => true,
|
||||||
@@ -57,7 +56,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
|||||||
logsMode: 'structured',
|
logsMode: 'structured',
|
||||||
activeTab: 'compose',
|
activeTab: 'compose',
|
||||||
editingCompose: false,
|
editingCompose: false,
|
||||||
copiedDigestTimerRef: { current: null },
|
|
||||||
deployStack: vi.fn(),
|
deployStack: vi.fn(),
|
||||||
restartStack: vi.fn(),
|
restartStack: vi.fn(),
|
||||||
stopStack: vi.fn(),
|
stopStack: vi.fn(),
|
||||||
@@ -76,7 +74,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
|||||||
setActiveTab: vi.fn(),
|
setActiveTab: vi.fn(),
|
||||||
setEditingCompose: vi.fn(),
|
setEditingCompose: vi.fn(),
|
||||||
setGitSourceOpen: vi.fn(),
|
setGitSourceOpen: vi.fn(),
|
||||||
setCopiedDigest: vi.fn(),
|
|
||||||
requestDeleteStack: vi.fn(),
|
requestDeleteStack: vi.fn(),
|
||||||
onMobileBack: vi.fn(),
|
onMobileBack: vi.fn(),
|
||||||
onCloseEditor: vi.fn(),
|
onCloseEditor: vi.fn(),
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
isFileLoading,
|
isFileLoading,
|
||||||
gitSourcePendingMap,
|
gitSourcePendingMap,
|
||||||
notifications,
|
notifications,
|
||||||
copiedDigest,
|
|
||||||
loadingAction,
|
loadingAction,
|
||||||
stackMisconfigScanning,
|
stackMisconfigScanning,
|
||||||
can,
|
can,
|
||||||
@@ -49,7 +48,6 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
logsMode,
|
logsMode,
|
||||||
activeTab,
|
activeTab,
|
||||||
editingCompose,
|
editingCompose,
|
||||||
copiedDigestTimerRef,
|
|
||||||
deployStack,
|
deployStack,
|
||||||
restartStack,
|
restartStack,
|
||||||
stopStack,
|
stopStack,
|
||||||
@@ -68,7 +66,6 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
setEditingCompose,
|
setEditingCompose,
|
||||||
setGitSourceOpen,
|
setGitSourceOpen,
|
||||||
setCopiedDigest,
|
|
||||||
requestDeleteStack,
|
requestDeleteStack,
|
||||||
isSelfStack = false,
|
isSelfStack = false,
|
||||||
onMobileBack,
|
onMobileBack,
|
||||||
@@ -138,9 +135,6 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
activeNode={activeNode}
|
activeNode={activeNode}
|
||||||
safeContainers={safeContainers}
|
safeContainers={safeContainers}
|
||||||
isRunning={isRunning}
|
isRunning={isRunning}
|
||||||
copiedDigest={copiedDigest}
|
|
||||||
setCopiedDigest={setCopiedDigest}
|
|
||||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
|
||||||
can={can}
|
can={can}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
trivy={trivy}
|
trivy={trivy}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
|||||||
backupInfo: { exists: false, timestamp: null },
|
backupInfo: { exists: false, timestamp: null },
|
||||||
gitSourcePendingMap: {},
|
gitSourcePendingMap: {},
|
||||||
notifications: [],
|
notifications: [],
|
||||||
copiedDigest: null,
|
|
||||||
loadingAction: null,
|
loadingAction: null,
|
||||||
stackMisconfigScanning: false,
|
stackMisconfigScanning: false,
|
||||||
activeTab: 'compose',
|
activeTab: 'compose',
|
||||||
@@ -55,7 +54,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
|||||||
can: () => true,
|
can: () => true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
trivy: { available: false },
|
trivy: { available: false },
|
||||||
copiedDigestTimerRef: { current: null },
|
|
||||||
deployStack: vi.fn(),
|
deployStack: vi.fn(),
|
||||||
restartStack: vi.fn(),
|
restartStack: vi.fn(),
|
||||||
stopStack: vi.fn(),
|
stopStack: vi.fn(),
|
||||||
@@ -76,7 +74,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
|||||||
setLogsMode: vi.fn(),
|
setLogsMode: vi.fn(),
|
||||||
setEditingCompose: vi.fn(),
|
setEditingCompose: vi.fn(),
|
||||||
setGitSourceOpen: vi.fn(),
|
setGitSourceOpen: vi.fn(),
|
||||||
setCopiedDigest: vi.fn(),
|
|
||||||
requestDeleteStack: vi.fn(),
|
requestDeleteStack: vi.fn(),
|
||||||
onRefreshState: vi.fn(),
|
onRefreshState: vi.fn(),
|
||||||
onDismissRecovery: vi.fn(),
|
onDismissRecovery: vi.fn(),
|
||||||
|
|||||||
@@ -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<ComponentProps<typeof StackIdentityHeader>> = {}) {
|
||||||
|
return render(
|
||||||
|
<StackIdentityHeader
|
||||||
|
stackName="plex"
|
||||||
|
activeNode={{ id: 1, name: 'local', type: 'local' } as never}
|
||||||
|
safeContainers={CONTAINERS}
|
||||||
|
isRunning
|
||||||
|
can={() => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,12 +35,6 @@ describe('useEditorViewState', () => {
|
|||||||
expect(result.current.gitSourcePendingMap).toEqual({});
|
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', () => {
|
it('defaults activeTab to compose', () => {
|
||||||
const { result } = renderHook(() => useEditorViewState());
|
const { result } = renderHook(() => useEditorViewState());
|
||||||
expect(result.current.activeTab).toBe('compose');
|
expect(result.current.activeTab).toBe('compose');
|
||||||
@@ -103,21 +97,4 @@ describe('useEditorViewState', () => {
|
|||||||
expect(window.localStorage.getItem(LOGS_MODE_STORAGE_KEY)).toBe('raw');
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,9 +120,6 @@ export interface StackIdentityHeaderProps {
|
|||||||
activeNode: Node | null;
|
activeNode: Node | null;
|
||||||
safeContainers: ContainerInfo[];
|
safeContainers: ContainerInfo[];
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
copiedDigest: string | null;
|
|
||||||
setCopiedDigest: React.Dispatch<React.SetStateAction<string | null>>;
|
|
||||||
copiedDigestTimerRef: React.MutableRefObject<number | null>;
|
|
||||||
can: ReturnType<typeof useAuth>['can'];
|
can: ReturnType<typeof useAuth>['can'];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
trivy: { available: boolean };
|
trivy: { available: boolean };
|
||||||
@@ -141,16 +138,13 @@ export interface StackIdentityHeaderProps {
|
|||||||
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breadcrumb + serif title + state pill + image ref + action bar. The action
|
// Breadcrumb + serif title + state pill + action bar. The action buttons grow
|
||||||
// buttons grow to a 44px touch target below md without changing desktop.
|
// to a 44px touch target below md without changing desktop.
|
||||||
export function StackIdentityHeader({
|
export function StackIdentityHeader({
|
||||||
stackName,
|
stackName,
|
||||||
activeNode,
|
activeNode,
|
||||||
safeContainers,
|
safeContainers,
|
||||||
isRunning,
|
isRunning,
|
||||||
copiedDigest,
|
|
||||||
setCopiedDigest,
|
|
||||||
copiedDigestTimerRef,
|
|
||||||
can,
|
can,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
trivy,
|
trivy,
|
||||||
@@ -191,54 +185,6 @@ export function StackIdentityHeader({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
|
||||||
const first = safeContainers[0];
|
|
||||||
if (!first?.Image) return null;
|
|
||||||
const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : '';
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
|
|
||||||
<span>image <span className="text-muted-foreground/60">·</span> <span className="text-foreground/90">{first.Image}</span></span>
|
|
||||||
{digest && first.ImageID && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground/60">·</span>
|
|
||||||
<span>digest <span className="text-foreground/90">{digest}</span></span>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
|
|
||||||
onClick={() => {
|
|
||||||
const id = first.ImageID as string;
|
|
||||||
void copyToClipboard(id).then(() => {
|
|
||||||
setCopiedDigest(id);
|
|
||||||
if (copiedDigestTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(copiedDigestTimerRef.current);
|
|
||||||
}
|
|
||||||
copiedDigestTimerRef.current = window.setTimeout(() => {
|
|
||||||
setCopiedDigest(prev => (prev === id ? null : prev));
|
|
||||||
copiedDigestTimerRef.current = null;
|
|
||||||
}, 1500);
|
|
||||||
}).catch(() => { /* clipboard unavailable */ });
|
|
||||||
}}
|
|
||||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
|
||||||
>
|
|
||||||
{copiedDigest === first.ImageID ? (
|
|
||||||
<Check className="h-3 w-3" strokeWidth={2} />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Copy digest</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ImageSourceMenu imageRef={first.Image} imageId={first.ImageID} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Action Bar: deploy and delete affordances render against their own
|
{/* Action Bar: deploy and delete affordances render against their own
|
||||||
backend permissions so a delete-only or deploy-only persona sees
|
backend permissions so a delete-only or deploy-only persona sees
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ContainerInfo } from '../EditorView';
|
import type { ContainerInfo } from '../EditorView';
|
||||||
|
|
||||||
export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode';
|
export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode';
|
||||||
@@ -19,15 +19,6 @@ function readLogsMode(): LogsMode {
|
|||||||
|
|
||||||
export function useEditorViewState() {
|
export function useEditorViewState() {
|
||||||
const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false);
|
const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false);
|
||||||
const [copiedDigest, setCopiedDigest] = useState<string | null>(null);
|
|
||||||
const copiedDigestTimerRef = useRef<number | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (copiedDigestTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(copiedDigestTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [content, setContent] = useState<string>('');
|
const [content, setContent] = useState<string>('');
|
||||||
const [originalContent, setOriginalContent] = useState<string>('');
|
const [originalContent, setOriginalContent] = useState<string>('');
|
||||||
@@ -55,8 +46,6 @@ export function useEditorViewState() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
stackMisconfigScanning, setStackMisconfigScanning,
|
stackMisconfigScanning, setStackMisconfigScanning,
|
||||||
copiedDigest, setCopiedDigest,
|
|
||||||
copiedDigestTimerRef,
|
|
||||||
content, setContent,
|
content, setContent,
|
||||||
originalContent, setOriginalContent,
|
originalContent, setOriginalContent,
|
||||||
composeEtag, setComposeEtag,
|
composeEtag, setComposeEtag,
|
||||||
|
|||||||
Reference in New Issue
Block a user