mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
fix(ui): flatten single-container Update onto the service row (#1759)q
* fix(ui): flatten single-container Update onto the service row On multi-service stacks, put Update/Rebuild on the container card (left of image source) when a service has one matching container, and keep the shared header only for multi-replica services. * fix(ui): show per-service Update only when an image update is confirmed Registry services were always showing Update because eligibility checked declaredImage/hasBuild only. Gate Update on a confirmed pending check so the button clears after a successful recheck; keep Rebuild for build-backed services.
This commit is contained in:
@@ -223,13 +223,17 @@ Each row includes:
|
||||
- **Meta line.** Uptime (`up 12 hours`), the Docker health status when defined (`healthy`, `unhealthy`, or `starting`), and the primary port mapping (`8989 → 8989/tcp`).
|
||||
- **Open link.** When the container publishes a port, the mapping itself is a link (`8989 → 8989/tcp ↗`) 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. Recognised multi-port apps open their web path automatically (for example, Plex opens `/web`).
|
||||
- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds.
|
||||
- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and (on single-service stacks) the per-container Start / Stop / Restart kebab.
|
||||
- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and the **Service actions** kebab on single-service stacks and single-container multi-service rows (multi-replica services keep the kebab on the shared header; see below).
|
||||
|
||||
### Multi-service stacks
|
||||
|
||||
When a stack declares more than one Compose service, each service gets its own header above its container cards. That header owns **Update service** or **Rebuild service** (when eligible), the update badge when a registry update is confirmed for that service, and **Start / Stop / Restart** for every replica of the service. Child container cards keep logs, shell, ports, and metrics only.
|
||||
When a stack declares more than one Compose service, each service with a single matching container renders as one flat container card. When a registry image update is confirmed for that service, the card shows **Update** immediately left of the image source links control (with an update badge). Build-backed services without a confirmed registry update show **Rebuild** in the same place. The **Service actions** kebab for Start / Stop / Restart stays on the card.
|
||||
|
||||
Build-backed services without a registry update show **Rebuild** wording and no update badge. The editor toolbar **Update** action still updates the entire stack.
|
||||
Services with multiple replicas keep a shared header above their nested container cards. That header owns the same **Update** / **Rebuild** rules, the update badge, and **Start / Stop / Restart** for every replica. Nested cards keep logs, shell, ports, and metrics only.
|
||||
|
||||
A declared service with no running containers shows a compact row with the service name, Update/Rebuild under the same rules, and the Service actions kebab (so you can still Start the service).
|
||||
|
||||
After a service update succeeds and the image check clears, the **Update** button disappears. The editor toolbar **Update** action still updates the entire stack at any time.
|
||||
|
||||
Single-service stacks keep the existing flat container layout; service headers do not appear.
|
||||
|
||||
@@ -425,7 +429,7 @@ Press `B` again or toggle the bulk mode button to leave bulk mode.
|
||||
|
||||
## Controlling a single service
|
||||
|
||||
Inside the stack detail view, each container card has quick actions (View logs, Monitor, Open bash when available) and a **Service actions** kebab on the right side. Use them to act on that service without touching the rest of the stack.
|
||||
Inside the stack detail view, container cards expose quick actions (View logs, Monitor, Open bash when available). On single-service stacks, single-container multi-service rows, and empty declared-service rows, a **Service actions** kebab sits on that row. On multi-replica services the kebab lives on the shared service header only, not on each replica card. Use these actions to act on that service without touching the rest of the stack.
|
||||
|
||||
- **Monitor**: opens the stack **Monitor** sheet with that Compose service preferred in the add forms. Shown only when the container reports a Compose service name.
|
||||
- **Restart service**: stops and starts all containers for that service.
|
||||
|
||||
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../Terminal', () => ({ default: () => null }));
|
||||
vi.mock('../../StructuredLogViewer', () => ({ default: () => null }));
|
||||
vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => null }));
|
||||
vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => <button type="button" aria-label="Image source links" /> }));
|
||||
|
||||
import { ContainersHealth, type ContainersHealthProps } from '../editor-view-blocks';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
@@ -302,12 +302,12 @@ describe('declared-service headers (multi-service only)', () => {
|
||||
expect(screen.getByRole('button', { name: 'View logs' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one header per declared service and groups containers under it', () => {
|
||||
it('flattens single-container multi-service rows without a declared-service header', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
@@ -319,19 +319,47 @@ describe('declared-service headers (multi-service only)', () => {
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: 'postgres:16' })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/running$/)).toBeNull();
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText('db')).toBeInTheDocument();
|
||||
// Flattened rows restore the per-container Service actions kebab.
|
||||
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
|
||||
// Per-container service menu is hidden inside a multi-service header group.
|
||||
expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(2);
|
||||
// Registry services without a confirmed update hide the Update button.
|
||||
expect(screen.queryByRole('button', { name: /^Update$/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the Update badge only for the service with a confirmed update', () => {
|
||||
it('hides Update for registry services without a confirmed pending update', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db' }),
|
||||
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: 'postgres:16' })]}
|
||||
serviceUpdateStatuses={[
|
||||
status({ service: 'web', hasUpdate: false }),
|
||||
status({ service: 'db', hasUpdate: false }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /^Update$/ })).toBeNull();
|
||||
expect(screen.queryByText('Update', { selector: 'span' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the Update badge and button on the flattened container card', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
@@ -347,7 +375,13 @@ describe('declared-service headers (multi-service only)', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Update', { selector: 'span' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
|
||||
const updateBtn = screen.getByRole('button', { name: /^Update$/ });
|
||||
expect(updateBtn).toBeInTheDocument();
|
||||
const imageSource = screen.getAllByLabelText('Image source links')[0];
|
||||
// Update sits left of ImageSourceMenu in the action row.
|
||||
expect(
|
||||
updateBtn.compareDocumentPosition(imageSource) & globalThis.Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses Rebuild wording with no badge for a build-backed service without a detected update', () => {
|
||||
@@ -355,8 +389,8 @@ describe('declared-service headers (multi-service only)', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db' }),
|
||||
makeContainer({ Id: 'w1', Names: ['/web'], Service: 'web' }),
|
||||
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
@@ -376,14 +410,15 @@ describe('declared-service headers (multi-service only)', () => {
|
||||
expect(onRequestServiceUpdate).toHaveBeenCalledWith('web', 'rebuild');
|
||||
});
|
||||
|
||||
it('moves Start/Stop/Restart to the declared-service header menu', async () => {
|
||||
it('keeps Start/Stop/Restart on the multi-replica service header menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
const serviceAction = vi.fn();
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Service: 'db', State: 'running' }),
|
||||
makeContainer({ Id: 'w1', Names: ['/web-1'], Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'w2', Names: ['/web-2'], Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
@@ -392,14 +427,80 @@ describe('declared-service headers (multi-service only)', () => {
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={serviceAction}
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db' })]}
|
||||
effectiveServices={[
|
||||
spec({ name: 'web', expectedReplicas: 2 }),
|
||||
spec({ name: 'db' }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// web keeps a header (2 containers); db is flattened (1 container).
|
||||
expect(screen.getByText(/2\/2 running/i)).toBeInTheDocument();
|
||||
// Only the web header kebab + the flattened db card kebab.
|
||||
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
|
||||
await user.click(screen.getAllByLabelText('Service actions')[0]);
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Restart service' }));
|
||||
expect(serviceAction).toHaveBeenCalledWith('restart', 'web');
|
||||
});
|
||||
|
||||
it('retains multi-replica header Update without leaking onto nested children', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'w1', Names: ['/web-1'], Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'w2', Names: ['/web-2'], Service: 'web', State: 'running' }),
|
||||
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
effectiveServices={[
|
||||
spec({ name: 'web', expectedReplicas: 2 }),
|
||||
spec({ name: 'db', declaredImage: null }),
|
||||
]}
|
||||
serviceUpdateStatuses={[status({ service: 'web', hasUpdate: true })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/2\/2 running/i)).toBeInTheDocument();
|
||||
// One Update badge + one Update button on the web header only (db ineligible).
|
||||
expect(screen.getAllByText('Update', { selector: 'span' })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('button', { name: /^Update$/ })).toHaveLength(1);
|
||||
// Nested web children hide the Service actions kebab (header owns it).
|
||||
// web header kebab + db flattened kebab = 2.
|
||||
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
|
||||
expect(screen.getAllByLabelText('Open bash shell')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders a compact Update row for a declared service with zero containers', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
safeContainers={[
|
||||
makeContainer({ Id: 'd1', Names: ['/db'], Service: 'db', State: 'running' }),
|
||||
]}
|
||||
containerStats={{}}
|
||||
containerStatsError={null}
|
||||
isAdmin
|
||||
activeNode={LOCAL_NODE}
|
||||
openLogViewer={vi.fn()}
|
||||
openBashModal={vi.fn()}
|
||||
serviceAction={vi.fn()}
|
||||
// db is flattened but not update-eligible so only the empty web row
|
||||
// owns the Update button under test.
|
||||
effectiveServices={[spec({ name: 'web' }), spec({ name: 'db', declaredImage: null })]}
|
||||
serviceUpdateStatuses={[status({ service: 'web', hasUpdate: true })]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText(/No containers running for this service/i)).toBeNull();
|
||||
expect(screen.queryByText(/running$/)).toBeNull();
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Update$/ })).toBeInTheDocument();
|
||||
// Compact empty row + flattened db card each have a Service actions kebab.
|
||||
expect(screen.getAllByLabelText('Service actions')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still surfaces summary strip and density toggle on multi-service stacks', () => {
|
||||
render(
|
||||
<ContainersHealth
|
||||
|
||||
@@ -307,6 +307,18 @@ export function StackIdentityHeader({
|
||||
);
|
||||
}
|
||||
|
||||
/** Optional per-card Update/Rebuild affordance for flattened single-container
|
||||
* multi-service rows. Pass only from that call site; leave undefined on
|
||||
* multi-replica nested children and the single-service flat path. */
|
||||
export interface ServiceUpdateAffordance {
|
||||
hasUpdate: boolean;
|
||||
mode: 'update' | 'rebuild';
|
||||
showUpdateAction: boolean;
|
||||
busy: boolean;
|
||||
replicaCopy: string;
|
||||
onRequest: () => void;
|
||||
}
|
||||
|
||||
export interface ContainersHealthProps {
|
||||
safeContainers: ContainerInfo[];
|
||||
containerStats: Record<string, ContainerStatsEntry>;
|
||||
@@ -464,10 +476,73 @@ export function ContainersHealth({
|
||||
) : null;
|
||||
|
||||
// One container card. `hideServiceMenu` drops the per-container
|
||||
// Start/Stop/Restart kebab on multi-service stacks; the declared-service
|
||||
// header above owns lifecycle actions. Child cards keep logs, shell, ports,
|
||||
// and metrics only.
|
||||
const renderContainerCard = (container: ContainerInfo, hideServiceMenu: boolean) => {
|
||||
// Start/Stop/Restart kebab on multi-replica nested children; the
|
||||
// declared-service header above owns lifecycle actions there. Flattened
|
||||
// single-container multi-service rows pass updateAffordance and keep the
|
||||
// kebab (`hideServiceMenu=false`). Single-service flat rows leave
|
||||
// updateAffordance undefined.
|
||||
const renderServiceUpdateButton = (affordance: ServiceUpdateAffordance) => {
|
||||
if (!affordance.showUpdateAction) return null;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md px-2 max-md:h-11"
|
||||
onClick={affordance.onRequest}
|
||||
disabled={affordance.busy}
|
||||
>
|
||||
<CloudDownload className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
{affordance.busy
|
||||
? (affordance.mode === 'rebuild' ? 'Rebuilding...' : 'Updating...')
|
||||
: (affordance.mode === 'rebuild' ? 'Rebuild' : 'Update')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{affordance.replicaCopy}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderServiceLifecycleMenu = (serviceName: string, isServiceActive: boolean) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isServiceActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', serviceName)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', serviceName)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', serviceName)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const renderContainerCard = (
|
||||
container: ContainerInfo,
|
||||
hideServiceMenu: boolean,
|
||||
updateAffordance?: ServiceUpdateAffordance,
|
||||
) => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
let mainPortProto: string | undefined;
|
||||
@@ -517,7 +592,14 @@ export function ContainersHealth({
|
||||
{badgeGlyph}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
|
||||
{updateAffordance?.hasUpdate && (
|
||||
<span className="shrink-0 rounded-full border border-brand/30 bg-brand/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-brand">
|
||||
Update
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
|
||||
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
|
||||
@@ -563,6 +645,7 @@ export function ContainersHealth({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{updateAffordance ? renderServiceUpdateButton(updateAffordance) : null}
|
||||
<ImageSourceMenu
|
||||
imageRef={container.Image}
|
||||
imageId={container.ImageID}
|
||||
@@ -623,34 +706,10 @@ export function ContainersHealth({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!hideServiceMenu && container.Service && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
renderServiceLifecycleMenu(
|
||||
container.Service,
|
||||
isActive,
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -750,13 +809,63 @@ export function ContainersHealth({
|
||||
const busy = serviceUpdateInProgress?.service === spec.name;
|
||||
const hasUpdate = status ? isConfirmedServiceUpdate(status) : false;
|
||||
const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update';
|
||||
const showUpdateAction = spec.declaredImage !== null || spec.hasBuild;
|
||||
// Registry Update only when a check confirmed a pending
|
||||
// image update (clears after a successful recheck). Rebuild
|
||||
// stays available for build-backed services without one.
|
||||
// Stack-level Update in the identity header remains the
|
||||
// always-on full-stack pull path.
|
||||
const showUpdateAction = hasUpdate || spec.hasBuild;
|
||||
const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused');
|
||||
const runningCount = group.filter(c => c.State === 'running').length;
|
||||
const replicaWord = spec.expectedReplicas === 1 ? 'replica' : 'replicas';
|
||||
const replicaCopy = mode === 'rebuild'
|
||||
? `Rebuilds all ${spec.expectedReplicas} ${replicaWord}`
|
||||
: `Updates all ${spec.expectedReplicas} ${replicaWord}`;
|
||||
const updateAffordance: ServiceUpdateAffordance = {
|
||||
hasUpdate,
|
||||
mode,
|
||||
showUpdateAction,
|
||||
busy,
|
||||
replicaCopy,
|
||||
onRequest: () => onRequestServiceUpdate?.(spec.name, mode),
|
||||
};
|
||||
|
||||
// Single-container declared service: one flat card with
|
||||
// Update left of ImageSourceMenu and the lifecycle kebab.
|
||||
if (group.length === 1) {
|
||||
return (
|
||||
<div key={spec.name}>
|
||||
{renderContainerCard(group[0], false, updateAffordance)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Zero containers: compact row (name + Update + kebab),
|
||||
// not renderContainerCard (no ContainerInfo).
|
||||
if (group.length === 0) {
|
||||
return (
|
||||
<div
|
||||
key={spec.name}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-card-border bg-muted/40 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-sm font-medium text-foreground">{spec.name}</span>
|
||||
{hasUpdate && (
|
||||
<span className="rounded-full border border-brand/30 bg-brand/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-brand">
|
||||
Update
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{renderServiceUpdateButton(updateAffordance)}
|
||||
{renderServiceLifecycleMenu(spec.name, false)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-replica: keep header + nested children (no
|
||||
// updateAffordance on child cards).
|
||||
return (
|
||||
<div key={spec.name} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border bg-muted/40 px-3 py-2">
|
||||
@@ -772,67 +881,13 @@ export function ContainersHealth({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showUpdateAction && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 rounded-md px-2 max-md:h-11"
|
||||
onClick={() => onRequestServiceUpdate?.(spec.name, mode)}
|
||||
disabled={busy}
|
||||
>
|
||||
<CloudDownload className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
{busy
|
||||
? (mode === 'rebuild' ? 'Rebuilding...' : 'Updating...')
|
||||
: (mode === 'rebuild' ? 'Rebuild' : 'Update')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{replicaCopy}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isServiceActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', spec.name)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', spec.name)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', spec.name)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{renderServiceUpdateButton(updateAffordance)}
|
||||
{renderServiceLifecycleMenu(spec.name, isServiceActive)}
|
||||
</div>
|
||||
</div>
|
||||
{group.length > 0 ? (
|
||||
<div className="ml-2 flex flex-col gap-2 border-l border-hairline pl-3">
|
||||
{group.map(container => renderContainerCard(container, true))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-2 pl-3 font-mono text-xs text-muted-foreground">
|
||||
No containers running for this service.
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-2 flex flex-col gap-2 border-l border-hairline pl-3">
|
||||
{group.map(container => renderContainerCard(container, true))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -844,6 +899,7 @@ export function ContainersHealth({
|
||||
{safeContainers.map(container => renderContainerCard(container, false))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user