mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
ba09e6f69e
* fix: resolve the root .env at deploy and render time for Git context-dir stacks A Git multi-file source with a context dir set --project-directory to that dir, so Docker Compose looked for .env there and missed the root .env Sencho writes. Validation already passed the root .env with --env-file, so a stack could validate with one effective config but deploy or render another. Add authoredComposeEnvFileArgs, which appends --env-file <stackDir>/.env when the applied deploy spec has a context dir and a root .env exists, and wire it into the deploy/update, image-scan, render, and container-listing compose invocations so they all resolve env from the same file the validator used. A non-ENOENT access error surfaces instead of silently dropping the flag. * fix: base multi-file Git dossier and doc-drift on the effective Compose model The Stack Dossier and its documentation-drift check parsed only the stored root compose file. For a multi-file Git source, services, ports, networks, or volumes that an override file adds were invisible, so the dossier showed incomplete facts and doc-drift could falsely warn that a documented port is unpublished when an override actually publishes it. Add a secret-safe GET /stacks/:name/effective-anatomy that renders the merged effective model and extracts only structural facts (services, ports, volumes, networks, restart), never env, label, or command values. StackAnatomyPanel fetches it for multi-file Git stacks and feeds those facts into the dossier and doc-drift, falling back to the root-only parse for single-file or non-git stacks and whenever the render is unavailable. * fix: add an inline path-injection barrier to the Git env-file resolver CodeQL js/path-injection flagged the fs.access in authoredComposeEnvFileArgs because the env path derives from the route-supplied stack name and the only containment check lived in the callers, not at the sink. Resolve the stack dir against the compose base and assert containment with startsWith inline, then derive the .env path from the validated dir, mirroring the existing inline guards in renderConfig and validateCompose. Valid stack names are unaffected; a name that escapes the base now yields no --env-file. * test: stabilize the dossier doc-drift e2e against the dossier-load race The first assertion filled the access_urls field as soon as the Dossier panel was visible, but the panel's GET /stacks/:name/dossier resolves by overwriting the fields from the server (empty access_urls) and only then flips the doc-drift gate on. When the GET landed after the fill, it clobbered the typed value and the warning never rendered, so the test failed intermittently under CI timing. Wait for that GET to land before typing, mirroring the spec's openStack helper.
605 lines
26 KiB
TypeScript
605 lines
26 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
|
|
import { Button } from './ui/button';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
|
import { ScrollableTabRow } from './ui/ScrollableTabRow';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { cn } from '@/lib/utils';
|
|
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
|
import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
|
|
import { buildServiceUrl } from '@/lib/serviceUrl';
|
|
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
|
import StackDossierPanel from './stack/StackDossierPanel';
|
|
import DriftPanel from './stack/DriftPanel';
|
|
import PreflightPanel from './stack/PreflightPanel';
|
|
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import type { NotificationItem } from '@/components/dashboard/types';
|
|
|
|
interface StackAnatomyPanelProps {
|
|
stackName: string;
|
|
content: string;
|
|
envContent: string;
|
|
selectedEnvFile: string;
|
|
gitSourcePending: boolean;
|
|
onEditCompose: () => void;
|
|
onOpenGitSource: () => void;
|
|
onApplyUpdate: () => void;
|
|
onOpenFiles?: () => void;
|
|
canEdit: boolean;
|
|
applying?: boolean;
|
|
notifications?: NotificationItem[];
|
|
}
|
|
|
|
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
|
|
|
interface UpdatePreviewSummary {
|
|
has_update: boolean;
|
|
primary_image: string | null;
|
|
current_tag: string | null;
|
|
next_tag: string | null;
|
|
semver_bump: SemverBump;
|
|
blocked: boolean;
|
|
blocked_reason: string | null;
|
|
}
|
|
|
|
interface UpdatePreview {
|
|
summary: UpdatePreviewSummary;
|
|
changelog: string | null;
|
|
}
|
|
|
|
/** Secret-safe effective facts from GET /stacks/:name/effective-anatomy. */
|
|
interface EffectiveAnatomyFacts {
|
|
services: string[];
|
|
ports: Record<string, PortRow[]>;
|
|
volumes: Record<string, VolumeRow[]>;
|
|
restart: string | null;
|
|
networks: string[];
|
|
}
|
|
|
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="grid grid-cols-[72px_1fr] gap-3 border-t border-muted py-2 first:border-t-0">
|
|
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle pt-0.5">{label}</span>
|
|
<div className="min-w-0 text-[12px] text-foreground/90">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function StackAnatomyPanel({
|
|
stackName,
|
|
content,
|
|
envContent,
|
|
selectedEnvFile,
|
|
gitSourcePending,
|
|
onEditCompose,
|
|
onOpenGitSource,
|
|
onApplyUpdate,
|
|
onOpenFiles,
|
|
canEdit,
|
|
applying = false,
|
|
notifications,
|
|
}: StackAnatomyPanelProps) {
|
|
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
|
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
|
|
const missingVars = useMemo(() => {
|
|
if (!anatomy) return [];
|
|
return anatomy.referencedVars.filter(v => !envKeys.has(v));
|
|
}, [anatomy, envKeys]);
|
|
|
|
const envVarCount = envKeys.size;
|
|
|
|
const { hasCapability, activeNode } = useNodes();
|
|
const doctorEnabled = hasCapability('compose-doctor');
|
|
const networkingEnabled = hasCapability('compose-networking');
|
|
|
|
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null);
|
|
// Merged effective facts (services/ports/volumes/networks/restart) for a
|
|
// multi-file Git stack, fetched from the backend's rendered model so the Dossier
|
|
// and its doc-drift reflect every override file. Null for single-file / non-git
|
|
// stacks and whenever the render is unavailable, where the root-only parse stands.
|
|
const [effectiveAnatomy, setEffectiveAnatomy] = useState<({ stack: string } & EffectiveAnatomyFacts) | null>(null);
|
|
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
|
|
// Last preflight severity, used only to dot the Doctor tab. Radix mounts the
|
|
// active tab content lazily, so the badge cannot come from PreflightPanel; the
|
|
// parent reads the stored run once per stack/node change.
|
|
const [preflightSeverity, setPreflightSeverity] = useState<string | null>(null);
|
|
const [scanStatus, setScanStatus] = useState<{
|
|
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
|
|
attemptedAt?: number;
|
|
errorMessage?: string | null;
|
|
} | null>(null);
|
|
|
|
// Best-effort badge: read the last stored preflight severity to dot the tab.
|
|
// Skipped when the active node does not advertise the capability.
|
|
useEffect(() => {
|
|
// The dot and tab are gated on doctorEnabled, so a stale severity is never
|
|
// shown; no synchronous reset needed when the capability is absent.
|
|
if (!doctorEnabled) return;
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/preflight`);
|
|
if (cancelled || !res.ok) return;
|
|
const data = await res.json();
|
|
if (!cancelled) setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null);
|
|
} catch {
|
|
if (!cancelled) setPreflightSeverity(null);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [stackName, activeNode?.id, doctorEnabled]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const run = async () => {
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/git-source`);
|
|
if (cancelled) return;
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// An unlinked stack answers 200 { linked: false }; only render the
|
|
// badge when an actual source is attached.
|
|
if (data && data.linked === false) {
|
|
setGitSource(null);
|
|
} else {
|
|
// More than one configured compose path means override files merge into
|
|
// the deployed model, so the dossier must read the effective render.
|
|
const multiFile = Array.isArray(data.compose_paths) && data.compose_paths.length > 1;
|
|
setGitSource({
|
|
stack: stackName,
|
|
info: { repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path },
|
|
multiFile,
|
|
});
|
|
}
|
|
} else {
|
|
setGitSource(null);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setGitSource(null);
|
|
}
|
|
};
|
|
void run();
|
|
return () => { cancelled = true; };
|
|
}, [stackName]);
|
|
|
|
// Multi-file Git stacks deploy a merged model, so the dossier reads the backend's
|
|
// rendered effective facts instead of the root compose alone.
|
|
useEffect(() => {
|
|
// Single-file / non-git stacks keep the root-only parse and skip the fetch.
|
|
// Any tagged result left from a previous stack is ignored downstream by the
|
|
// stack-name guard, so there is no need to clear state synchronously here.
|
|
if (!(gitSource?.stack === stackName && gitSource.multiFile)) return;
|
|
let cancelled = false;
|
|
const run = async () => {
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/effective-anatomy`);
|
|
if (cancelled) return;
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Adopt the merged facts only when the model actually rendered; on a render
|
|
// error keep the root-only parse so the dossier never shows an empty summary.
|
|
setEffectiveAnatomy(data && data.renderable ? {
|
|
stack: stackName,
|
|
services: Array.isArray(data.services) ? data.services : [],
|
|
ports: data.ports ?? {},
|
|
volumes: data.volumes ?? {},
|
|
restart: data.restart ?? null,
|
|
networks: Array.isArray(data.networks) ? data.networks : [],
|
|
} : null);
|
|
} else {
|
|
setEffectiveAnatomy(null);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setEffectiveAnatomy(null);
|
|
}
|
|
};
|
|
void run();
|
|
return () => { cancelled = true; };
|
|
}, [stackName, activeNode?.id, gitSource]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const run = async () => {
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
|
|
if (cancelled) return;
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setUpdatePreview(data);
|
|
} else {
|
|
setUpdatePreview(null);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setUpdatePreview(null);
|
|
}
|
|
};
|
|
void run();
|
|
return () => { cancelled = true; };
|
|
}, [stackName]);
|
|
|
|
// When an apply for the current stack finishes (applying true -> false on the same
|
|
// stackName), re-check the preview: a landed update clears has_update so the banner
|
|
// unmounts; if it did not land, or the re-check itself fails, the banner stays.
|
|
// Tracking stackName alongside applying avoids treating a stack switch made while the
|
|
// first stack is still applying as a completion for the newly selected stack.
|
|
const prevApplyRef = useRef({ applying, stackName });
|
|
useEffect(() => {
|
|
const prev = prevApplyRef.current;
|
|
const finishedApplying = prev.applying && !applying && prev.stackName === stackName;
|
|
prevApplyRef.current = { applying, stackName };
|
|
if (!finishedApplying) return;
|
|
let cancelled = false;
|
|
const run = async () => {
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/update-preview`);
|
|
if (cancelled) return;
|
|
if (!res.ok) {
|
|
// Re-check failed: keep the banner already shown rather than hiding a
|
|
// possibly-still-pending update. The apply action reports its own outcome.
|
|
console.error(`[StackAnatomyPanel] update-preview re-check returned ${res.status}; keeping the existing banner`);
|
|
return;
|
|
}
|
|
const data = await res.json();
|
|
if (!cancelled) setUpdatePreview(data);
|
|
} catch (err) {
|
|
console.error('[StackAnatomyPanel] update-preview re-check failed:', err);
|
|
}
|
|
};
|
|
void run();
|
|
return () => { cancelled = true; };
|
|
}, [applying, stackName]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const run = async () => {
|
|
try {
|
|
const res = await apiFetch(`/stacks/${stackName}/scan-status`);
|
|
if (cancelled) return;
|
|
if (res.ok) {
|
|
setScanStatus(await res.json());
|
|
} else {
|
|
setScanStatus(null);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setScanStatus(null);
|
|
}
|
|
};
|
|
void run();
|
|
return () => { cancelled = true; };
|
|
}, [stackName]);
|
|
|
|
const networkName = anatomy && anatomy.networks.length > 0
|
|
? anatomy.networks[0]
|
|
: `${stackName}_default`;
|
|
const firstEnvFile = anatomy?.envFiles[0] ?? selectedEnvFile ?? null;
|
|
// Only treat the fetched source as current when it belongs to the selected stack, so a
|
|
// slow /git-source response for a previously selected stack cannot render or be exported here.
|
|
const activeGitSource = gitSource?.stack === stackName ? gitSource.info : null;
|
|
const primaryHostPort = useMemo(
|
|
() => (anatomy ? primaryPublishedHostPort(anatomy.ports) : null),
|
|
[anatomy],
|
|
);
|
|
const primaryServiceUrl = useMemo(
|
|
() => (primaryHostPort !== null ? buildServiceUrl({ node: activeNode, publicPort: primaryHostPort }) : null),
|
|
[primaryHostPort, activeNode],
|
|
);
|
|
|
|
// Assembled facts for this stack, passed to the Dossier tab for its read-only
|
|
// summary and Markdown export. Null until compose parses.
|
|
const anatomyInput = useMemo<AnatomyMarkdownInput | null>(() => {
|
|
// Prefer the merged effective facts for multi-file Git stacks so the dossier and
|
|
// its doc-drift reflect every override file; fall back to the root-only parse.
|
|
// Env-derived fields (count, missing vars, env file) always come from the raw
|
|
// parse, which reads the unresolved `${VAR}` references the render has substituted.
|
|
// `anatomy` already carries the same structural fields (plus env-only extras we
|
|
// read separately below), so the raw parse stands in directly when there are no
|
|
// effective facts for this stack.
|
|
const activeEffective = effectiveAnatomy?.stack === stackName ? effectiveAnatomy : null;
|
|
const structural = activeEffective ?? anatomy;
|
|
if (!structural) return null;
|
|
return {
|
|
stackName,
|
|
services: structural.services,
|
|
ports: structural.ports,
|
|
volumes: structural.volumes,
|
|
restart: structural.restart,
|
|
envFile: firstEnvFile,
|
|
envVarCount,
|
|
missingVars,
|
|
networkName: structural.networks.length > 0 ? structural.networks[0] : `${stackName}_default`,
|
|
gitSource: activeGitSource ? formatGitSource(activeGitSource) : null,
|
|
};
|
|
}, [effectiveAnatomy, anatomy, stackName, firstEnvFile, envVarCount, missingVars, activeGitSource]);
|
|
|
|
const bump = updatePreview?.summary.semver_bump ?? 'none';
|
|
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
|
const blocked = Boolean(updatePreview?.summary.blocked);
|
|
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
|
|
? 'danger'
|
|
: bump === 'minor' ? 'warn' : 'ok';
|
|
const bannerTone = bannerSeverity === 'danger'
|
|
? 'border-destructive/40 bg-destructive/[0.06] text-destructive'
|
|
: bannerSeverity === 'warn'
|
|
? 'border-warning/40 bg-warning/[0.06] text-warning'
|
|
: 'border-success/40 bg-success/[0.06] text-success';
|
|
const applyBtnTone = bannerSeverity === 'danger'
|
|
? 'border-destructive/40 text-destructive hover:bg-destructive/10'
|
|
: bannerSeverity === 'warn'
|
|
? 'border-warning/40 text-warning hover:bg-warning/10'
|
|
: 'border-success/40 text-success hover:bg-success/10';
|
|
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
|
|
const bannerLeadIn = blocked
|
|
? 'review required'
|
|
: bump === 'patch'
|
|
? 'safe to apply'
|
|
: bump === 'minor'
|
|
? 'review recommended'
|
|
: bump === 'major'
|
|
? 'breaking changes possible'
|
|
: '';
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
|
<Tabs defaultValue="anatomy" className="flex flex-col h-full min-h-0">
|
|
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
|
|
<ScrollableTabRow surface="card" wrapperClassName="min-w-0 flex-1">
|
|
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
|
|
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
|
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
|
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
|
|
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
|
|
{networkingEnabled && (
|
|
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Networking</TabsTrigger>
|
|
)}
|
|
{doctorEnabled && (
|
|
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
|
|
<span className="inline-flex items-center gap-1">
|
|
Doctor
|
|
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && (
|
|
<span
|
|
data-testid="doctor-tab-dot"
|
|
className={cn('h-1.5 w-1.5 rounded-full', preflightSeverity === 'blocker' ? 'bg-destructive' : 'bg-warning')}
|
|
/>
|
|
)}
|
|
</span>
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
</ScrollableTabRow>
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
{onOpenFiles && (
|
|
<button
|
|
type="button"
|
|
data-testid="anatomy-files-btn"
|
|
onClick={onOpenFiles}
|
|
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
|
>
|
|
<FolderOpen className="h-3 w-3" strokeWidth={1.5} />
|
|
files
|
|
</button>
|
|
)}
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
onClick={onEditCompose}
|
|
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
|
>
|
|
<Pencil className="h-3 w-3" strokeWidth={1.5} />
|
|
edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
|
|
<StackActivityTimeline stackName={stackName} liveEvents={notifications?.filter(n => n.stack_name === stackName)} />
|
|
</TabsContent>
|
|
<TabsContent value="anatomy" className="flex flex-col flex-1 min-h-0 mt-0">
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-3">
|
|
{!anatomy ? (
|
|
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Unable to parse compose.yaml.</div>
|
|
) : (
|
|
<>
|
|
<Row label="services">
|
|
{anatomy.services.length === 0 ? (
|
|
<span className="text-stat-subtitle">none defined</span>
|
|
) : (
|
|
<div className="flex flex-wrap gap-1">
|
|
{anatomy.services.map(s => (
|
|
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">
|
|
{s}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Row>
|
|
<Row label="ports">
|
|
{Object.keys(anatomy.ports).length === 0 ? (
|
|
<span className="text-stat-subtitle">none</span>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
|
{Object.entries(anatomy.ports).flatMap(([svc, rows]) =>
|
|
rows.map((r, i) => (
|
|
<div key={`${svc}-${i}`} className="flex items-center gap-1.5">
|
|
{anatomy.services.length > 1 && (
|
|
<span className="text-stat-subtitle">{svc}</span>
|
|
)}
|
|
<span className="font-semibold text-foreground">{r.host}</span>
|
|
<span className="text-stat-subtitle">→</span>
|
|
<span>{r.container}/{r.proto}</span>
|
|
</div>
|
|
)),
|
|
)}
|
|
</div>
|
|
)}
|
|
</Row>
|
|
<Row label="volumes">
|
|
{Object.keys(anatomy.volumes).length === 0 ? (
|
|
<span className="text-stat-subtitle">none</span>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
|
{Object.entries(anatomy.volumes).flatMap(([svc, rows]) =>
|
|
rows.map((r, i) => (
|
|
<div key={`${svc}-${i}`} className="flex items-center gap-1.5 min-w-0">
|
|
{anatomy.services.length > 1 && (
|
|
<span className="text-stat-subtitle shrink-0">{svc}</span>
|
|
)}
|
|
<span className="truncate text-foreground/90">{r.host}</span>
|
|
<span className="text-stat-subtitle shrink-0">→</span>
|
|
<span className="truncate">{r.container}</span>
|
|
</div>
|
|
)),
|
|
)}
|
|
</div>
|
|
)}
|
|
</Row>
|
|
<Row label="restart">
|
|
<span className="font-mono text-[11px]">{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
|
|
</Row>
|
|
<Row label="env_file">
|
|
{!firstEnvFile ? (
|
|
<span className="text-stat-subtitle">none</span>
|
|
) : (
|
|
<div className="flex flex-col gap-0.5">
|
|
<div className="font-mono text-[11px]">
|
|
<span className="text-foreground/90">{firstEnvFile}</span>
|
|
<span className="text-stat-subtitle"> · {envVarCount} var{envVarCount === 1 ? '' : 's'}</span>
|
|
</div>
|
|
{missingVars.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 font-mono text-[11px] text-destructive">
|
|
{missingVars.map(v => (
|
|
<span key={v}>{'${'}{v}{'}'} missing</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Row>
|
|
<Row label="network">
|
|
<span className="font-mono text-[11px]">{networkName} <span className="text-stat-subtitle">· bridge</span></span>
|
|
</Row>
|
|
<Row label="source">
|
|
<button
|
|
type="button"
|
|
onClick={onOpenGitSource}
|
|
aria-label="Git Source"
|
|
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-left hover:text-brand transition-colors"
|
|
>
|
|
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
|
|
{activeGitSource ? (
|
|
<span className="truncate">git <span className="text-stat-subtitle">·</span> {formatGitSource(activeGitSource)}</span>
|
|
) : (
|
|
<span>local</span>
|
|
)}
|
|
{gitSourcePending && (
|
|
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-brand animate-pulse" />
|
|
)}
|
|
</button>
|
|
</Row>
|
|
</>
|
|
)}
|
|
{hasUpdate && updatePreview && (
|
|
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="font-mono text-[11px] uppercase tracking-wide">
|
|
Update available
|
|
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
|
|
<span className="text-foreground">
|
|
{' · '}
|
|
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
|
|
{' -> '}
|
|
<span className="text-foreground font-semibold">{updatePreview.summary.next_tag}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 font-mono text-[11px] text-foreground/80 leading-relaxed">
|
|
{[
|
|
bumpLabel,
|
|
bannerLeadIn,
|
|
updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '',
|
|
].filter(Boolean).join(' · ')}
|
|
</div>
|
|
{blocked && updatePreview.summary.blocked_reason && (
|
|
<div className="mt-1 font-mono text-[10px] text-destructive">{updatePreview.summary.blocked_reason}</div>
|
|
)}
|
|
</div>
|
|
{canEdit && !blocked && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={applying}
|
|
className={cn('shrink-0 h-7 gap-1', applyBtnTone)}
|
|
onClick={onApplyUpdate}
|
|
>
|
|
<Rocket className={cn('h-3 w-3', applying && 'animate-pulse')} strokeWidth={1.5} />
|
|
{applying ? 'applying...' : 'apply'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
|
|
<div
|
|
className="mx-3 my-2 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/[0.06] px-2 py-1.5 text-[11px] text-warning"
|
|
role="status"
|
|
title={scanStatus.errorMessage ?? undefined}
|
|
>
|
|
<span className="font-mono text-[9px] uppercase tracking-wide shrink-0 mt-0.5">scan</span>
|
|
<span className="flex-1">
|
|
{scanStatus.status === 'failed' && 'Last post-deploy scan failed.'}
|
|
{scanStatus.status === 'partial' && 'Last post-deploy scan partially failed.'}
|
|
{scanStatus.status === 'skipped' && 'Post-deploy scan did not run.'}
|
|
{scanStatus.errorMessage ? ` ${scanStatus.errorMessage}` : ''}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{anatomy && anatomy.services.length > 0 && (
|
|
<div className="border-t border-muted px-3 py-2 flex items-center justify-between">
|
|
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">
|
|
{Object.keys(anatomy.ports).length > 0 ? 'exposed' : 'no ports'}
|
|
</span>
|
|
{primaryHostPort !== null && (
|
|
primaryServiceUrl ? (
|
|
<a
|
|
href={primaryServiceUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 font-mono text-[10px] text-stat-subtitle hover:text-foreground"
|
|
>
|
|
<ExternalLink className="h-3 w-3" strokeWidth={1.5} />
|
|
:{primaryHostPort}
|
|
</a>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
|
|
<ExternalLink className="h-3 w-3" strokeWidth={1.5} />
|
|
:{primaryHostPort}
|
|
</span>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
<TabsContent value="dossier" className="flex flex-col flex-1 min-h-0 mt-0">
|
|
<StackDossierPanel stackName={stackName} anatomy={anatomyInput} canEdit={canEdit} />
|
|
</TabsContent>
|
|
<TabsContent value="drift" className="flex flex-col flex-1 min-h-0 mt-0">
|
|
<DriftPanel stackName={stackName} />
|
|
</TabsContent>
|
|
{networkingEnabled && (
|
|
<TabsContent value="networking" className="flex flex-col flex-1 min-h-0 mt-0">
|
|
<StackNetworkingPanel stackName={stackName} canEdit={canEdit} doctorEnabled={doctorEnabled} />
|
|
</TabsContent>
|
|
)}
|
|
{doctorEnabled && (
|
|
<TabsContent value="doctor" className="flex flex-col flex-1 min-h-0 mt-0">
|
|
<PreflightPanel stackName={stackName} />
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|