Skip to content

Commit 8578cc0

Browse files
feat: add docker-compose import feature (#135)
* feat: support multiple query tabs in query workbench * feat: add --write flag to enable data mutations * feat: add docker-compose import feature --------- Co-authored-by: Mananwebdev160408 <guptamanan576@gmail.com>
1 parent db119a5 commit 8578cc0

1 file changed

Lines changed: 366 additions & 1 deletion

File tree

frontend/src/components/views/DockerRunnerView.tsx

Lines changed: 366 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ export default function DockerRunnerView({
139139
]);
140140
const [composeModalOpen, setComposeModalOpen] = useState(false);
141141
const [composeYaml, setComposeYaml] = useState("");
142+
const [importModalOpen, setImportModalOpen] = useState(false);
143+
const [importYaml, setImportYaml] = useState("");
144+
const [importError, setImportError] = useState("");
142145
const [saveStatus, setSaveStatus] = useState("");
143146
const [saveError, setSaveError] = useState(false);
144147

@@ -385,7 +388,184 @@ export default function DockerRunnerView({
385388
});
386389
return yaml;
387390
};
391+
const handleImportCompose = () => {
392+
setImportError("");
393+
try {
394+
const lines = importYaml.split("\n");
395+
const newConfigs: ContainerConfig[] = [];
396+
let currentService: Partial<ContainerConfig> | null = null;
397+
let inServices = false;
398+
let inPorts = false;
399+
let inEnv = false;
400+
let inVolumes = false;
401+
402+
for (const rawLine of lines) {
403+
const line = rawLine;
404+
const trimmed = line.trimEnd();
405+
const indent = line.length - line.trimStart().length;
406+
407+
if (trimmed.trim() === "services:") {
408+
inServices = true;
409+
continue;
410+
}
411+
412+
if (
413+
inServices &&
414+
indent === 2 &&
415+
trimmed.trim().endsWith(":") &&
416+
!trimmed.trim().startsWith("-")
417+
) {
418+
if (currentService?.imageName) {
419+
newConfigs.push({
420+
key: Math.random().toString(36).slice(2),
421+
imageSearch: "",
422+
searchResults: [],
423+
isSearching: false,
424+
showSearchDropdown: false,
425+
tags: [],
426+
isLoadingTags: false,
427+
imageName: currentService.imageName || "",
428+
selectedTag: "",
429+
containerName: currentService.containerName || "",
430+
ports: currentService.ports || [],
431+
volumes: currentService.volumes || [],
432+
env: currentService.env || [],
433+
command: currentService.command || "",
434+
tty: currentService.tty || false,
435+
});
436+
}
437+
currentService = { ports: [], env: [], volumes: [] };
438+
inPorts = false;
439+
inEnv = false;
440+
inVolumes = false;
441+
continue;
442+
}
443+
444+
if (!currentService) continue;
445+
446+
const t = trimmed.trim();
447+
448+
if (indent === 4) {
449+
if (t.startsWith("image:")) {
450+
currentService.imageName = t.replace("image:", "").trim();
451+
inPorts = false;
452+
inEnv = false;
453+
inVolumes = false;
454+
} else if (t.startsWith("container_name:")) {
455+
currentService.containerName = t
456+
.replace("container_name:", "")
457+
.trim();
458+
inPorts = false;
459+
inEnv = false;
460+
inVolumes = false;
461+
} else if (t.startsWith("command:")) {
462+
currentService.command = t.replace("command:", "").trim();
463+
inPorts = false;
464+
inEnv = false;
465+
inVolumes = false;
466+
} else if (t === "ports:") {
467+
inPorts = true;
468+
inEnv = false;
469+
inVolumes = false;
470+
} else if (t === "environment:") {
471+
inEnv = true;
472+
inPorts = false;
473+
inVolumes = false;
474+
} else if (t === "volumes:") {
475+
inVolumes = true;
476+
inPorts = false;
477+
inEnv = false;
478+
} else {
479+
inPorts = false;
480+
inEnv = false;
481+
inVolumes = false;
482+
}
483+
}
484+
485+
if (indent === 6 && t.startsWith("-")) {
486+
const val = t
487+
.slice(1)
488+
.trim()
489+
.replace(/^["']|["']$/g, "");
490+
if (inPorts) {
491+
const parts = val.split(":");
492+
if (parts.length >= 2) {
493+
const proto = val.includes("/udp") ? "udp" : "tcp";
494+
currentService.ports!.push({
495+
host: parts[0],
496+
container: parts[1].replace("/udp", "").replace("/tcp", ""),
497+
protocol: proto,
498+
});
499+
}
500+
} else if (inEnv) {
501+
const eqIdx = val.indexOf("=");
502+
if (eqIdx > -1) {
503+
currentService.env!.push({
504+
key: val.slice(0, eqIdx),
505+
value: val.slice(eqIdx + 1),
506+
});
507+
}
508+
} else if (inVolumes) {
509+
const vParts = val.split(":");
510+
if (vParts.length >= 2) {
511+
currentService.volumes!.push({
512+
hostPath: vParts[0],
513+
containerPath: vParts[1],
514+
});
515+
}
516+
}
517+
}
518+
}
519+
520+
// Push last service
521+
if (currentService?.imageName) {
522+
newConfigs.push({
523+
key: Math.random().toString(36).slice(2),
524+
imageSearch: "",
525+
searchResults: [],
526+
isSearching: false,
527+
showSearchDropdown: false,
528+
tags: [],
529+
isLoadingTags: false,
530+
imageName: currentService.imageName || "",
531+
selectedTag: "",
532+
containerName: currentService.containerName || "",
533+
ports: currentService.ports || [],
534+
volumes: currentService.volumes || [],
535+
env: currentService.env || [],
536+
command: currentService.command || "",
537+
tty: currentService.tty || false,
538+
});
539+
}
540+
541+
if (newConfigs.length === 0) {
542+
setImportError(
543+
"No valid services found in the YAML. Make sure it has a 'services:' section with at least one image.",
544+
);
545+
return;
546+
}
388547

548+
setConfigs(newConfigs);
549+
setImportModalOpen(false);
550+
setImportYaml("");
551+
setImportError("");
552+
} catch (err: any) {
553+
setImportError(
554+
"Failed to parse YAML: " + (err.message || "Unknown error"),
555+
);
556+
}
557+
};
558+
559+
const handleImportFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
560+
const file = e.target.files?.[0];
561+
if (!file) return;
562+
const reader = new FileReader();
563+
reader.onload = (ev) => {
564+
setImportYaml((ev.target?.result as string) || "");
565+
setImportError("");
566+
};
567+
reader.readAsText(file);
568+
};
389569
const handleComposeClick = () => {
390570
const yaml = generateComposeYaml();
391571
setComposeYaml(yaml);
@@ -1348,6 +1528,28 @@ export default function DockerRunnerView({
13481528
gap: "16px",
13491529
}}
13501530
>
1531+
<button
1532+
className="control-btn"
1533+
type="button"
1534+
onClick={() => {
1535+
setImportYaml("");
1536+
setImportError("");
1537+
setImportModalOpen(true);
1538+
}}
1539+
style={{
1540+
height: "42px",
1541+
padding: "0 24px",
1542+
fontSize: "0.85rem",
1543+
fontWeight: 700,
1544+
border: "1px solid var(--line-strong)",
1545+
}}
1546+
>
1547+
<FileCodeIcon
1548+
size={14}
1549+
style={{ marginRight: 7, verticalAlign: "middle" }}
1550+
/>{" "}
1551+
Import Compose File
1552+
</button>
13511553
<button
13521554
className="control-btn restart-btn"
13531555
type="button"
@@ -1382,7 +1584,170 @@ export default function DockerRunnerView({
13821584
<RocketIcon size={14} style={{ marginRight: 8 }} /> Launch Containers
13831585
</button>
13841586
</div>
1385-
1587+
{/* DOCKER COMPOSE IMPORT MODAL */}
1588+
{importModalOpen && (
1589+
<div
1590+
style={{
1591+
position: "fixed",
1592+
top: 0,
1593+
left: 0,
1594+
right: 0,
1595+
bottom: 0,
1596+
background: "rgba(0,0,0,0.8)",
1597+
zIndex: 100,
1598+
display: "flex",
1599+
alignItems: "center",
1600+
justifyContent: "center",
1601+
padding: "24px",
1602+
}}
1603+
>
1604+
<div
1605+
className="metric-card"
1606+
style={{
1607+
width: "100%",
1608+
maxWidth: "680px",
1609+
padding: "24px",
1610+
display: "flex",
1611+
flexDirection: "column",
1612+
gap: "20px",
1613+
border: "1px solid var(--line-strong)",
1614+
background: "var(--surface)",
1615+
boxShadow: "var(--shadow-technical)",
1616+
borderRadius: "var(--radius-lg)",
1617+
}}
1618+
>
1619+
<div
1620+
style={{
1621+
display: "flex",
1622+
justifyContent: "space-between",
1623+
alignItems: "center",
1624+
}}
1625+
>
1626+
<h3 style={{ margin: 0, fontSize: "1.2rem", fontWeight: 800 }}>
1627+
Import docker-compose.yml
1628+
</h3>
1629+
<button
1630+
type="button"
1631+
onClick={() => setImportModalOpen(false)}
1632+
style={{
1633+
background: "transparent",
1634+
color: "var(--text-muted)",
1635+
border: "none",
1636+
fontSize: "1.4rem",
1637+
cursor: "pointer",
1638+
}}
1639+
>
1640+
<CloseIcon size={16} />
1641+
</button>
1642+
</div>
1643+
<p
1644+
style={{
1645+
margin: 0,
1646+
fontSize: "0.85rem",
1647+
color: "var(--text-muted)",
1648+
}}
1649+
>
1650+
Paste your <code>docker-compose.yml</code> content below or upload
1651+
a file. Services will be loaded into the launcher.
1652+
</p>
1653+
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
1654+
<label
1655+
style={{
1656+
fontSize: "0.82rem",
1657+
fontWeight: 600,
1658+
color: "var(--accent)",
1659+
cursor: "pointer",
1660+
padding: "6px 14px",
1661+
border: "1px solid var(--accent)",
1662+
borderRadius: "var(--radius-sm)",
1663+
}}
1664+
>
1665+
Upload File
1666+
<input
1667+
type="file"
1668+
accept=".yml,.yaml"
1669+
onChange={handleImportFileUpload}
1670+
style={{ display: "none" }}
1671+
/>
1672+
</label>
1673+
<span style={{ fontSize: "0.8rem", color: "var(--text-muted)" }}>
1674+
or paste below
1675+
</span>
1676+
</div>
1677+
<textarea
1678+
value={importYaml}
1679+
onChange={(e) => {
1680+
setImportYaml(e.target.value);
1681+
setImportError("");
1682+
}}
1683+
placeholder={
1684+
"version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - \"80:80\""
1685+
}
1686+
style={{
1687+
width: "100%",
1688+
minHeight: "240px",
1689+
fontFamily: "var(--font-mono)",
1690+
fontSize: "0.82rem",
1691+
background: "var(--bg)",
1692+
color: "var(--accent)",
1693+
border: "1px solid var(--line)",
1694+
borderRadius: "var(--radius-sm)",
1695+
padding: "12px",
1696+
resize: "vertical",
1697+
boxSizing: "border-box",
1698+
}}
1699+
/>
1700+
{importError && (
1701+
<div
1702+
style={{
1703+
fontSize: "0.82rem",
1704+
color: "var(--danger)",
1705+
padding: "10px 14px",
1706+
background: "rgba(255,69,58,0.05)",
1707+
border: "1px solid rgba(255,69,58,0.2)",
1708+
borderRadius: "var(--radius-sm)",
1709+
}}
1710+
>
1711+
{importError}
1712+
</div>
1713+
)}
1714+
<div
1715+
style={{
1716+
display: "flex",
1717+
justifyContent: "flex-end",
1718+
gap: "12px",
1719+
}}
1720+
>
1721+
<button
1722+
type="button"
1723+
className="control-btn stop-btn"
1724+
onClick={() => setImportModalOpen(false)}
1725+
style={{
1726+
padding: "0 16px",
1727+
height: "36px",
1728+
fontSize: "0.82rem",
1729+
}}
1730+
>
1731+
Cancel
1732+
</button>
1733+
<button
1734+
type="button"
1735+
className="control-btn start-btn"
1736+
onClick={handleImportCompose}
1737+
style={{
1738+
padding: "0 20px",
1739+
height: "36px",
1740+
fontSize: "0.82rem",
1741+
fontWeight: 700,
1742+
}}
1743+
>
1744+
<RocketIcon size={13} style={{ marginRight: 6 }} /> Load
1745+
Services
1746+
</button>
1747+
</div>
1748+
</div>
1749+
</div>
1750+
)}
13861751
{/* DOCKER COMPOSE GENERATION MODAL */}
13871752
{composeModalOpen && (
13881753
<div

0 commit comments

Comments
 (0)