// 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 */}
06 · NAME TAG
setName(e.target.value)} style={{ width: "100%" }} autoFocus />
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]) => (
{lbl}
setter(Math.max(0, Math.min(max, +e.target.value || 0)))} style={{ width: "100%", textAlign: "center", fontSize: 15, padding: "8px 4px" }} />
))}
= {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
{ami && }
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.id} · {ami.created}
DISK{diskGb} GB gp3{inst?.storage?.totalGb ? ` + ${inst.storage.totalGb} GB NVMe` : ""}
{reason.trim() && (
REASON
"{reason}"
)}
⚠ 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
{REGIONS.map(reg => { const entry = compare[reg]?.[instanceId]; const avg = latAvg(reg); const isCheapest = cheapestRegion === reg; return ( ); })}
REGION NOW 7D MED LATENCY
{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.
Open AWS Console → {region}
)} {/* ── ERROR ── */} {stage === "error" && (
⚠ REQUEST FAILED
Something went wrong.
{errorMsg}
)}
); } window.SEIS_LAUNCHER = { EC2Launcher };