mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +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:
@@ -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