// EC2 Spot Launcher — step-by-step: GPU → Size → Image → Config → Review
const { Panel, Bar, Pill, MoneyBig } = window.SEIS_PRIM;
const _ADJ = ["fast","dark","cold","bold","deep","wild","iron","soft","raw","sharp","dry","dim","hot","slim","nova","arc","void","flux","peak","echo"];
const _NOUN = ["bake","forge","pulse","node","run","core","shard","burst","wave","draft","batch","fire","loop","spark","probe","task","build","push","job","scan"];
const randName = () => `${_ADJ[Math.random()*_ADJ.length|0]}-${_NOUN[Math.random()*_NOUN.length|0]}`;
const GPU_COLORS = {
test: "var(--gpu-test)",
hermes: "var(--gpu-hermes)",
hephaestus: "var(--gpu-hephaestus)",
apollo: "var(--gpu-apollo)",
selene: "var(--gpu-selene)",
artemis: "var(--gpu-artemis)",
athena: "var(--gpu-athena)",
prometheus: "var(--gpu-prometheus)",
ares: "var(--gpu-ares)",
zeus: "var(--gpu-zeus)",
kronos: "var(--gpu-kronos)",
};
const GPU_GROUPS = [
{ key: "test", name: "SAND", gpu: "No GPU · testing only", vram: "—", cuda: "—", families: ["t3"], color: GPU_COLORS.test, test: true },
{ key: "hermes", name: "HERMES", gpu: "NVIDIA T4", vram: "16 GB", cuda: "7.5", families: ["g4dn"], color: GPU_COLORS.hermes },
{ key: "hephaestus", name: "HEPHAESTUS", gpu: "AMD Radeon V520", vram: "8 GB", cuda: "—", families: ["g4ad"], color: GPU_COLORS.hephaestus },
{ key: "apollo", name: "APOLLO", gpu: "NVIDIA A10G", vram: "24 GB", cuda: "8.6", families: ["g5"], color: GPU_COLORS.apollo },
{ key: "selene", name: "SELENE", gpu: "NVIDIA T4g", vram: "16 GB", cuda: "7.5", families: ["g5g"], color: GPU_COLORS.selene },
{ key: "artemis", name: "ARTEMIS", gpu: "NVIDIA L4", vram: "24 GB", cuda: "8.9", families: ["g6","g6f","gr6","gr6f"], color: GPU_COLORS.artemis },
{ key: "athena", name: "ATHENA", gpu: "NVIDIA L40S", vram: "48 GB", cuda: "8.9", families: ["g6e"], color: GPU_COLORS.athena },
{ key: "prometheus", name: "PROMETHEUS", gpu: "NVIDIA RTX PRO 6000", vram: "96 GB", cuda: "10.0", families: ["g7e"], color: GPU_COLORS.prometheus },
{ key: "ares", name: "ARES", gpu: "NVIDIA V100", vram: "16–32 GB", cuda: "7.0", families: ["p3"], color: GPU_COLORS.ares },
{ key: "zeus", name: "ZEUS", gpu: "NVIDIA A100", vram: "320–640 GB", cuda: "8.0", families: ["p4d","p4de"], color: GPU_COLORS.zeus },
{ key: "kronos", name: "KRONOS", gpu: "NVIDIA H100", vram: "640 GB", cuda: "9.0", families: ["p5","p5e"], color: GPU_COLORS.kronos },
];
const REGIONS = ["us-east-1", "us-east-2", "us-west-1", "us-west-2"];
const REGION_LIMITS = { "us-west-1": "Limited: g4dn + p5 only" };
const STEP_LABELS = ["GPU", "SIZE", "IMAGE", "CONFIG", "REVIEW"];
const AMI_RE = /^ami-[0-9a-f]{8,17}$/;
const SESSION_KEY = "seis_draft";
function EC2Launcher({ open, onClose, instanceTypes, budget, regionLatency = {} }) {
const free = (budget?.monthly || 600) - (budget?.used || 0);
const allTypes = instanceTypes && instanceTypes.length ? instanceTypes : [];
const [step, setStep] = React.useState(1);
const [gpuKey, setGpuKey] = React.useState(null);
const [instanceId, setInstanceId] = React.useState("");
const [platform, setPlatform] = React.useState("linux");
const [amiId, setAmiId] = React.useState("");
const [hours, setHours] = React.useState(8);
const [durMode, setDurMode] = React.useState("slider"); // "slider" | "picker"
const [durD, setDurD] = React.useState(0);
const [durH, setDurH] = React.useState(8);
const [durM, setDurM] = React.useState(0);
const [durS, setDurS] = React.useState(0);
const [name, setName] = React.useState(randName);
const [region, setRegion] = React.useState("us-east-1");
const [az, setAz] = React.useState("");
const [reason, setReason] = React.useState("");
const [diskGb, setDiskGb] = React.useState(200);
const [amis, setAmis] = React.useState([]);
const [amisLoading, setAmisLoading] = React.useState(false);
const [amisError, setAmisError] = React.useState("");
const [customAmi, setCustomAmi] = React.useState("");
const [compare, setCompare] = React.useState(null);
const [quota, setQuota] = React.useState(null);
const latency = regionLatency;
const [winAzPrices, setWinAzPrices] = React.useState({});
const [submitting, setSubmitting] = React.useState(false);
const [stage, setStage] = React.useState("form");
const [errorMsg, setErrorMsg] = React.useState("");
const [requestId, setRequestId] = React.useState("");
const [amiValid, setAmiValid] = React.useState(null);
const modalRef = React.useRef(null);
const escCountRef = React.useRef(0);
// AMI cache keyed by region
const _amiCache = React.useRef({});
// Restore draft from sessionStorage
React.useEffect(() => {
if (!open) return;
try {
const saved = sessionStorage.getItem(SESSION_KEY);
if (saved) {
const d = JSON.parse(saved);
if (d.gpuKey) setGpuKey(d.gpuKey);
if (d.instanceId) setInstanceId(d.instanceId);
if (d.platform) setPlatform(d.platform);
if (d.region) setRegion(d.region);
if (d.hours) setHours(d.hours);
if (d.name) setName(d.name);
if (d.reason) setReason(d.reason);
}
} catch {}
}, [open]);
// Persist draft to sessionStorage
React.useEffect(() => {
if (!open || stage !== "form") return;
try {
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
gpuKey, instanceId, platform, region, hours, name, reason,
}));
} catch {}
}, [gpuKey, instanceId, platform, region, hours, name, reason, stage, open]);
// Clear draft on submit or close
const clearDraft = React.useCallback(() => {
try { sessionStorage.removeItem(SESSION_KEY); } catch {}
}, []);
// AMIs — reload on region or open (with cache)
React.useEffect(() => {
if (!open) return;
const cache = _amiCache.current;
if (cache[region]) {
setAmis(cache[region]);
const pf = cache[region].filter(a => a.platform === platform);
if (pf.length) setAmiId(pf[0].id);
else if (cache[region].length) setAmiId(cache[region][0].id);
return;
}
setAmisLoading(true);
setAmisError("");
setAmiId("");
fetch(`/api/ec2/amis?region=${region}`, { credentials: "include" })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => {
cache[region] = data;
setAmis(data);
const pf = data.filter(a => a.platform === platform);
if (pf.length) setAmiId(pf[0].id);
else if (data.length) setAmiId(data[0].id);
})
.catch(() => setAmisError("Failed to load AMIs"))
.finally(() => setAmisLoading(false));
}, [region, open]);
// Compare + quota
React.useEffect(() => {
if (!open) return;
fetch(`/api/ec2/prices/compare`, { credentials: "include" })
.then(r => r.ok ? r.json() : null).then(d => setCompare(d)).catch(() => {});
fetch(`/api/ec2/quota?region=${region}`, { credentials: "include" })
.then(r => r.ok ? r.json() : null).then(d => setQuota(d)).catch(() => {});
}, [open, region]);
// Auto-select cheapest region + AZ when compare data arrives for a new instanceId
const _autoSelectedFor = React.useRef(null);
React.useEffect(() => {
if (!compare || !instanceId || _autoSelectedFor.current === instanceId) return;
const candidates = REGIONS.map(reg => ({
reg,
current: compare[reg]?.[instanceId]?.current ?? Infinity,
az: compare[reg]?.[instanceId]?.az ?? "",
})).filter(r => r.current < Infinity && r.az);
if (!candidates.length) return;
const best = candidates.reduce((a, b) => a.current < b.current ? a : b);
_autoSelectedFor.current = instanceId;
setRegion(best.reg);
setAz(best.az);
}, [compare, instanceId]);
// Disk default when instance changes
React.useEffect(() => {
if (!instanceId) return;
}, [instanceId]);
// Windows AZ prices
React.useEffect(() => {
if (platform !== "windows" || !instanceId || !open) return;
setWinAzPrices({});
fetch(`/api/ec2/prices/az?instance_type=${instanceId}®ion=${region}&platform=windows`, { credentials: "include" })
.then(r => r.ok ? r.json() : {})
.then(d => setWinAzPrices(d))
.catch(() => {});
}, [platform, instanceId, region, open]);
// Focus trap + keyboard
React.useEffect(() => {
if (!open) return;
const handleKey = (e) => {
if (e.key === "Escape") {
e.preventDefault();
escCountRef.current += 1;
if (escCountRef.current === 1) {
setTimeout(() => { escCountRef.current = 0; }, 400);
}
if (stage !== "form" || step === 1) {
handleClose();
} else {
setStep(s => s - 1);
}
}
if (e.key === "Enter" && stage === "form" && !e.target.closest("input") && !e.target.closest("select")) {
if (canAdvance) handleNext();
}
if (e.key === "Enter" && e.target.closest("input") && step === 4 && reason.trim() && name.trim()) {
e.preventDefault();
setStep(5);
}
};
document.addEventListener("keydown", handleKey);
modalRef.current?.focus();
return () => document.removeEventListener("keydown", handleKey);
}, [open, step, stage, canAdvance]);
const formatDuration = (h) => {
const totalS = Math.round(h * 3600);
const d = Math.floor(totalS / 86400);
const hh = Math.floor((totalS % 86400) / 3600);
const mm = Math.floor((totalS % 3600) / 60);
const ss = totalS % 60;
if (d > 0) return `${d}D ${hh}H ${mm}M`;
if (ss > 0) return `${hh}H ${mm}M ${ss}S`;
if (mm > 0) return `${hh}H ${mm}M`;
return `${hh}H`;
};
React.useEffect(() => {
if (durMode !== "picker") return;
setHours(+(durD * 24 + durH + durM / 60 + durS / 3600).toFixed(4));
}, [durMode, durD, durH, durM, durS]);
if (!open) return null;
// ── Derived ──────────────────────────────────────────────────────────────────
const selectedGroup = GPU_GROUPS.find(g => g.key === gpuKey) || null;
const filteredTypes = selectedGroup
? allTypes.filter(t => selectedGroup.families.includes(t.id.split(".")[0]))
.sort((a, b) => (a.vcpu || 0) - (b.vcpu || 0))
: [];
const inst = allTypes.find(i => i.id === instanceId) || { spot7d: 0, spotCurrent: 0, spotByAz: {}, vcpu: 0 };
const azMap = platform === "windows" ? winAzPrices : (inst.spotByAz || {});
const azOptions = Object.entries(azMap).sort((a, b) => a[1].current - b[1].current);
const effectiveAz = az || (azOptions[0]?.[0] ?? "");
const azData = azMap[effectiveAz] || {};
const spotPrice = azData.current || inst.spotCurrent || inst.spot7d || 0;
const cost = spotPrice * hours;
const HOURS_CAP = 336;
const maxHrs = Math.min(HOURS_CAP, spotPrice > 0 ? Math.floor(free / spotPrice) : HOURS_CAP);
const overBudget = free <= 0 || cost > free;
const filteredAmis = amis.filter(a => a.platform === platform);
const isCustomAmi = amiId === "__custom__";
const customValid = isCustomAmi ? AMI_RE.test(customAmi.trim()) : true;
const effectiveAmiId = isCustomAmi ? customAmi.trim() : amiId;
const ami = isCustomAmi
? (customAmi.trim() ? { id: customAmi.trim(), name: customAmi.trim(), os: platform, framework: "Custom" } : null)
: amis.find(a => a.id === amiId) || null;
const priceRangeFor = (group) => {
const types = allTypes.filter(t => group.families.includes(t.id.split(".")[0]));
const prices = types.map(t => t.spot7d).filter(p => p > 0);
if (!prices.length) return null;
return `$${Math.min(...prices).toFixed(2)}–$${Math.max(...prices).toFixed(2)}/h`;
};
const canAdvance = [!!gpuKey, !!instanceId, !!(isCustomAmi ? customValid && customAmi.trim() : amiId), !!reason.trim() && !!name.trim(), false][step - 1];
const latAvg = (reg) => { const s = latency[reg]; return s && s.length ? Math.round(s.reduce((a,b)=>a+b,0)/s.length) : null; };
const latCount = (reg) => (latency[reg] || []).length;
const LAT_MAX = 10;
const msColor = (ms) => ms == null ? "var(--muted)" : ms < 60 ? "var(--lat-good)" : ms < 150 ? "var(--lat-warn)" : "var(--lat-bad)";
const _regionCandidates = compare && instanceId
? REGIONS.map(reg => ({ reg, current: compare[reg]?.[instanceId]?.current ?? Infinity, az: compare[reg]?.[instanceId]?.az ?? "" }))
.filter(r => r.current < Infinity)
: [];
const cheapestRegion = _regionCandidates.length
? _regionCandidates.reduce((a, b) => a.current < b.current ? a : b).reg
: null;
const cheapestAzOf = (reg) => compare?.[reg]?.[instanceId]?.az ?? null;
const cheapestAzLocal = azOptions.length ? azOptions[0][0] : null;
// ── Handlers ─────────────────────────────────────────────────────────────────
const retryAmis = () => {
setAmisLoading(true);
setAmisError("");
fetch(`/api/ec2/amis?region=${region}`, { credentials: "include" })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(d => {
setAmis(d);
const pf = d.filter(a => a.platform === platform);
if (pf.length) setAmiId(pf[0].id);
})
.catch(() => setAmisError("Failed to load AMIs"))
.finally(() => setAmisLoading(false));
};
const handleClose = () => {
clearDraft();
setStage("form"); setErrorMsg(""); onClose();
};
const handleBackdropClick = (e) => {
if (e.target !== e.currentTarget) return;
if (step > 1 && stage === "form") {
if (!window.confirm("Discard your launcher draft?")) return;
}
handleClose();
};
const handleReset = () => {
setStep(1); setGpuKey(null); setInstanceId(""); setPlatform("linux");
setAmiId(""); setAz(""); setReason(""); setStage("form"); setErrorMsg("");
clearDraft();
};
const handleNext = () => {
if (step === 1 && gpuKey) setStep(2);
else if (step === 2 && instanceId) setStep(3);
else if (step === 3 && amiId) setStep(4);
else if (step === 4 && reason.trim() && name.trim()) setStep(5);
};
const submit = async () => {
if (!reason.trim() || !amiId || !instanceId || overBudget || submitting) return;
setSubmitting(true);
try {
const r = await fetch("/api/ec2/launch", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instanceType: instanceId,
amiId: effectiveAmiId,
amiName: ami ? ami.name : "",
name,
region,
az: effectiveAz || undefined,
hours,
reason: reason.trim(),
platform,
diskGb,
rootDeviceName: ami?.rootDeviceName || undefined,
}),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
setErrorMsg(body.detail || `Error ${r.status}`);
setStage("error");
} else {
const data = await r.json();
setRequestId(data.requestId || "");
setStage("success");
clearDraft();
}
} catch {
setErrorMsg("Network error — try again");
setStage("error");
} finally {
setSubmitting(false);
}
};
// ── Render ───────────────────────────────────────────────────────────────────
return (
e.stopPropagation()}>
{/* Header */}
EC2-LNCH-001
REQUEST · SPOT INSTANCE
{selectedGroup && · {selectedGroup.name}}
{instanceId && · {instanceId}}
{stage === "form" && DRAFT}
{step > 1 && stage === "form" && (
)}
{/* Step indicator */}
{stage === "form" && (
{STEP_LABELS.map((label, i) => {
const n = i + 1;
const done = step > n;
const cur = step === n;
return (
{i > 0 && ——}
{ if (done) setStep(n); }}
>
{done ? "✓" : `0${n}`} · {label}
);
})}
)}
{/* ── FORM ── */}
{stage === "form" && (
<>
{/* STEP 1 — GPU Architecture */}
{step === 1 && (
01 · SELECT GPU ARCHITECTURE
{GPU_GROUPS.map(g => {
const range = priceRangeFor(g);
const avail = allTypes.some(t => g.families.includes(t.id.split(".")[0]));
return (
);
})}
CLICK A CARD TO CONTINUE · DIMMED = NO INSTANCES IN CURRENT DATA FOR {region}
)}
{/* STEP 2 — Machine Size */}
{step === 2 && (
{selectedGroup && (
{selectedGroup.name}
{selectedGroup.test ? "NO GPU · SANDBOX · <$0.02/hr" : `${selectedGroup.gpu} · ${selectedGroup.vram} · CUDA ${selectedGroup.cuda}`}
)}
02 · SELECT MACHINE SIZE SORTED BY vCPU · LINUX SPOT PRICE
{filteredTypes.length === 0 && (
NO INSTANCES AVAILABLE IN {region}
)}
{filteredTypes.map(i => (
))}
)}
{/* DISK SIZE (shown when instance is selected in step 2) */}
{step === 2 && instanceId && (
ROOT DISK · gp3
{(() => {
const t = allTypes.find(i => i.id === instanceId);
const hasStore = t?.storage?.supported && t?.storage?.totalGb > 0;
return (
<>
setDiskGb(Math.max(200, Math.min(16384, parseInt(e.target.value) || 200)))}
style={{ width: 80, textAlign: "right" }} />
GB
{hasStore && (
+ {t.storage.totalGb} GB NVMe instance store (ephemeral)
)}
{!hasStore && (
EBS-only · no instance store
)}
>
);
})()}
)}
{/* STEP 3 — OS + AMI */}
{step === 3 && (
03 · OPERATING SYSTEM & IMAGE
{[
{ key: "linux", label: "LINUX", sub: "Ubuntu / Amazon Linux · lower spot price" },
{ key: "windows", label: "WINDOWS", sub: "Windows Server 2019/2022 · higher spot price" },
].map(p => (
))}
AMI / IMAGE · {platform.toUpperCase()}
{amisLoading && (
)}
{amisError &&
{amisError}
}
{!amisLoading && !amisError && (
<>
{!isCustomAmi && ami && (
{ami.id} · {ami.created} · {ami.arch}
)}
{isCustomAmi && (
setCustomAmi(e.target.value)}
placeholder="ami-0xxxxxxxxxxxxxxxxx"
style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12, borderColor: customAmi && !customValid ? "var(--danger)" : undefined }}
autoFocus
/>
{customAmi && !customValid &&
INVALID AMI FORMAT · MUST MATCH ami-xxxxxxxxxxxxx
}
PASTE AMI ID · MUST BE IN {region}
)}
>
)}
{platform === "windows" && (
⚠ WINDOWS SPOT PRICES LOAD IN THE NEXT STEP — TYPICALLY 1.5–4× LINUX PRICE
)}
)}
{/* STEP 4 — Config */}
{step === 4 && (
{/* Left — Region + AZ */}
04 · REGION
{REGIONS.some(r => latCount(r) < LAT_MAX) && (
· PROBING...
)}
{REGIONS.map(reg => {
const avg = latAvg(reg);
const cnt = latCount(reg);
const isCheapest = cheapestRegion === reg;
const regPrice = compare?.[reg]?.[instanceId];
const limitNote = REGION_LIMITS[reg];
return (
);
})}
{REGION_LIMITS[region] && (
⚠ {REGION_LIMITS[region]}
)}
AVG LATENCY · 10 PROBES · MEASURED ON PAGE LOAD
05 · AVAILABILITY ZONE · {platform.toUpperCase()} PRICE
{platform === "windows" && Object.keys(winAzPrices).length === 0 && (
· LOADING...
)}
{azOptions.length > 0 ? (
{azOptions.map(([zone, prices]) => {
const isAzCheapest = zone === cheapestAzLocal;
const isGlobalBest = isAzCheapest && region === cheapestRegion;
return (
);
})}
) : (
{platform === "windows" ? "Windows AZ prices loading..." : "Select instance type to see AZ prices"}
)}
{/* Right — Name / Reason / Hours */}
07 · REASON / PURPOSE *
setReason(e.target.value)}
placeholder="e.g. render bake pass 07, audio stems training"
style={{ width: "100%", fontSize: 12 }}
/>
{["render bake", "training run", "stem export", "dev testing"].map(t => (
))}
08 · RUNTIME
MAX {maxHrs}H
{durMode === "slider" ? (
<>
setHours(+e.target.value)} style={{ width: "100%" }} />
1H
{formatDuration(hours)}
{maxHrs}H
>
) : (
{[["DD", durD, setDurD, 99], ["HH", durH, setDurH, 23], ["MM", durM, setDurM, 59], ["SS", durS, setDurS, 59]].map(([lbl, val, setter, max]) => (
))}
= {formatDuration(hours)}
{hours.toFixed(4)}H TOTAL
)}
COST PREVIEW · {platform.toUpperCase()}
$
{cost.toFixed(4)}
/ ${free.toFixed(4)} free
{spotPrice > 0 &&
${spotPrice.toFixed(4)}/h × {formatDuration(hours)}
}
{overBudget &&
⚠ EXCEEDS MONTHLY BUDGET
}
{!reason.trim() &&
REASON IS REQUIRED TO CONTINUE
}
)}
{/* STEP 5 — Review & Launch */}
{step === 5 && (
{/* Left — Summary */}
{selectedGroup && (
{selectedGroup.name}
{instanceId}
{selectedGroup.gpu} · {selectedGroup.vram} · CUDA {selectedGroup.cuda}
)}
HARDWARE
| GPU | {inst.gpu || "—"} |
| VRAM | {inst.vram || "—"} |
| CUDA | {inst.cuda || "—"} |
| vCPU | {inst.vcpu || "—"} |
| RAM | {inst.ram || "—"} |
| REGION | {region} |
| AZ | {effectiveAz || "—"} |
| OS | {platform.toUpperCase()}{ami ? ` · ${ami.framework}` : ""} |
{ami && | AMI | {ami.id} · {ami.created} |
}
| DISK | {diskGb} GB gp3{inst?.storage?.totalGb ? ` + ${inst.storage.totalGb} GB NVMe` : ""} |
{reason.trim() && (
)}
⚠ Spot instances may be interrupted with 2-minute notice. Save work to S3.
{/* Right — Cost + Compare + Quota */}
COST · PROJECTED · {platform.toUpperCase()}
$
{cost.toFixed(4)}
/ ${free.toFixed(4)} FREE
{overBudget ? "⚠ EXCEEDS FREE BUDGET" : "✓ WITHIN MONTHLY BUDGET"}
${spotPrice.toFixed(4)}/h × {formatDuration(hours)}
{compare && instanceId && (
REGION COMPARE · {instanceId} · LINUX
| REGION |
NOW |
7D MED |
LATENCY |
|
{REGIONS.map(reg => {
const entry = compare[reg]?.[instanceId];
const avg = latAvg(reg);
const isCheapest = cheapestRegion === reg;
return (
|
{reg}
{isCheapest && CHEAPEST}
|
{entry ? `$${entry.current.toFixed(4)}` : "—"} |
{entry ? `$${entry.median7d.toFixed(4)}` : "—"} |
{avg != null ? `${avg}ms` : "—"} |
{entry && reg !== region && (
)}
|
);
})}
LINUX PRICING · CACHED 1H
)}
{quota && quota.length > 0 && instanceId && (
SPOT QUOTA · {region}
{quota.map(q => {
const relevant = instanceId.charAt(0).toUpperCase() === q.family.charAt(0);
const capacity = (q.vcpus && inst.vcpu) ? Math.floor(q.vcpus / inst.vcpu) : null;
return (
| {q.label} |
{q.vcpus != null ? `${q.vcpus} vCPU` : "—"} |
{capacity != null ? `~${capacity}× max` : ""} |
);
})}
MAX CONCURRENT · NOT CURRENT USAGE
)}
{!instanceId &&
SELECT GPU + SIZE
}
{!reason.trim() &&
REASON IS REQUIRED
}
{!amiId &&
SELECT AN AMI
}
)}
{/* Bottom nav */}
{step > 1 && (
)}
{step < 5 && (
)}
{step === 5 && (
)}
>
)}
{/* ── SUCCESS ── */}
{stage === "success" && (
● REQUEST SUBMITTED
Spinning up.
Request {requestId || "—"} queued.
{instanceId} · {effectiveAz || region} · {formatDuration(hours)} · ~${cost.toFixed(4)}.
Reason: "{reason}"
SSH ACCESS
ssh ubuntu@<public-ip>
Public IP appears in the EC2 panel once the instance is running. Open the instance from § SERVERS to connect.
)}
{/* ── ERROR ── */}
{stage === "error" && (
⚠ REQUEST FAILED
Something went wrong.
{errorMsg}
)}
);
}
window.SEIS_LAUNCHER = { EC2Launcher };