mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
4e54a9272d
Open the existing image detail sheet from each container card, between View logs and Monitor, so operators can inspect a container's image without leaving stack details.
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
import { ChevronLeft } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import ErrorBoundary from '../ErrorBoundary';
|
|
import StackAnatomyPanel from '../StackAnatomyPanel';
|
|
import { MobileComposeEditor } from './MobileComposeEditor';
|
|
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
|
|
import { RecoveryPanel } from './RecoveryPanel';
|
|
import { StackOperationBanner } from './StackOperationBanner';
|
|
import { retryHandlerFor } from './recovery-retry';
|
|
import type { EditorViewProps } from './EditorView';
|
|
|
|
const SEGMENTS = [
|
|
{ id: 'health', label: 'Health' },
|
|
{ id: 'logs', label: 'Logs' },
|
|
{ id: 'compose', label: 'Compose' },
|
|
] as const;
|
|
|
|
type Segment = (typeof SEGMENTS)[number]['id'];
|
|
|
|
// Full-screen stack detail for the mobile shell (below md). The desktop
|
|
// two-pane grid does not fit a phone, so the same identity header, container
|
|
// health, logs, and anatomy are reorganized into a tracked-mono segmented
|
|
// control. Logs is the default segment (first-read, without copying Dockge's
|
|
// layout). From the Compose segment, an editor with stack:edit can open the
|
|
// full-screen MobileComposeEditor for small, safe compose/.env edits.
|
|
export function MobileStackDetail(props: EditorViewProps) {
|
|
const {
|
|
stackName,
|
|
activeNode,
|
|
containers,
|
|
containerStats,
|
|
containerStatsError,
|
|
content,
|
|
envContent,
|
|
envExists,
|
|
envFiles,
|
|
selectedEnvFile,
|
|
isFileLoading,
|
|
gitSourcePendingMap,
|
|
notifications,
|
|
loadingAction,
|
|
stackMisconfigScanning,
|
|
can,
|
|
isAdmin,
|
|
trivy,
|
|
backupInfo,
|
|
logsMode,
|
|
activeTab,
|
|
editingCompose,
|
|
deployStack,
|
|
restartStack,
|
|
stopStack,
|
|
updateStack,
|
|
rollbackStack,
|
|
scanStackConfig,
|
|
requestSave,
|
|
requestSaveAndDeploy,
|
|
setContent,
|
|
setEnvContent,
|
|
changeEnvFile,
|
|
openLogViewer,
|
|
openBashModal,
|
|
onInspectImage,
|
|
onOpenMonitor,
|
|
onOpenServiceMonitor,
|
|
serviceAction,
|
|
effectiveServices = [],
|
|
serviceUpdateStatuses,
|
|
serviceUpdateInProgress,
|
|
onRequestServiceUpdate,
|
|
setLogsMode,
|
|
setActiveTab,
|
|
setGitSourceOpen,
|
|
requestDeleteStack,
|
|
requestTakeDownStack,
|
|
showTakeDown,
|
|
isSelfStack = false,
|
|
canSaveAndReapply = false,
|
|
onMobileBack,
|
|
onCloseEditor,
|
|
hasUnsavedChanges,
|
|
headerActions,
|
|
recoveryResult,
|
|
onRefreshState,
|
|
onDismissRecovery,
|
|
panelStartedAt,
|
|
stackMuteActions,
|
|
openComposeEditor,
|
|
} = props;
|
|
|
|
const [segment, setSegment] = useState<Segment>('logs');
|
|
|
|
const safeContainers = containers || [];
|
|
const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1;
|
|
const isRunning = safeContainers.some(c => c.State === 'running');
|
|
const canEditStack = can('stack:edit', 'stack', stackName, activeNode?.id);
|
|
|
|
// The writable editor layer renders only for an editor; a stale editingCompose
|
|
// while the user lacks stack:edit falls back to the read-only Compose segment.
|
|
if (editingCompose && canEditStack) {
|
|
return (
|
|
<ErrorBoundary>
|
|
<MobileComposeEditor
|
|
content={content}
|
|
envContent={envContent}
|
|
setContent={setContent}
|
|
setEnvContent={setEnvContent}
|
|
activeTab={activeTab}
|
|
setActiveTab={setActiveTab}
|
|
envExists={envExists}
|
|
envFiles={envFiles}
|
|
selectedEnvFile={selectedEnvFile}
|
|
changeEnvFile={changeEnvFile}
|
|
isFileLoading={isFileLoading}
|
|
loadingAction={loadingAction}
|
|
canEdit={canEditStack}
|
|
requestSave={requestSave}
|
|
requestSaveAndDeploy={requestSaveAndDeploy}
|
|
canSaveAndReapply={canSaveAndReapply}
|
|
onClose={onCloseEditor}
|
|
hasUnsavedChanges={hasUnsavedChanges}
|
|
/>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<div className="flex h-full min-h-0 flex-col">
|
|
{/* Detail header: back to list + identity + action bar */}
|
|
<div className="shrink-0 border-b border-hairline px-4 pb-3 pt-3">
|
|
<div className="mb-1 flex items-center justify-between gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onMobileBack}
|
|
aria-label="Back to stacks"
|
|
className="inline-flex min-h-11 items-center gap-1 pr-3 font-mono text-xs text-brand"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
|
|
Stacks
|
|
</button>
|
|
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
|
|
</div>
|
|
<StackIdentityHeader
|
|
stackName={stackName}
|
|
activeNode={activeNode}
|
|
safeContainers={safeContainers}
|
|
isRunning={isRunning}
|
|
can={can}
|
|
trivy={trivy}
|
|
backupInfo={backupInfo}
|
|
loadingAction={loadingAction}
|
|
stackMisconfigScanning={stackMisconfigScanning}
|
|
deployStack={deployStack}
|
|
restartStack={restartStack}
|
|
stopStack={stopStack}
|
|
updateStack={updateStack}
|
|
rollbackStack={rollbackStack}
|
|
scanStackConfig={scanStackConfig}
|
|
requestDeleteStack={requestDeleteStack}
|
|
requestTakeDownStack={requestTakeDownStack}
|
|
showTakeDown={showTakeDown}
|
|
isSelfStack={isSelfStack}
|
|
stackMuteActions={stackMuteActions}
|
|
onOpenMonitor={onOpenMonitor}
|
|
/>
|
|
</div>
|
|
|
|
<StackOperationBanner
|
|
stackName={stackName}
|
|
activeNode={activeNode}
|
|
panelStartedAt={panelStartedAt}
|
|
variant="card"
|
|
className="mx-4 mt-3 shrink-0"
|
|
/>
|
|
|
|
{recoveryResult && loadingAction == null && (
|
|
<div className="shrink-0 px-4 pt-3">
|
|
<RecoveryPanel
|
|
stackName={stackName}
|
|
result={recoveryResult}
|
|
activeNode={activeNode}
|
|
backupInfo={backupInfo}
|
|
canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)}
|
|
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
|
|
onRestart={restartStack}
|
|
onRollback={rollbackStack}
|
|
onRefreshState={onRefreshState}
|
|
onDismiss={onDismissRecovery}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Segmented control: Health · Logs · Compose */}
|
|
<div className="shrink-0 px-4 pt-3">
|
|
<div
|
|
role="tablist"
|
|
aria-label="Stack detail sections"
|
|
className="flex gap-1 rounded-lg border border-card-border bg-well p-1 shadow-[var(--shadow-well)]"
|
|
>
|
|
{SEGMENTS.map(seg => {
|
|
const on = seg.id === segment;
|
|
return (
|
|
<button
|
|
key={seg.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={on}
|
|
onClick={() => setSegment(seg.id)}
|
|
className={cn(
|
|
'flex-1 rounded-md py-2 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors',
|
|
on
|
|
? 'bg-card text-stat-value shadow-card-bevel'
|
|
: 'text-stat-subtitle hover:text-foreground',
|
|
)}
|
|
>
|
|
{seg.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active segment */}
|
|
<div className="flex flex-1 min-h-0 flex-col overflow-hidden p-4">
|
|
{segment === 'health' && (
|
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
<ContainersHealth
|
|
safeContainers={safeContainers}
|
|
containerStats={containerStats}
|
|
containerStatsError={containerStatsError}
|
|
isAdmin={isAdmin}
|
|
activeNode={activeNode}
|
|
openLogViewer={openLogViewer}
|
|
openBashModal={openBashModal}
|
|
onInspectImage={onInspectImage}
|
|
onOpenServiceMonitor={onOpenServiceMonitor}
|
|
serviceAction={serviceAction}
|
|
effectiveServices={effectiveServices}
|
|
serviceUpdateStatuses={serviceUpdateStatuses}
|
|
serviceUpdateInProgress={serviceUpdateInProgress}
|
|
onRequestServiceUpdate={onRequestServiceUpdate}
|
|
containersLoadStatus={props.containersLoadStatus}
|
|
containersLoadError={props.containersLoadError}
|
|
onRetryContainersLoad={props.onRetryContainersLoad}
|
|
syncStale={props.containersSyncStale}
|
|
onRetrySync={props.onRetrySync}
|
|
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
{segment === 'logs' && (
|
|
<StackLogsSection
|
|
stackName={stackName}
|
|
logsMode={logsMode}
|
|
setLogsMode={setLogsMode}
|
|
showServiceChips={isMultiContainerLayout}
|
|
/>
|
|
)}
|
|
{segment === 'compose' && (
|
|
<div className="min-h-0 flex-1">
|
|
<StackAnatomyPanel
|
|
stackName={stackName}
|
|
content={content}
|
|
envContent={envContent}
|
|
selectedEnvFile={selectedEnvFile}
|
|
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
|
|
onEditCompose={openComposeEditor}
|
|
onOpenGitSource={() => setGitSourceOpen(true)}
|
|
onApplyUpdate={() => { void updateStack(); }}
|
|
applying={loadingAction === 'update'}
|
|
canEdit={canEditStack}
|
|
notifications={notifications}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|