mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat: guide missing external network creation during deploy (#1645)
* feat: guide missing external network creation during deploy Detect missing external networks before Compose runs, prompt or auto-create safe bridge networks, and keep unsupported declarations blocked with trusted deploy provenance. * test: align deploy context and settings fixtures with missing-network gate Update caller spies, EffResource expectations, StacksSection save keys, and git-source spy cleanup so CI matches the new deployStack context and auto-create setting. * fix: drop unused renderError binding in missing-network resolver Satisfies no-unused-vars so backend ESLint CI passes; callers already key only on model presence. * fix: use HTTP-safe clipboard helper in missing-network dialog navigator.clipboard fails on plain HTTP LAN hosts; route copy actions through copyToClipboard so Docker and Compose copy buttons work on self-hosted instances. * fix: simplify missing-network dialog actions and copy label Drop the Compose snippet escape hatch, move secondary actions under More, and rename the terminal copy action to Copy create command so the footer is a clear Cancel / Create decision.
This commit is contained in:
@@ -270,6 +270,7 @@ export default function EditorLayout() {
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard: hasCapability('update-guard'),
|
||||
hasGuidedExternalNetworkPreflight: hasCapability('guided-external-network-preflight'),
|
||||
canEditStack: (stackNameOrFilename) => {
|
||||
const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, '');
|
||||
return can('stack:edit', 'stack', stackName);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BashExecModal from '../BashExecModal';
|
||||
import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
|
||||
import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
|
||||
import { MissingExternalNetworksDialog } from '../stack/MissingExternalNetworksDialog';
|
||||
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
|
||||
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
|
||||
import { DeleteStackDialog } from './DeleteStackDialog';
|
||||
@@ -54,6 +55,7 @@ export function ShellOverlays({
|
||||
policyBlock, setPolicyBlock, policyBypassing,
|
||||
updateReadiness, setUpdateReadiness,
|
||||
preDeployAdvisory,
|
||||
missingExternalNetworks, setMissingExternalNetworks,
|
||||
selfStackProtectedOpen, setSelfStackProtectedOpen,
|
||||
stackMisconfigScanId, setStackMisconfigScanId,
|
||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||
@@ -135,6 +137,24 @@ export function ShellOverlays({
|
||||
onDeploy={() => preDeployAdvisory?.proceed()}
|
||||
/>
|
||||
|
||||
<MissingExternalNetworksDialog
|
||||
open={missingExternalNetworks !== null}
|
||||
payload={missingExternalNetworks?.payload ?? null}
|
||||
isAdmin={isAdmin}
|
||||
creating={missingExternalNetworks?.creating ?? false}
|
||||
onCancel={() => {
|
||||
missingExternalNetworks?.cancel();
|
||||
setMissingExternalNetworks(null);
|
||||
}}
|
||||
onOpenNetworking={() => {
|
||||
missingExternalNetworks?.openNetworking();
|
||||
setMissingExternalNetworks(null);
|
||||
}}
|
||||
onCreateAndContinue={() => {
|
||||
void missingExternalNetworks?.createAndContinue();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pre-deploy policy block */}
|
||||
<PolicyBlockDialog
|
||||
open={policyBlock !== null}
|
||||
|
||||
@@ -143,6 +143,14 @@ export function useOverlayState() {
|
||||
cancel: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [missingExternalNetworks, setMissingExternalNetworks] = useState<{
|
||||
payload: import('../../stack/MissingExternalNetworksDialog').MissingExternalNetworksPayload;
|
||||
creating: boolean;
|
||||
cancel: () => void;
|
||||
openNetworking: () => void;
|
||||
createAndContinue: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [selfStackProtectedOpen, setSelfStackProtectedOpen] = useState(false);
|
||||
const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []);
|
||||
const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []);
|
||||
@@ -166,6 +174,7 @@ export function useOverlayState() {
|
||||
policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing,
|
||||
updateReadiness, setUpdateReadiness,
|
||||
preDeployAdvisory, setPreDeployAdvisory,
|
||||
missingExternalNetworks, setMissingExternalNetworks,
|
||||
selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected,
|
||||
stackMisconfigScanId, setStackMisconfigScanId,
|
||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||
|
||||
@@ -23,6 +23,9 @@ import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes';
|
||||
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
||||
import type {
|
||||
MissingExternalNetworksPayload,
|
||||
} from '../../stack/MissingExternalNetworksDialog';
|
||||
import type { PreDeployScanImage } from '@/types/security';
|
||||
|
||||
interface RunResult {
|
||||
@@ -31,8 +34,17 @@ interface RunResult {
|
||||
rolledBack?: boolean;
|
||||
/** Health gate run id from the success body, when the backend started one. */
|
||||
healthGateId?: string | null;
|
||||
/**
|
||||
* Deploy hit a missing-external-networks gate; the dialog owns deployPendingRef
|
||||
* until the operator cancels or continues.
|
||||
*/
|
||||
deferredNetworks?: boolean;
|
||||
}
|
||||
|
||||
type MissingExternalNetworksEnvelope = MissingExternalNetworksPayload & {
|
||||
declaredExternalCount: number;
|
||||
};
|
||||
|
||||
/** healthGateId from a success body, or null when absent or unreadable. */
|
||||
const parseHealthGateId = async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
@@ -138,6 +150,9 @@ interface UseStackActionsOptions {
|
||||
// the pre-update readiness dialog. Defaults to false: without the
|
||||
// capability, updates run directly with no dialog.
|
||||
hasUpdateGuard?: boolean;
|
||||
// Active node advertises guided external-network preflight. Absent capability
|
||||
// keeps legacy deploy (no GET). Advertised-but-broken fails closed.
|
||||
hasGuidedExternalNetworkPreflight?: boolean;
|
||||
// Target-aware stack:edit check. Pass the loaded stack identity (folder name
|
||||
// or compose path); callers strip extensions when comparing to RBAC stack
|
||||
// names. Evaluated against the load target so post-load auto-edit is not
|
||||
@@ -182,6 +197,108 @@ export async function fetchPreDeployAdvisory(
|
||||
}
|
||||
}
|
||||
|
||||
const MISSING_EXTERNAL_PREFLIGHT_TIMEOUT_MS = 10000;
|
||||
|
||||
function parseMissingExternalNetworksPayload(data: unknown): MissingExternalNetworksPayload | null {
|
||||
if (!isRecord(data)) return null;
|
||||
if (
|
||||
data.status !== 'ok'
|
||||
&& data.status !== 'render_unavailable'
|
||||
&& data.status !== 'runtime_unavailable'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (typeof data.stackName !== 'string' || typeof data.autoCreateEnabled !== 'boolean') return null;
|
||||
if (!Array.isArray(data.networks)) return null;
|
||||
return {
|
||||
status: data.status,
|
||||
autoCreateEnabled: data.autoCreateEnabled,
|
||||
stackName: data.stackName,
|
||||
networks: data.networks as MissingExternalNetworksPayload['networks'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative missing-external preflight for the captured deploy node.
|
||||
* Returns null only when the route is missing or the body is unusable (treat
|
||||
* as fail-closed when the capability is advertised).
|
||||
*/
|
||||
export async function fetchMissingExternalNetworks(
|
||||
stackName: string,
|
||||
opNodeId: number | null,
|
||||
): Promise<MissingExternalNetworksEnvelope | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), MISSING_EXTERNAL_PREFLIGHT_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/stacks/${encodeURIComponent(stackName)}/missing-external-networks`,
|
||||
{ nodeId: opNodeId, signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data: unknown = await res.json();
|
||||
const payload = parseMissingExternalNetworksPayload(data);
|
||||
if (!payload || !isRecord(data)) return null;
|
||||
const declaredExternalCount = typeof data.declaredExternalCount === 'number'
|
||||
? data.declaredExternalCount
|
||||
: 0;
|
||||
return { ...payload, declaredExternalCount };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch missing external networks:', error);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSafeExternalNetworks(
|
||||
networks: MissingExternalNetworksPayload['networks'],
|
||||
opNodeId: number | null,
|
||||
): Promise<{ ok: boolean; errorMessage?: string }> {
|
||||
for (const network of networks.filter((n) => n.safe)) {
|
||||
try {
|
||||
const res = await apiFetch('/system/networks', {
|
||||
method: 'POST',
|
||||
nodeId: opNodeId,
|
||||
body: JSON.stringify({ name: network.name, driver: 'bridge' }),
|
||||
});
|
||||
if (res.ok || res.status === 409) continue;
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
const message = isRecord(body) && typeof body.error === 'string'
|
||||
? body.error
|
||||
: `Failed to create network "${network.name}" (${res.status})`;
|
||||
return { ok: false, errorMessage: message };
|
||||
} catch (error) {
|
||||
console.error('Failed to create external network:', error);
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: error instanceof Error ? error.message : `Failed to create network "${network.name}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function missingExternalBlocksDeploy(
|
||||
envelope: MissingExternalNetworksEnvelope,
|
||||
): string | null {
|
||||
if (envelope.status === 'render_unavailable') {
|
||||
return 'Sencho could not render this stack\'s Compose model to check external networks.';
|
||||
}
|
||||
if (envelope.status === 'runtime_unavailable' && envelope.declaredExternalCount > 0) {
|
||||
return 'Sencho could not read Docker networking state to check external networks.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getResponseCode(rawBody: string): string | undefined {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(rawBody);
|
||||
return isRecord(payload) && typeof payload.code === 'string' ? payload.code : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const parseStackOpInProgress = (rawBody: string): StackOpInProgressInfo | null => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawBody);
|
||||
@@ -261,6 +378,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard = false,
|
||||
hasGuidedExternalNetworkPreflight = false,
|
||||
canEditStack,
|
||||
canOfferVolumeRemoval = false,
|
||||
} = options;
|
||||
@@ -857,6 +975,96 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
return null;
|
||||
};
|
||||
|
||||
const openMissingExternalNetworksDialog = (
|
||||
payload: MissingExternalNetworksPayload,
|
||||
opNodeId: number | null,
|
||||
onContinue: () => void,
|
||||
) => {
|
||||
let settled = false;
|
||||
const finishCancel = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
overlayState.setMissingExternalNetworks(null);
|
||||
deployPendingRef.current = false;
|
||||
};
|
||||
overlayState.setMissingExternalNetworks({
|
||||
payload,
|
||||
creating: false,
|
||||
cancel: finishCancel,
|
||||
openNetworking: () => {
|
||||
finishCancel();
|
||||
const node = nodes.find((n) => n.id === opNodeId);
|
||||
if (node && activeNode?.id !== opNodeId) setActiveNode(node);
|
||||
navState.setActiveView('networking');
|
||||
},
|
||||
createAndContinue: () => void createAndContinue(),
|
||||
});
|
||||
|
||||
function setCreating(creating: boolean, verified?: MissingExternalNetworksPayload): void {
|
||||
overlayState.setMissingExternalNetworks((current) => (
|
||||
current
|
||||
? { ...current, creating, ...(verified ? { payload: verified } : {}) }
|
||||
: current
|
||||
));
|
||||
}
|
||||
|
||||
async function createAndContinue(): Promise<void> {
|
||||
if (settled) return;
|
||||
setCreating(true);
|
||||
|
||||
const created = await createSafeExternalNetworks(payload.networks, opNodeId);
|
||||
if (!created.ok) {
|
||||
toast.error(created.errorMessage ?? 'Failed to create external networks');
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await fetchMissingExternalNetworks(payload.stackName, opNodeId);
|
||||
if (!verified || verified.status !== 'ok') {
|
||||
toast.error('Could not verify external networks after create.');
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stillMissing = payload.networks.some((needed) => (
|
||||
verified.networks.some((network) => network.name === needed.name)
|
||||
));
|
||||
if (stillMissing) {
|
||||
toast.error('Some external networks are still missing after create.');
|
||||
setCreating(false, verified);
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
overlayState.setMissingExternalNetworks(null);
|
||||
onContinue();
|
||||
}
|
||||
};
|
||||
|
||||
const finishSuccessfulDeploy = async (
|
||||
response: Response,
|
||||
stackName: string,
|
||||
stackFile: string,
|
||||
ignorePolicy: boolean,
|
||||
): Promise<RunResult> => {
|
||||
overlayState.setPolicyBlock(null);
|
||||
const healthGateId = await parseHealthGateId(response);
|
||||
if (healthGateId) {
|
||||
toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...');
|
||||
} else {
|
||||
toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!');
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true, healthGateId };
|
||||
};
|
||||
|
||||
const runDeploy = async (
|
||||
stackName: string,
|
||||
stackFile: string,
|
||||
@@ -897,27 +1105,47 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
toast.error(message);
|
||||
return { ok: false, errorMessage: message };
|
||||
}
|
||||
if (
|
||||
getResponseCode(rawBody) === 'missing_external_networks'
|
||||
&& hasGuidedExternalNetworkPreflight
|
||||
) {
|
||||
const envelope = await fetchMissingExternalNetworks(stackName, opNodeId ?? null);
|
||||
if (!envelope) {
|
||||
toast.error('Deploy needs missing external networks, but Sencho could not re-check them.');
|
||||
return { ok: false, errorMessage: 'Missing external networks check failed' };
|
||||
}
|
||||
const blockMessage = missingExternalBlocksDeploy(envelope);
|
||||
if (blockMessage) {
|
||||
toast.error(blockMessage);
|
||||
return { ok: false, errorMessage: blockMessage };
|
||||
}
|
||||
if (envelope.status === 'ok' && envelope.networks.length === 0) {
|
||||
// Networks appeared between gate and refetch; retry once.
|
||||
const retry = await apiFetch(
|
||||
path,
|
||||
withDeploySession(deploySessionId ?? '', { method: 'POST', nodeId: opNodeId }),
|
||||
);
|
||||
if (retry.ok) {
|
||||
return finishSuccessfulDeploy(retry, stackName, stackFile, ignorePolicy);
|
||||
}
|
||||
throw parseStackActionError(await retry.text(), 'Deploy failed', retry.status);
|
||||
}
|
||||
openMissingExternalNetworksDialog(envelope, opNodeId ?? null, () => {
|
||||
stackListState.setStackAction(stackFile, 'deploy');
|
||||
void runWithLog({ stackName, action: 'deploy', nodeId: opNodeId ?? null }, (startedRetry, ds) =>
|
||||
runDeploy(stackName, stackFile, ignorePolicy, startedRetry, ds, opNodeId),
|
||||
).finally(() => {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
deployPendingRef.current = false;
|
||||
});
|
||||
});
|
||||
return { ok: false, deferredNetworks: true };
|
||||
}
|
||||
}
|
||||
throw parseStackActionError(rawBody, 'Deploy failed', response.status);
|
||||
}
|
||||
overlayState.setPolicyBlock(null);
|
||||
const healthGateId = await parseHealthGateId(response);
|
||||
// With a health gate observing, the operation finishing is not the
|
||||
// final verdict yet; soften the toast so success is not claimed twice.
|
||||
if (healthGateId) {
|
||||
toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...');
|
||||
} else {
|
||||
toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!');
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true, healthGateId };
|
||||
return finishSuccessfulDeploy(response, stackName, stackFile, ignorePolicy);
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
if (previousStatus !== undefined)
|
||||
@@ -957,19 +1185,52 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
// (not before the advisory) so cancelling the advisory leaves no stuck state.
|
||||
const runDeployFlow = async () => {
|
||||
stackListState.setStackAction(stackFile, 'deploy');
|
||||
let deferredNetworks = false;
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
||||
const result = await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
||||
runDeploy(stackName, stackFile, false, started, ds, opNodeId),
|
||||
);
|
||||
deferredNetworks = result.deferredNetworks === true;
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
deployPendingRef.current = false;
|
||||
if (!deferredNetworks) {
|
||||
deployPendingRef.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const continueAfterExternalNetworks = () => {
|
||||
void runDeployFlow();
|
||||
};
|
||||
|
||||
// Advisory runs before the deploy log opens (fails open: a null result means
|
||||
// setting off / timeout / older node / error, so the deploy proceeds).
|
||||
const beginDeployAfterAdvisory = async () => {
|
||||
if (hasGuidedExternalNetworkPreflight) {
|
||||
const envelope = await fetchMissingExternalNetworks(stackName, opNodeId);
|
||||
if (!envelope) {
|
||||
toast.error('Sencho could not check external networks on this node before deploy.');
|
||||
deployPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
const blockMessage = missingExternalBlocksDeploy(envelope);
|
||||
if (blockMessage) {
|
||||
toast.error(blockMessage);
|
||||
deployPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (envelope.status === 'ok' && envelope.networks.length > 0) {
|
||||
const allSafe = envelope.networks.every((n) => n.safe);
|
||||
if (!(allSafe && envelope.autoCreateEnabled)) {
|
||||
openMissingExternalNetworksDialog(envelope, opNodeId, continueAfterExternalNetworks);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await runDeployFlow();
|
||||
};
|
||||
|
||||
const advisoryImages = await fetchPreDeployAdvisory(stackName, opNodeId);
|
||||
if (advisoryImages && advisoryImages.length > 0) {
|
||||
let settled = false;
|
||||
@@ -980,7 +1241,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
overlayState.setPreDeployAdvisory(null);
|
||||
void runDeployFlow();
|
||||
void beginDeployAfterAdvisory();
|
||||
},
|
||||
cancel: () => {
|
||||
if (settled) return;
|
||||
@@ -991,7 +1252,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runDeployFlow();
|
||||
await beginDeployAfterAdvisory();
|
||||
};
|
||||
|
||||
const handleSaveAndDeploy = async (e: React.MouseEvent) => {
|
||||
|
||||
@@ -29,12 +29,13 @@ interface StacksSectionProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
type GuardrailFields = Pick<PatchableSettings, 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required'>;
|
||||
type GuardrailFields = Pick<PatchableSettings, 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required' | 'auto_create_missing_external_networks'>;
|
||||
|
||||
const DEFAULT_GUARDRAILS: GuardrailFields = {
|
||||
health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
|
||||
auto_create_missing_external_networks: DEFAULT_SETTINGS.auto_create_missing_external_networks,
|
||||
};
|
||||
|
||||
function GuardrailSkeleton() {
|
||||
@@ -87,6 +88,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
|
||||
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
|
||||
auto_create_missing_external_networks: (nodeData.auto_create_missing_external_networks as '0' | '1') ?? DEFAULT_SETTINGS.auto_create_missing_external_networks,
|
||||
};
|
||||
reset(safe);
|
||||
} catch (e) {
|
||||
@@ -223,6 +225,15 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
|
||||
onChange={(next) => onGuardrailChange('env_block_deploy_on_missing_required', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Automatically create missing external networks during deploy"
|
||||
helper="When on, safe missing external bridge networks are created automatically before deploy continues. Off by default: manual deploy prompts first. Advanced drivers and custom options are never auto-created."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.auto_create_missing_external_networks === '1'}
|
||||
onChange={(next) => onGuardrailChange('auto_create_missing_external_networks', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
|
||||
@@ -57,6 +57,7 @@ const FULL_SETTINGS: Record<string, string> = {
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
env_block_deploy_on_missing_required: '0',
|
||||
auto_create_missing_external_networks: '0',
|
||||
};
|
||||
|
||||
function patchedKeys(): string[] {
|
||||
@@ -110,6 +111,7 @@ describe('split section save payloads', () => {
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
|
||||
expect(patchedKeys()).toEqual([
|
||||
'auto_create_missing_external_networks',
|
||||
'env_block_deploy_on_missing_required',
|
||||
'health_gate_enabled',
|
||||
'health_gate_window_seconds',
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface PatchableSettings {
|
||||
health_gate_enabled?: '0' | '1';
|
||||
health_gate_window_seconds?: string;
|
||||
env_block_deploy_on_missing_required?: '0' | '1';
|
||||
auto_create_missing_external_networks?: '0' | '1';
|
||||
image_update_sidebar_indicators?: '0' | '1';
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
env_block_deploy_on_missing_required: '0',
|
||||
auto_create_missing_external_networks: '0',
|
||||
image_update_sidebar_indicators: '1',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
Modal,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
} from '@/components/ui/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { canUseNetworkName } from '@/lib/networking';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
export type MissingExternalNetworkDto = {
|
||||
name: string;
|
||||
keys: string[];
|
||||
declarations: Array<{
|
||||
key: string;
|
||||
driverKind: string;
|
||||
unsupportedFeatures: string[];
|
||||
}>;
|
||||
safe: boolean;
|
||||
blockReason?: string;
|
||||
unsupportedFeatures: string[];
|
||||
creationSpec: { driver: 'bridge'; options: 'default' } | null;
|
||||
};
|
||||
|
||||
export type MissingExternalNetworksPayload = {
|
||||
status: 'ok' | 'render_unavailable' | 'runtime_unavailable';
|
||||
autoCreateEnabled: boolean;
|
||||
stackName: string;
|
||||
networks: MissingExternalNetworkDto[];
|
||||
};
|
||||
|
||||
interface MissingExternalNetworksDialogProps {
|
||||
open: boolean;
|
||||
payload: MissingExternalNetworksPayload | null;
|
||||
isAdmin: boolean;
|
||||
creating?: boolean;
|
||||
onCancel: () => void;
|
||||
onOpenNetworking: () => void;
|
||||
onCreateAndContinue: () => void;
|
||||
}
|
||||
|
||||
function buildCreateCommands(networks: MissingExternalNetworkDto[]): string {
|
||||
return networks
|
||||
.filter((n) => n.safe && canUseNetworkName(n.name))
|
||||
.map((n) => n.name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((name) => `docker network create ${name}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatUnsafeReason(net: MissingExternalNetworkDto): string {
|
||||
const parts: string[] = [];
|
||||
if (net.blockReason === 'unsupported_driver') {
|
||||
const kinds = [...new Set(net.declarations.map((d) => d.driverKind))];
|
||||
parts.push(`Driver kinds: ${kinds.join(', ')}`);
|
||||
}
|
||||
if (net.unsupportedFeatures.length > 0) {
|
||||
parts.push(`Unsupported: ${net.unsupportedFeatures.join(', ')}`);
|
||||
}
|
||||
if (net.blockReason === 'invalid_name') {
|
||||
parts.push('Invalid Docker network name');
|
||||
}
|
||||
if (net.blockReason === 'reserved_system') {
|
||||
parts.push('Reserved system network name');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function MissingExternalNetworksDialog({
|
||||
open,
|
||||
payload,
|
||||
isAdmin,
|
||||
creating = false,
|
||||
onCancel,
|
||||
onOpenNetworking,
|
||||
onCreateAndContinue,
|
||||
}: MissingExternalNetworksDialogProps) {
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
const networks = payload?.networks ?? [];
|
||||
const allSafe = networks.length > 0 && networks.every((n) => n.safe);
|
||||
const canCreate = isAdmin && allSafe && payload?.status === 'ok';
|
||||
const createCommands = buildCreateCommands(networks);
|
||||
|
||||
const copyCreateCommand = async () => {
|
||||
if (!createCommands) return;
|
||||
setCopying(true);
|
||||
try {
|
||||
await copyToClipboard(createCommands);
|
||||
toast.success('Create command copied');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy network create command', error);
|
||||
toast.error('Copy failed');
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={(next) => { if (!next) onCancel(); }} size="xl">
|
||||
<ModalHeader
|
||||
kicker={`${(payload?.stackName ?? 'STACK').toUpperCase()} · EXTERNAL NETWORKS`}
|
||||
title="Missing external networks"
|
||||
description="Docker Compose will not create external networks. Create safe bridge networks here, or cancel the deploy."
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="max-h-[min(50vh,28rem)] overflow-y-auto border border-glass-border bg-card/60 shadow-card-bevel divide-y divide-glass-border">
|
||||
{payload?.status !== 'ok' ? (
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{payload?.status === 'render_unavailable'
|
||||
? 'Sencho could not render this stack\'s Compose model to check external networks.'
|
||||
: 'Sencho could not read Docker networking state on this node.'}
|
||||
</div>
|
||||
) : networks.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground">No missing external networks.</div>
|
||||
) : (
|
||||
networks.map((net) => (
|
||||
<div key={net.name} className="px-4 py-3 space-y-1.5">
|
||||
<div className="font-mono text-sm">{net.name}</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Compose keys: {net.keys.join(', ')}
|
||||
</div>
|
||||
{net.safe && net.creationSpec ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Sencho can create a {net.creationSpec.driver} network with {net.creationSpec.options} options.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-destructive">
|
||||
{formatUnsafeReason(net)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
{/* Primary Cancel / Create pair; secondary actions live under More. */}
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-glass-border px-4 py-3 max-md:items-stretch md:flex-row md:items-center md:justify-between">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={creating} className="max-md:w-full md:w-auto">
|
||||
<MoreHorizontal className="mr-1.5 h-3.5 w-3.5" strokeWidth={1.5} aria-hidden />
|
||||
More
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuItem onSelect={onOpenNetworking}>
|
||||
Open Networking
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!createCommands || copying}
|
||||
onSelect={() => { void copyCreateCommand(); }}
|
||||
>
|
||||
Copy create command
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-col-reverse gap-2 max-md:w-full md:flex-row md:items-center md:justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={creating} className="max-md:w-full">
|
||||
Cancel deploy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || creating}
|
||||
className="max-md:w-full"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCreateAndContinue();
|
||||
}}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create and continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,13 @@ interface StackNetworkFacts {
|
||||
networks: NetworkFactNetwork[];
|
||||
services: NetworkFactService[];
|
||||
drift: NetworkDriftFacts;
|
||||
missingExternalNetworks?: Array<{
|
||||
name: string;
|
||||
keys: string[];
|
||||
safe: boolean;
|
||||
blockReason?: string;
|
||||
unsupportedFeatures: string[];
|
||||
}>;
|
||||
}
|
||||
interface IntentEntry { service: string; intent: ExposureIntent }
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import {
|
||||
MissingExternalNetworksDialog,
|
||||
type MissingExternalNetworksPayload,
|
||||
} from '../MissingExternalNetworksDialog';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
const safePayload: MissingExternalNetworksPayload = {
|
||||
status: 'ok',
|
||||
autoCreateEnabled: false,
|
||||
stackName: 'media',
|
||||
networks: [
|
||||
{
|
||||
name: 'arr-net',
|
||||
keys: ['arr'],
|
||||
declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }],
|
||||
safe: true,
|
||||
unsupportedFeatures: [],
|
||||
creationSpec: { driver: 'bridge', options: 'default' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('MissingExternalNetworksDialog', () => {
|
||||
it('enables Create and continue for admin when all networks are safe', async () => {
|
||||
const onCreateAndContinue = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={safePayload}
|
||||
isAdmin
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={vi.fn()}
|
||||
onCreateAndContinue={onCreateAndContinue}
|
||||
/>,
|
||||
);
|
||||
const create = screen.getByRole('button', { name: 'Create and continue' });
|
||||
expect(create).toBeEnabled();
|
||||
await user.click(create);
|
||||
expect(onCreateAndContinue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('disables Create and continue for non-admin', () => {
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={safePayload}
|
||||
isAdmin={false}
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={vi.fn()}
|
||||
onCreateAndContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create and continue' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Create when any network is unsafe', () => {
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={{
|
||||
...safePayload,
|
||||
networks: [
|
||||
{
|
||||
...safePayload.networks[0],
|
||||
safe: false,
|
||||
blockReason: 'unsupported_driver',
|
||||
creationSpec: null,
|
||||
declarations: [{ key: 'arr', driverKind: 'macvlan', unsupportedFeatures: [] }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
isAdmin
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={vi.fn()}
|
||||
onCreateAndContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create and continue' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('exposes secondary actions under More and copies the create command', async () => {
|
||||
const onOpenNetworking = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={safePayload}
|
||||
isAdmin
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={onOpenNetworking}
|
||||
onCreateAndContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Copy Compose snippet' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Copy Docker command' })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Open Networking' }));
|
||||
expect(onOpenNetworking).toHaveBeenCalledOnce();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Copy create command' }));
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('docker network create arr-net');
|
||||
expect(toast.success).toHaveBeenCalledWith('Create command copied');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user