mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
48f010475b
* feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch The ref model now resolves a configured branch, tag, or full commit SHA to an immutable commit before any content is downloaded, and records both the configured and the resolved identity where revision state persists. - RefKind (branch | tag | sha) is a resolved property, not caller-asserted. A bare string resolves branch-first, then tag; a full 40/64-hex SHA self-resolves with no remote round-trip. Branch and tag both fetch via a bare --branch name; a SHA uses init + shallow fetch + detached checkout. - A single ls-remote with narrow heads/tags refspecs pins the configured ref to an immutable SHA; rev-parse HEAD must equal the resolved SHA or the fetch refuses (tip-changed) instead of materializing unreviewed content. - Error union grew: REF_NOT_FOUND (ref-neutral, replaces BRANCH_NOT_FOUND), UNSUPPORTED_REF (a pinned SHA the host will not serve), and a service-level REF_DELETED upgrade that fires when a classified REF_NOT_FOUND occurs for a source with prior fetch history (a vanished ref reads as delete/force-push, not a fresh typo). Status mapping: REF_NOT_FOUND/REF_DELETED to 404, UNSUPPORTED_REF to 400. - Configured-vs-resolved identity is recorded via a nullable resolved_ref_kind column on gitops_generations (added to CREATE TABLE and re-added for legacy installs through maybeAddCol). The kind is deliberately NOT in the plan fingerprint: two sources naming the same commit differently are the same plan. Docs updated (git-sources feature page, connect-a-git-source tutorial, and the native-git-transport internal deep-dive) to the ref-neutral naming. * fix(gitops): harden ref resolution after pre-merge audit Request peeled annotated-tag refs from ls-remote, detect force-pushes and ref-kind changes against prior fetch identity, persist resolved kind on application rows, and add real-git tag/SHA integration coverage plus ref-neutral UI and operator docs. * test(gitops): mock verifyFastForward in direct producer suite The producer tests stub the transport seam but were missing resolved kind on resolveRef and a verifyFastForward stub, so second pulls tripped the new ref-continuity checks as REF_DELETED. * test(git): remove unused buildBareFixtureRepo helper Fixes backend lint failure after the integration fixture was refactored to buildRichFixtureRepo without dropping the old wrapper. * fix(gitops): correct fast-forward ancestry verification under size bounds Replace the dual shallow-fetch ancestry probe with a single-tip deepen strategy, keep verifier Git work inside the transport watchdog, and add real-Git regression coverage for linear advances and rewritten history. * fix(gitops): bound fast-forward verification with exponential deepen Replace per-commit deepen loops with exponential steps, cap remote fetch rounds, and share one deadline across verifier Git calls. Budget exhaustion now surfaces as a classified timeout instead of REF_DELETED. * fix(gitops): classify fast-forward probe failures accurately Normalize verifier probe timeouts and unexpected exit codes into transport failures, interpret merge-base status 1 as proven non-ancestry only, and treat shallow stagnation as timeout instead of REF_DELETED. * fix(gitops): satisfy tsc on probeFailure never returns * fix(gitops): address Phase E QA findings on ref verification Remove the fast-forward scratch repo after verification so pull size caps are not inflated, classify GitHub not-our-ref as UNSUPPORTED_REF, persist fetched_resolved_ref_kind on create-from-git, and broaden REF_DELETED copy for retagged tags.
619 lines
23 KiB
TypeScript
619 lines
23 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { GitBranch, Loader2, Trash2, RefreshCw, Save } from 'lucide-react';
|
|
import { Modal, ModalHeader, ConfirmModal } from '@/components/ui/modal';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { apiFetch } from '@/lib/api';
|
|
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { GitSourceDiffDialog, type PullResult, type PublicPendingPlan } from './GitSourceDiffDialog';
|
|
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
|
|
import { GitManifestSummary, type ManifestSummary } from './GitManifestSummary';
|
|
import type { GitBrowseResult } from './GitComposeFilePicker';
|
|
import GitOpsStateCard, { GitOpsFaultCard } from '@/components/gitops/GitOpsStateCard';
|
|
import GitOpsCaveats from '@/components/gitops/GitOpsCaveats';
|
|
import { SOURCE_STATE_LOOKUP, absentFault, liveSourceFacet, type LiveSourceFacet } from '@/lib/gitopsState';
|
|
import type { GitOpsRevisionCarrier, GitOpsRevisionProjection, GitOpsSourceStatus } from '@/types/gitops';
|
|
|
|
export interface GitSource {
|
|
id: number;
|
|
stack_name: string;
|
|
repo_url: string;
|
|
branch: string;
|
|
compose_path: string;
|
|
compose_paths: string[];
|
|
context_dir: string | null;
|
|
sync_env: boolean;
|
|
env_path: string | null;
|
|
auth_type: 'none' | 'token';
|
|
has_token: boolean;
|
|
auto_apply_on_webhook: boolean;
|
|
auto_deploy_on_apply: boolean;
|
|
last_applied_commit_sha: string | null;
|
|
pending_commit_sha: string | null;
|
|
pending_fetched_at: number | null;
|
|
pending_plan: PublicPendingPlan | null;
|
|
last_plan_fingerprint: string | null;
|
|
last_plan_outcome: string | null;
|
|
created_at: number;
|
|
updated_at: number;
|
|
manifest_state: ManifestSummary['state'] | null;
|
|
manifest: ManifestSummary | null;
|
|
}
|
|
|
|
// The GET carries the revision on both of its 200 shapes. The PUT does not,
|
|
// which is why GitSource itself stays free of it.
|
|
type GitSourceRead = GitSource & GitOpsRevisionCarrier;
|
|
type GitSourceUnlinked = { linked: false } & GitOpsRevisionCarrier;
|
|
|
|
interface GitSourcePanelProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
stackName: string;
|
|
canEdit: boolean;
|
|
isDarkMode: boolean;
|
|
/** Called after any change that may affect the sidebar pending-badge. */
|
|
onSourceChanged?: () => void;
|
|
}
|
|
|
|
function deriveApplyMode(source: GitSource | null, pendingMode: ApplyMode | null): ApplyMode {
|
|
if (pendingMode) return pendingMode;
|
|
if (!source) return 'review';
|
|
if (!source.auto_apply_on_webhook) return 'review';
|
|
return source.auto_deploy_on_apply ? 'auto-deploy' : 'auto-write';
|
|
}
|
|
|
|
/** The commit the pending banner announces, or null when there is nothing to announce. */
|
|
interface PendingCommit {
|
|
status: GitOpsSourceStatus;
|
|
/** Short-sha detail line; null when the state is known but the commit is not. */
|
|
sha: string | null;
|
|
}
|
|
|
|
/**
|
|
* What the pending banner shows, from the projection when one answered and from
|
|
* the flat pointer when none did.
|
|
*
|
|
* The fallback is reachable when a GitOps write failed and was swallowed while
|
|
* the pending commit still committed, and it is what this banner read before the
|
|
* projection existed. A fault suppresses it: that means an application was
|
|
* expected and could not be read, so the pointer is not evidence a candidate is
|
|
* ready. The sidebar applies the same rule, so the two surfaces cannot disagree.
|
|
*/
|
|
function derivePendingCommit(
|
|
facet: LiveSourceFacet | null,
|
|
faultCount: number,
|
|
flatPendingSha: string | null,
|
|
): PendingCommit | null {
|
|
if (facet) {
|
|
if (facet.candidateGenerationId === null) return null;
|
|
return { status: facet.status, sha: facet.fetchedCommitSha };
|
|
}
|
|
if (faultCount > 0 || !flatPendingSha) return null;
|
|
return { status: 'candidate_ready', sha: flatPendingSha };
|
|
}
|
|
|
|
export function GitSourcePanel({
|
|
open,
|
|
onOpenChange,
|
|
stackName,
|
|
canEdit,
|
|
onSourceChanged,
|
|
}: GitSourcePanelProps) {
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [pulling, setPulling] = useState(false);
|
|
const [applying, setApplying] = useState(false);
|
|
const [source, setSource] = useState<GitSource | null>(null);
|
|
// Kept out of `source` on purpose: the PUT that saves this panel answers with
|
|
// a bare Git source and no revision, so carrying it on that type would make
|
|
// the save path a lie.
|
|
const [revision, setRevision] = useState<GitOpsRevisionProjection | null>(null);
|
|
|
|
const [repoUrl, setRepoUrl] = useState('');
|
|
const [branch, setBranch] = useState('main');
|
|
const [composePaths, setComposePaths] = useState<string[]>(['compose.yaml']);
|
|
const [contextDir, setContextDir] = useState('');
|
|
const [syncEnv, setSyncEnv] = useState(false);
|
|
const [authType, setAuthType] = useState<'none' | 'token'>('none');
|
|
const [token, setToken] = useState('');
|
|
const [applyModeOverride, setApplyModeOverride] = useState<ApplyMode | null>(null);
|
|
|
|
const [pull, setPull] = useState<PullResult | null>(null);
|
|
const [diffOpen, setDiffOpen] = useState(false);
|
|
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
|
|
|
const { runWithLog } = useDeployFeedback();
|
|
const { activeNode } = useNodes();
|
|
const applyMode = deriveApplyMode(source, applyModeOverride);
|
|
|
|
const sourceFacet = liveSourceFacet(revision);
|
|
const faults = revision ? absentFault(revision) : [];
|
|
const pending = derivePendingCommit(sourceFacet, faults.length, source?.pending_commit_sha ?? null);
|
|
|
|
const resetToUnlinked = useCallback(() => {
|
|
setSource(null);
|
|
setRepoUrl('');
|
|
setBranch('main');
|
|
setComposePaths(['compose.yaml']);
|
|
setContextDir('');
|
|
setSyncEnv(false);
|
|
setAuthType('none');
|
|
setToken('');
|
|
setApplyModeOverride(null);
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`);
|
|
if (res.ok) {
|
|
const data: GitSourceRead | GitSourceUnlinked = await res.json();
|
|
setRevision(data.gitopsRevision);
|
|
// An existing stack with no Git source attached answers 200 { linked: false }.
|
|
if ('linked' in data) {
|
|
resetToUnlinked();
|
|
} else {
|
|
setSource(data);
|
|
setRepoUrl(data.repo_url);
|
|
setBranch(data.branch);
|
|
setComposePaths(data.compose_paths?.length ? data.compose_paths : [data.compose_path]);
|
|
setContextDir(data.context_dir ?? '');
|
|
setSyncEnv(data.sync_env);
|
|
setAuthType(data.auth_type);
|
|
setToken('');
|
|
setApplyModeOverride(null);
|
|
}
|
|
} else if (res.status === 404) {
|
|
resetToUnlinked();
|
|
setRevision(null);
|
|
} else if (res.status === 403) {
|
|
setSource(null);
|
|
setRevision(null);
|
|
toast.error('You do not have permission to view this stack\'s Git source.');
|
|
} else {
|
|
setRevision(null);
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || 'Failed to load Git source.');
|
|
}
|
|
} catch (e) {
|
|
// Clear alongside the other failure branches: the panel is reused across
|
|
// stacks, so a revision left behind would render one stack's state under
|
|
// another stack's header.
|
|
setRevision(null);
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [stackName, resetToUnlinked]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
void load();
|
|
}
|
|
}, [open, load]);
|
|
|
|
const save = async () => {
|
|
if (!repoUrl.trim() || !branch.trim() || composePaths.length === 0) {
|
|
toast.error('Repository URL, ref, and at least one compose file are required.');
|
|
return;
|
|
}
|
|
if (!/^https:\/\//i.test(repoUrl.trim())) {
|
|
toast.error('Only HTTPS repository URLs are supported.');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
const loadingId = toast.loading('Verifying repository access...');
|
|
try {
|
|
const autoApply = applyMode !== 'review';
|
|
const autoDeploy = applyMode === 'auto-deploy';
|
|
const body: Record<string, unknown> = {
|
|
repo_url: repoUrl.trim(),
|
|
branch: branch.trim(),
|
|
compose_paths: composePaths,
|
|
context_dir: contextDir.trim() || null,
|
|
sync_env: syncEnv,
|
|
auth_type: authType,
|
|
auto_apply_on_webhook: autoApply,
|
|
auto_deploy_on_apply: autoDeploy,
|
|
};
|
|
if (authType === 'token' && token !== '') {
|
|
body.token = token;
|
|
}
|
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (res.ok) {
|
|
setToken('');
|
|
setApplyModeOverride(null);
|
|
toast.success('Git source saved.');
|
|
onSourceChanged?.();
|
|
// Re-read rather than trust the save response, which carries the source
|
|
// row without a revision. A material configuration change clears the
|
|
// staged candidate server side, so the state held here has genuinely
|
|
// moved; dropping it left the panel blank until the next open, which
|
|
// reads as "this stack has no GitOps state" rather than as a state that
|
|
// was just invalidated.
|
|
await load();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || 'Failed to save Git source.');
|
|
}
|
|
} catch (e) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
} finally {
|
|
toast.dismiss(loadingId);
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const browseRepo = async (): Promise<GitBrowseResult | null> => {
|
|
if (!repoUrl.trim() || !branch.trim()) {
|
|
toast.error('Enter a repository URL and ref first.');
|
|
return null;
|
|
}
|
|
try {
|
|
const body: Record<string, unknown> = {
|
|
repo_url: repoUrl.trim(),
|
|
branch: branch.trim(),
|
|
auth_type: authType,
|
|
};
|
|
if (authType === 'token' && token !== '') body.token = token;
|
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/browse`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
return { files: data.files ?? [], truncated: data.truncated ?? false };
|
|
}
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || 'Failed to browse repository.');
|
|
return null;
|
|
} catch (e) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const remove = async () => {
|
|
if (!source) return;
|
|
setRemoveConfirmOpen(false);
|
|
setDeleting(true);
|
|
try {
|
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (res.ok) {
|
|
toast.success('Git source removed.');
|
|
setSource(null);
|
|
// Detaching is a stronger invalidation than a save: the projection now
|
|
// describes a source that is gone, and the pending card is derived from
|
|
// the revision alone, so leaving it would advertise a waiting commit on
|
|
// a stack Git no longer manages.
|
|
setRevision(null);
|
|
onSourceChanged?.();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || 'Failed to remove Git source.');
|
|
}
|
|
} catch (e) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const pullNow = async () => {
|
|
if (!source) return;
|
|
setPulling(true);
|
|
const loadingId = toast.loading('Fetching from Git...');
|
|
try {
|
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/pull`, {
|
|
method: 'POST',
|
|
});
|
|
if (res.ok) {
|
|
const data: PullResult = await res.json();
|
|
if (data.warnings && data.warnings.length > 0) {
|
|
toast.warning(data.warnings.join(' '));
|
|
}
|
|
setPull(data);
|
|
setDiffOpen(true);
|
|
onSourceChanged?.();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || 'Pull failed.');
|
|
}
|
|
} catch (e) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
} finally {
|
|
toast.dismiss(loadingId);
|
|
setPulling(false);
|
|
}
|
|
};
|
|
|
|
const applyPull = async (commitSha: string, deploy: boolean, planFingerprint: string) => {
|
|
setApplying(true);
|
|
const loadingId = toast.loading(deploy ? 'Applying and deploying...' : 'Applying changes...');
|
|
// Snapshot the node once so the apply (and any deploy it triggers) stays
|
|
// bound to it even if the active node changes while the operation runs.
|
|
const opNodeId = activeNode?.id ?? null;
|
|
try {
|
|
const runApply = async (started: Promise<void>) => {
|
|
if (deploy) await started;
|
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/apply`, {
|
|
method: 'POST',
|
|
nodeId: opNodeId,
|
|
body: JSON.stringify({ commitSha, planFingerprint, deploy }),
|
|
});
|
|
if (res.ok) {
|
|
const data: { applied: boolean; deployed: boolean; deployError?: string } = await res.json();
|
|
if (data.deployError) {
|
|
toast.warning(`Applied, but deploy failed: ${data.deployError}`);
|
|
} else if (deploy && data.deployed) {
|
|
toast.success('Changes applied and deployed.');
|
|
} else {
|
|
toast.success('Changes applied.');
|
|
}
|
|
setDiffOpen(false);
|
|
setPull(null);
|
|
await load();
|
|
onSourceChanged?.();
|
|
return { ok: true };
|
|
} else {
|
|
const err = await res.json().catch(() => ({})) as {
|
|
error?: string;
|
|
code?: string;
|
|
plan?: PullResult['plan'];
|
|
planFingerprint?: string;
|
|
};
|
|
if (res.status === 409 && err.code === 'STALE_PLAN' && err.plan && err.planFingerprint) {
|
|
setPull((prev) => prev
|
|
? { ...prev, plan: err.plan ?? null, planFingerprint: err.planFingerprint ?? null }
|
|
: prev);
|
|
toast.warning(err.error || 'The change plan is stale. Review the updated plan before applying.');
|
|
return { ok: false, errorMessage: err.error };
|
|
}
|
|
const msg = err.error || 'Failed to apply changes.';
|
|
toast.error(msg);
|
|
return { ok: false, errorMessage: msg };
|
|
}
|
|
};
|
|
|
|
if (deploy) {
|
|
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, runApply);
|
|
} else {
|
|
await runApply(Promise.resolve());
|
|
}
|
|
} catch (e: unknown) {
|
|
toast.error((e as Error)?.message || 'Something went wrong.');
|
|
} finally {
|
|
toast.dismiss(loadingId);
|
|
setApplying(false);
|
|
}
|
|
};
|
|
|
|
const dismissPending = async () => {
|
|
try {
|
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/dismiss-pending`, {
|
|
method: 'POST',
|
|
});
|
|
if (res.ok) {
|
|
toast.success('Pending update dismissed.');
|
|
setDiffOpen(false);
|
|
setPull(null);
|
|
await load();
|
|
onSourceChanged?.();
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
toast.error(err?.error || 'Failed to dismiss the pending update.');
|
|
}
|
|
} catch (e) {
|
|
toast.error((e as Error)?.message || 'Network error.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Modal open={open} onOpenChange={onOpenChange} size="xl">
|
|
<ModalHeader
|
|
kicker={`${stackName.toUpperCase()} · GIT SOURCE`}
|
|
title={
|
|
<span className="flex items-center gap-2">
|
|
<GitBranch className="w-5 h-5" strokeWidth={1.5} />
|
|
Git source
|
|
</span>
|
|
}
|
|
description="Link this stack to a Git repository so compose updates can be pulled on demand or via webhook."
|
|
/>
|
|
|
|
<ScrollArea className="h-[70vh] max-md:h-auto">
|
|
<div className="px-6 py-5 space-y-5">
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-9 w-full" />
|
|
<Skeleton className="h-9 w-full" />
|
|
<Skeleton className="h-9 w-full" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{faults.length > 0 && <GitOpsFaultCard message={faults[0].message} />}
|
|
|
|
{pending && (
|
|
<GitOpsStateCard
|
|
data-testid="git-pending"
|
|
stateKey={pending.status}
|
|
state={SOURCE_STATE_LOOKUP[pending.status]}
|
|
action={(
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7"
|
|
onClick={() => pullNow()}
|
|
disabled={pulling}
|
|
>
|
|
Review
|
|
</Button>
|
|
)}
|
|
>
|
|
{pending.sha && (
|
|
<div className="mt-1 font-mono text-[11px] text-stat-subtitle">
|
|
Commit <span className="tabular-nums text-foreground/80">{pending.sha.slice(0, 7)}</span>
|
|
</div>
|
|
)}
|
|
</GitOpsStateCard>
|
|
)}
|
|
|
|
<GitOpsCaveats revision={revision} />
|
|
|
|
<GitSourceFields
|
|
variant="edit"
|
|
disabled={!canEdit || saving}
|
|
repoUrl={repoUrl}
|
|
branch={branch}
|
|
composePaths={composePaths}
|
|
contextDir={contextDir}
|
|
syncEnv={syncEnv}
|
|
authType={authType}
|
|
token={token}
|
|
hasStoredToken={source?.has_token ?? false}
|
|
applyMode={applyMode}
|
|
onRepoUrlChange={setRepoUrl}
|
|
onBranchChange={setBranch}
|
|
onComposePathsChange={setComposePaths}
|
|
onContextDirChange={setContextDir}
|
|
onSyncEnvChange={setSyncEnv}
|
|
onAuthTypeChange={setAuthType}
|
|
onTokenChange={setToken}
|
|
onApplyModeChange={setApplyModeOverride}
|
|
onBrowse={browseRepo}
|
|
/>
|
|
|
|
{source && (
|
|
<div className="rounded-md border border-glass-border bg-muted/30 px-3 py-2 text-[11px] text-stat-subtitle space-y-0.5 shadow-card-bevel">
|
|
<div className="flex justify-between gap-2">
|
|
<span>Last applied commit</span>
|
|
<span className="font-mono tabular-nums">
|
|
{source.last_applied_commit_sha ? source.last_applied_commit_sha.slice(0, 7) : 'never'}
|
|
</span>
|
|
</div>
|
|
{sourceFacet && (
|
|
<div className="flex justify-between gap-2">
|
|
<span>Source state</span>
|
|
<span data-testid="git-source-state">{SOURCE_STATE_LOOKUP[sourceFacet.status]?.label ?? sourceFacet.status}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between gap-2">
|
|
<span>Updated</span>
|
|
<span className="font-mono tabular-nums">
|
|
{new Date(source.updated_at).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{source && (
|
|
<GitManifestSummary
|
|
stackName={stackName}
|
|
summary={
|
|
source.manifest ??
|
|
(source.manifest_state
|
|
? {
|
|
state: source.manifest_state,
|
|
manifestVersion: 0,
|
|
resolvedCommitSha: null,
|
|
managedCount: 0,
|
|
unmanagedCount: 0,
|
|
refusedCount: 0,
|
|
refused: [],
|
|
hasBuildContexts: false,
|
|
generatedAt: null,
|
|
}
|
|
: null)
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
<div className="px-6 py-4 border-t border-glass-border flex items-center justify-between gap-2">
|
|
<div>
|
|
{source && canEdit && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setRemoveConfirmOpen(true)}
|
|
disabled={deleting || saving}
|
|
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{source && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={pullNow}
|
|
disabled={pulling || saving}
|
|
>
|
|
{pulling ? (
|
|
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Pulling</>
|
|
) : (
|
|
<><RefreshCw className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Pull now</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
{canEdit && (
|
|
<Button size="sm" onClick={save} disabled={saving}>
|
|
{saving ? (
|
|
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Saving</>
|
|
) : (
|
|
<><Save className="w-4 h-4 mr-1.5" strokeWidth={1.5} />{source ? 'Update' : 'Save'}</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<GitSourceDiffDialog
|
|
open={diffOpen}
|
|
onOpenChange={setDiffOpen}
|
|
stackName={stackName}
|
|
pull={pull}
|
|
autoDeployDefault={applyMode === 'auto-deploy'}
|
|
applying={applying}
|
|
onApply={applyPull}
|
|
onDismiss={dismissPending}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={removeConfirmOpen}
|
|
onOpenChange={setRemoveConfirmOpen}
|
|
variant="destructive"
|
|
kicker={`${stackName.toUpperCase()} · GIT · DISCONNECT`}
|
|
title="Detach and export"
|
|
confirmLabel={deleting ? 'Detaching...' : 'Detach'}
|
|
confirming={deleting}
|
|
onConfirm={remove}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Detaches the stack from its Git source. Sencho renders the effective compose model into a single
|
|
compose.yaml, keeps the materialized files, and removes Git tracking. Resolved values are baked into
|
|
the exported file: anything interpolated from .env or env_file files, including credentials, becomes
|
|
readable in compose.yaml. Reconfiguring the source later is always possible.
|
|
</p>
|
|
</ConfirmModal>
|
|
</>
|
|
);
|
|
}
|