Files
sencho/frontend/src/components/EditorLayout/MobileStackDetail.tsx
T
Anso a89498ae5b fix(ui): hide log service chips on single-service stacks (#1689)
Service chips only differentiate multi-service or multi-container log streams. Gate rendering with the same layout criterion already used in stack details, while keeping parsed prefixes and download attribution intact.
2026-07-23 20:22:57 -04:00

274 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,
serviceAction,
effectiveServices = [],
serviceUpdateStatuses,
serviceUpdateInProgress,
onRequestServiceUpdate,
setLogsMode,
setActiveTab,
setGitSourceOpen,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack = 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);
// 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}
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}
isAdmin={isAdmin}
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}
/>
</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)}
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}
serviceAction={serviceAction}
effectiveServices={effectiveServices}
serviceUpdateStatuses={serviceUpdateStatuses}
serviceUpdateInProgress={serviceUpdateInProgress}
onRequestServiceUpdate={onRequestServiceUpdate}
containersLoadStatus={props.containersLoadStatus}
containersLoadError={props.containersLoadError}
onRetryContainersLoad={props.onRetryContainersLoad}
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>
);
}