diff --git a/internal/web/handlers_pages_test.go b/internal/web/handlers_pages_test.go
index a00a182..77e7e96 100644
--- a/internal/web/handlers_pages_test.go
+++ b/internal/web/handlers_pages_test.go
@@ -27,6 +27,12 @@ func TestServer_routes_smoke(t *testing.T) {
{"/static/style.css", http.StatusOK, ".table-modal"},
{"/static/app.js", http.StatusOK, "/api/table?"},
{"/static/app.js", http.StatusOK, "openTableModal"},
+ {"/static/app.js", http.StatusOK, "GENERATED_DRAFT_KEY"},
+ {"/static/app.js", http.StatusOK, "GRAPH_ROUTE_KEY"},
+ {"/static/app.js", http.StatusOK, "route-step"},
+ {"/static/app.js", http.StatusOK, "routeColorFor"},
+ {"/static/style.css", http.StatusOK, ".result-shell"},
+ {"/static/style.css", http.StatusOK, ".ws-route-toggle"},
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
diff --git a/internal/web/runners.go b/internal/web/runners.go
index b77826b..b85befb 100644
--- a/internal/web/runners.go
+++ b/internal/web/runners.go
@@ -127,15 +127,22 @@ func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc
jc.Phase("insert")
totalRows := 0
+ tableCounts := make(map[string]int, len(targetTables))
+ var dryRunSQL strings.Builder
for idx, tableName := range targetTables {
tableRows := data[tableName]
+ tableCounts[tableName] = len(tableRows)
log.Info().Str("table", tableName).Int("rows", len(tableRows)).Msg("Seeding table")
- if !req.DryRun {
- for i := 0; i < len(tableRows); i += req.BatchSize {
- end := i + req.BatchSize
- if end > len(tableRows) {
- end = len(tableRows)
- }
+ for i := 0; i < len(tableRows); i += req.BatchSize {
+ end := i + req.BatchSize
+ if end > len(tableRows) {
+ end = len(tableRows)
+ }
+ if req.DryRun {
+ query, _ := db.BuildBatchInsert(tableName, tableRows[i:end], sess.DBType)
+ dryRunSQL.WriteString(query)
+ dryRunSQL.WriteString(";\n")
+ } else {
query, values := db.BuildBatchInsert(tableName, tableRows[i:end], sess.DBType)
if _, err := conn.ExecContext(ctx, query, values...); err != nil {
return nil, fmt.Errorf("insert into %s: %w", tableName, err)
@@ -156,14 +163,20 @@ func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc
for t := range autoSelected {
autoList = append(autoList, t)
}
- return map[string]any{
- "tables": len(targetTables),
- "totalRows": totalRows,
- "durationMs": elapsed.Milliseconds(),
- "dryRun": req.DryRun,
- "order": targetTables,
- "auto": autoList,
- }, nil
+ result := map[string]any{
+ "tables": len(targetTables),
+ "totalRows": totalRows,
+ "durationMs": elapsed.Milliseconds(),
+ "dryRun": req.DryRun,
+ "order": targetTables,
+ "auto": autoList,
+ "tableCounts": tableCounts,
+ }
+ if req.DryRun {
+ result["output"] = dryRunSQL.String()
+ result["format"] = "sql"
+ }
+ return result, nil
}
// GapsRequest mirrors the gaps CLI flags. Tables, when set, restricts the
@@ -324,15 +337,29 @@ func (s *Server) runGenerate(ctx context.Context, sess *Session, req GenerateReq
if err != nil {
return nil, err
}
+ tableCounts, totalRows := tableRowCounts(data, targetTables)
jc.Phase("done")
log.Info().Int("tables", len(targetTables)).Str("format", req.Format).Msg("Generation complete")
return map[string]any{
- "output": output,
- "format": req.Format,
- "tables": targetTables,
+ "output": output,
+ "format": req.Format,
+ "tables": targetTables,
+ "tableCounts": tableCounts,
+ "totalRows": totalRows,
}, nil
}
+func tableRowCounts(data map[string][]map[string]any, sortedTables []string) (map[string]int, int) {
+ counts := make(map[string]int, len(sortedTables))
+ total := 0
+ for _, tableName := range sortedTables {
+ n := len(data[tableName])
+ counts[tableName] = n
+ total += n
+ }
+ return counts, total
+}
+
func encodeData(data map[string][]map[string]any, sortedTables []string, format, dbType string) (string, error) {
switch strings.ToLower(format) {
case "json":
@@ -467,9 +494,17 @@ func (s *Server) runExport(_ context.Context, sess *Session, req ExportRequest,
output = sb.String()
}
jc.Phase("done")
+ tableNames := make([]string, 0, len(data))
+ for tableName := range data {
+ tableNames = append(tableNames, tableName)
+ }
+ tableCounts, totalRows := tableRowCounts(data, tableNames)
log.Info().Str("format", req.Format).Int("tables", len(data)).Msg("Export complete")
return map[string]any{
- "output": output,
- "format": req.Format,
+ "output": output,
+ "format": req.Format,
+ "tables": len(data),
+ "tableCounts": tableCounts,
+ "totalRows": totalRows,
}, nil
}
diff --git a/internal/web/runners_test.go b/internal/web/runners_test.go
new file mode 100644
index 0000000..cefb27f
--- /dev/null
+++ b/internal/web/runners_test.go
@@ -0,0 +1,28 @@
+package web
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestTableRowCounts(t *testing.T) {
+ data := map[string][]map[string]any{
+ "users": {
+ {"id": 1},
+ {"id": 2},
+ },
+ "orders": {
+ {"id": 10},
+ },
+ }
+
+ counts, total := tableRowCounts(data, []string{"users", "orders", "missing"})
+
+ want := map[string]int{"users": 2, "orders": 1, "missing": 0}
+ if !reflect.DeepEqual(counts, want) {
+ t.Fatalf("counts = %+v, want %+v", counts, want)
+ }
+ if total != 3 {
+ t.Fatalf("total = %d, want 3", total)
+ }
+}
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
index e7b9cf5..dce5591 100644
--- a/internal/web/static/app.js
+++ b/internal/web/static/app.js
@@ -4,6 +4,8 @@
"use strict";
const PRESET_KEY = "seedstorm.connections.v1";
+ const GENERATED_DRAFT_KEY = "seedstorm.generatedData.v1";
+ const GRAPH_ROUTE_KEY = "seedstorm.graphRoute.v1";
// ── localStorage presets for the connection form ───────────────────────
function loadPresets() {
@@ -262,6 +264,7 @@
const form = document.getElementById("run-form");
if (!form) return;
const endpoint = form.dataset.endpoint;
+ hydrateExportDraft(form, endpoint);
document.getElementById("job-clear")?.addEventListener("click", () => {
resetPhases();
document.getElementById("job-result").innerHTML = "";
@@ -292,27 +295,344 @@
const r = job.result || {};
const out = document.getElementById("job-result");
if (!out) return;
- if (typeof r.output === "string") {
- const pre = document.createElement("pre");
- pre.className = "job-log"; pre.textContent = r.output;
- out.appendChild(pre);
- const dl = document.createElement("a");
- dl.className = "btn-ghost";
- dl.href = "data:text/plain;charset=utf-8," + encodeURIComponent(r.output);
- dl.download = `seedstorm-${j.name}.${r.format || "txt"}`;
- dl.textContent = "Download";
- out.appendChild(dl);
- }
- if (typeof r.yaml === "string") {
- const pre = document.createElement("pre");
- pre.className = "job-log"; pre.textContent = r.yaml;
- out.appendChild(pre);
- }
+ renderJobResult(out, r, job.name || j.name || "run");
},
});
});
}
+ function hydrateExportDraft(form, endpoint) {
+ if (endpoint !== "/api/export") return;
+ const input = form.querySelector('[name="dataYaml"]');
+ if (!input || input.value.trim()) return;
+ let draft = null;
+ try { draft = JSON.parse(sessionStorage.getItem(GENERATED_DRAFT_KEY) || "null"); }
+ catch (_) { draft = null; }
+ if (!draft || !draft.yaml) return;
+ input.value = draft.yaml;
+ const note = document.createElement("div");
+ note.className = "handoff-note";
+ note.innerHTML = 'Generated data loaded.Review it, choose a format, then export.';
+ form.insertBefore(note, form.firstChild);
+ }
+
+ function renderJobResult(out, result, jobName) {
+ out.innerHTML = "";
+ const shell = document.createElement("section");
+ shell.className = "result-shell";
+
+ const summary = resultSummaryItems(result, jobName);
+ if (summary.length > 0) {
+ const grid = document.createElement("div");
+ grid.className = "result-summary";
+ summary.forEach((item) => {
+ const card = document.createElement("div");
+ card.className = "result-stat";
+ const value = document.createElement("strong");
+ value.textContent = item.value;
+ const label = document.createElement("span");
+ label.textContent = item.label;
+ card.append(value, label);
+ grid.appendChild(card);
+ });
+ shell.appendChild(grid);
+ }
+
+ if (Array.isArray(result.order) && result.order.length > 0) {
+ shell.appendChild(renderTableList("Run order", result.order));
+ } else if (Array.isArray(result.tables) && result.tables.length > 0) {
+ shell.appendChild(renderTableList("Generated tables", result.tables));
+ }
+
+ if (Array.isArray(result.gapTables)) {
+ shell.appendChild(renderTableList("Empty tables", result.gapTables, "No empty tables found."));
+ }
+
+ const output = typeof result.output === "string" ? result.output : (typeof result.yaml === "string" ? result.yaml : "");
+ if (output) {
+ shell.appendChild(renderOutputPanel(output, result, jobName));
+ if ((result.format || "yaml").toLowerCase() === "yaml" && jobName === "generate") {
+ persistGeneratedDraft(output);
+ }
+ }
+
+ out.appendChild(shell);
+ }
+
+ function resultSummaryItems(result, jobName) {
+ const items = [];
+ const fmt = result.format ? String(result.format).toUpperCase() : "";
+ if (result.dryRun) items.push({ label: "mode", value: "Dry-run" });
+ else items.push({ label: "run", value: jobName });
+ if (fmt) items.push({ label: "format", value: fmt });
+ if (typeof result.totalRows === "number") items.push({ label: "rows", value: formatCount(result.totalRows) });
+ if (typeof result.tables === "number") items.push({ label: "tables", value: String(result.tables) });
+ else if (Array.isArray(result.tables)) items.push({ label: "tables", value: String(result.tables.length) });
+ if (Array.isArray(result.auto) && result.auto.length > 0) items.push({ label: "auto-required", value: String(result.auto.length) });
+ if (typeof result.durationMs === "number") items.push({ label: "duration", value: result.durationMs < 1000 ? `${result.durationMs}ms` : `${(result.durationMs / 1000).toFixed(1)}s` });
+ return items;
+ }
+
+ function renderTableList(title, tables, emptyText) {
+ const wrap = document.createElement("div");
+ wrap.className = "result-list";
+ const head = document.createElement("div");
+ head.className = "result-list-head";
+ const strong = document.createElement("strong");
+ strong.textContent = title;
+ const count = document.createElement("span");
+ count.className = "muted small";
+ count.textContent = `${tables.length} ${tables.length === 1 ? "table" : "tables"}`;
+ head.append(strong, count);
+ wrap.appendChild(head);
+ if (tables.length === 0) {
+ const empty = document.createElement("p");
+ empty.className = "muted small empty-hint";
+ empty.textContent = emptyText || "Nothing to show.";
+ wrap.appendChild(empty);
+ return wrap;
+ }
+ const row = document.createElement("div");
+ row.className = "result-table-chips";
+ tables.forEach((tableName) => {
+ const chip = document.createElement("span");
+ chip.textContent = tableName;
+ row.appendChild(chip);
+ });
+ wrap.appendChild(row);
+ return wrap;
+ }
+
+ function renderOutputPanel(output, result, jobName) {
+ const panel = document.createElement("div");
+ panel.className = "result-output";
+ const toolbar = document.createElement("div");
+ toolbar.className = "result-output-toolbar";
+ const title = document.createElement("div");
+ title.className = "result-output-title";
+ const label = document.createElement("strong");
+ label.textContent = result.dryRun ? "SQL preview" : "Output";
+ const meta = document.createElement("span");
+ meta.className = "muted small";
+ meta.textContent = `${formatBytes(output.length)} · ${(result.format || "txt").toUpperCase()}`;
+ title.append(label, meta);
+
+ const actions = document.createElement("div");
+ actions.className = "row";
+ const view = document.createElement("button");
+ view.className = "btn-primary";
+ view.type = "button";
+ view.textContent = "View full";
+ view.addEventListener("click", () => openResultModal(output, result, jobName));
+ const copy = document.createElement("button");
+ copy.className = "btn-ghost";
+ copy.type = "button";
+ copy.textContent = "Copy";
+ copy.addEventListener("click", async () => {
+ await copyText(output);
+ copy.textContent = "Copied";
+ setTimeout(() => { copy.textContent = "Copy"; }, 1400);
+ });
+ const dl = document.createElement("a");
+ dl.className = "btn-ghost";
+ dl.href = "data:text/plain;charset=utf-8," + encodeURIComponent(output);
+ dl.download = `seedstorm-${jobName}.${result.format || "txt"}`;
+ dl.textContent = "Download";
+ actions.append(view, copy, dl);
+
+ if ((result.format || "").toLowerCase() === "yaml" && jobName === "generate") {
+ const exportBtn = document.createElement("button");
+ exportBtn.className = "btn-ghost";
+ exportBtn.type = "button";
+ exportBtn.textContent = "Export this";
+ exportBtn.addEventListener("click", () => {
+ persistGeneratedDraft(output);
+ window.location.href = "/export";
+ });
+ actions.appendChild(exportBtn);
+ }
+
+ toolbar.append(title, actions);
+ const pre = document.createElement("pre");
+ pre.className = "job-log result-pre";
+ pre.textContent = output;
+ panel.append(toolbar, pre);
+ return panel;
+ }
+
+ function openResultModal(output, result, jobName) {
+ let modal = document.getElementById("result-modal");
+ if (!modal) {
+ modal = document.createElement("div");
+ modal.id = "result-modal";
+ modal.className = "result-modal";
+ modal.hidden = true;
+ modal.innerHTML = `
+
+
+
+
+
output preview
+
Output
+
+
+
+
+
+
+
+ `;
+ document.body.appendChild(modal);
+ modal.querySelector("[data-result-modal-close]")?.addEventListener("click", closeResultModal);
+ modal.querySelector("#result-modal-close")?.addEventListener("click", closeResultModal);
+ modal.querySelectorAll("[data-result-tab]").forEach((tab) => {
+ tab.addEventListener("click", () => activateResultTab(modal, tab.dataset.resultTab));
+ });
+ }
+
+ const format = result.format || "txt";
+ modal.dataset.output = output;
+ modal.querySelector("#result-modal-kind").textContent = result.dryRun ? "dry-run sql" : `${jobName} output`;
+ modal.querySelector("#result-modal-title").textContent = result.dryRun ? "Dry-run SQL preview" : "Generated output";
+ modal.querySelector("#result-modal-meta").textContent = `${formatBytes(output.length)} · ${String(format).toUpperCase()}`;
+ modal.querySelector("#result-modal-overview").innerHTML = renderResultOverview(result, output, jobName);
+ modal.querySelector("#result-modal-tables").innerHTML = renderResultTables(result);
+ modal.querySelector("#result-modal-output").textContent = output;
+ activateResultTab(modal, "overview");
+ const dl = modal.querySelector("#result-modal-download");
+ dl.href = "data:text/plain;charset=utf-8," + encodeURIComponent(output);
+ dl.download = `seedstorm-${jobName}.${format}`;
+ const copy = modal.querySelector("#result-modal-copy");
+ copy.onclick = async () => {
+ await copyText(output);
+ copy.textContent = "Copied";
+ setTimeout(() => { copy.textContent = "Copy"; }, 1400);
+ };
+ modal.hidden = false;
+ document.body.classList.add("modal-open");
+ }
+
+ function activateResultTab(modal, tabName) {
+ modal.querySelectorAll("[data-result-tab]").forEach((tab) => {
+ tab.classList.toggle("active", tab.dataset.resultTab === tabName);
+ });
+ modal.querySelectorAll("[data-result-pane]").forEach((pane) => {
+ pane.classList.toggle("active", pane.dataset.resultPane === tabName);
+ });
+ }
+
+ function renderResultOverview(result, output, jobName) {
+ const order = Array.isArray(result.order) ? result.order : (Array.isArray(result.tables) ? result.tables : []);
+ const auto = new Set(Array.isArray(result.auto) ? result.auto : []);
+ const explicit = Math.max(0, order.length - auto.size);
+ const rows = typeof result.totalRows === "number" ? formatCount(result.totalRows) : "n/a";
+ const relationText = auto.size > 0
+ ? `${auto.size} parent ${auto.size === 1 ? "table was" : "tables were"} added because selected tables depend on them.`
+ : "No extra parent tables were required for this run scope.";
+ const primary = result.dryRun ? "No database writes will run from this preview." : "This output is ready to copy or download.";
+ const cards = [
+ ["Run", result.dryRun ? "Dry-run" : jobName],
+ ["Rows", rows],
+ ["Tables", String(order.length || result.tables || 0)],
+ ["Output", `${formatBytes(output.length)} · ${(result.format || "txt").toUpperCase()}`],
+ ].map(([label, value]) => `
+
+ ${escapeHTML(value)}
+ ${escapeHTML(label)}
+
+ `).join("");
+ return `
+ ${cards}
+
+ ${escapeHTML(primary)}
+ ${escapeHTML(relationText)}
+
+
+
${explicit}explicit or default target tables
+
+
${auto.size}auto-required FK parents
+
+
${order.length}tables in execution order
+
+ `;
+ }
+
+ function renderResultTables(result) {
+ const order = Array.isArray(result.order) ? result.order : (Array.isArray(result.tables) ? result.tables : []);
+ const auto = new Set(Array.isArray(result.auto) ? result.auto : []);
+ const counts = result.tableCounts || {};
+ if (order.length === 0) {
+ return 'No table list was returned for this run.
';
+ }
+ const rows = order.map((tableName, idx) => {
+ const kind = auto.has(tableName) ? "required parent" : "target";
+ const rowCount = typeof counts[tableName] === "number" ? formatCount(counts[tableName]) : "n/a";
+ return `
+
+ | ${idx + 1} |
+ ${escapeHTML(tableName)} |
+ ${kind} |
+ ${rowCount} |
+
+ `;
+ }).join("");
+ return `
+
+ | # | Table | Relationship role | Rows |
+ ${rows}
+
+ `;
+ }
+
+ function closeResultModal() {
+ const modal = document.getElementById("result-modal");
+ if (modal) modal.hidden = true;
+ if (!document.getElementById("table-modal") || document.getElementById("table-modal").hidden) {
+ document.body.classList.remove("modal-open");
+ }
+ }
+
+ function persistGeneratedDraft(yaml) {
+ try {
+ sessionStorage.setItem(GENERATED_DRAFT_KEY, JSON.stringify({ yaml, createdAt: Date.now() }));
+ } catch (_) {}
+ }
+
+ async function copyText(text) {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ const ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.position = "fixed";
+ ta.style.opacity = "0";
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ ta.remove();
+ }
+
+ function formatBytes(chars) {
+ if (chars >= 1_000_000) return (chars / 1_000_000).toFixed(1) + " MB";
+ if (chars >= 1_000) return (chars / 1_000).toFixed(1) + " KB";
+ return `${chars} B`;
+ }
+
// ── Workspace ─────────────────────────────────────────────────────────
const ws = {
cy: null,
@@ -322,6 +642,7 @@
nodes: [], // raw graph payload
edges: [],
mode: "seed",
+ edgeRoute: loadGraphRoute(),
activeJob: null,
activeTable: null,
search: "",
@@ -369,9 +690,13 @@
document.getElementById("ws-fit")?.addEventListener("click", () => fitGraph());
document.getElementById("ws-zoom-in")?.addEventListener("click", () => zoomGraph(1.18));
document.getElementById("ws-zoom-out")?.addEventListener("click", () => zoomGraph(0.84));
+ document.querySelectorAll("[data-route]").forEach((b) => {
+ b.addEventListener("click", () => setEdgeRoute(b.dataset.route));
+ b.classList.toggle("active", b.dataset.route === ws.edgeRoute);
+ });
setupTableModal();
document.addEventListener("keydown", (ev) => {
- if (ev.key === "Escape") closeTableModal();
+ if (ev.key === "Escape") { closeTableModal(); closeResultModal(); }
if (ev.target && ["INPUT", "TEXTAREA", "SELECT"].includes(ev.target.tagName)) return;
if (ev.key === "/") {
ev.preventDefault();
@@ -406,7 +731,17 @@
const elements = [
...ws.nodes.map(n => ({ data: nodeData(n) })),
- ...ws.edges.map(e => ({ data: { id: e.id, source: e.source, target: e.target, label: e.column, nullable: e.nullable } })),
+ ...ws.edges.map((e, idx) => ({
+ data: {
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ label: e.column,
+ nullable: e.nullable,
+ routeLane: idx % 7,
+ routeColor: routeColorFor(e.source),
+ },
+ })),
];
ws.cy = cytoscape({
@@ -436,6 +771,7 @@
});
document.getElementById("ws-count-total").textContent = String(ws.nodes.length);
+ applyEdgeRoute();
updateStats();
refreshSelectionUI();
}
@@ -450,6 +786,13 @@
};
}
+ function routeColorFor(seed) {
+ const colors = ["#79d8b3", "#d8b56f", "#7ca7ff", "#df8cc8", "#8dd6e8", "#c2d16b", "#b196ff"];
+ let hash = 0;
+ for (let i = 0; i < seed.length; i++) hash = ((hash << 5) - hash) + seed.charCodeAt(i);
+ return colors[Math.abs(hash) % colors.length];
+ }
+
function formatCount(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
@@ -558,6 +901,28 @@
"arrow-scale": 0.9,
},
},
+ {
+ selector: "edge.route-smooth",
+ style: {
+ "curve-style": "unbundled-bezier",
+ },
+ },
+ {
+ selector: "edge.route-step",
+ style: {
+ "curve-style": "taxi",
+ "taxi-direction": "auto",
+ "taxi-turn": "42px",
+ "taxi-turn-min-distance": "16px",
+ },
+ },
+ {
+ selector: "edge.route-straight",
+ style: {
+ "curve-style": "bezier",
+ "control-point-step-size": 42,
+ },
+ },
{
selector: "edge[?nullable]",
style: { "line-style": "dashed", "line-color": "#4a5169", "target-arrow-color": "#4a5169" },
@@ -565,6 +930,60 @@
];
}
+ function loadGraphRoute() {
+ try {
+ const saved = localStorage.getItem(GRAPH_ROUTE_KEY);
+ if (["straight", "smooth", "step"].includes(saved)) return saved;
+ } catch (_) {}
+ return "straight";
+ }
+
+ function setEdgeRoute(route) {
+ if (!["straight", "smooth", "step"].includes(route)) return;
+ ws.edgeRoute = route;
+ try { localStorage.setItem(GRAPH_ROUTE_KEY, route); } catch (_) {}
+ document.querySelectorAll("[data-route]").forEach((b) => {
+ b.classList.toggle("active", b.dataset.route === route);
+ });
+ applyEdgeRoute();
+ }
+
+ function applyEdgeRoute() {
+ if (!ws.cy) return;
+ const smoothOffsets = [-96, -64, -32, 32, 64, 96, 128];
+ const taxiTurns = [24, 44, 64, 84, 104, 124, 144];
+ ws.cy.batch(() => {
+ ws.cy.edges()
+ .removeClass("route-straight route-smooth route-step")
+ .addClass("route-" + ws.edgeRoute);
+ ws.cy.edges().forEach((edge) => {
+ edge.removeStyle("curve-style line-color target-arrow-color control-point-distances control-point-weights control-point-step-size taxi-direction taxi-turn taxi-turn-min-distance");
+ const lane = Number(edge.data("routeLane") || 0);
+ if (ws.edgeRoute === "smooth") {
+ const color = edge.data("routeColor");
+ edge.style({
+ "curve-style": "unbundled-bezier",
+ "line-color": color,
+ "target-arrow-color": color,
+ "control-point-distances": smoothOffsets[lane] + "px",
+ "control-point-weights": lane % 2 === 0 ? "0.42" : "0.58",
+ });
+ }
+ if (ws.edgeRoute === "step") {
+ const color = edge.data("routeColor");
+ edge.style({
+ "curve-style": "taxi",
+ "line-color": color,
+ "target-arrow-color": color,
+ "taxi-direction": "rightward",
+ "taxi-turn": taxiTurns[lane] + "px",
+ "taxi-turn-min-distance": "18px",
+ });
+ }
+ });
+ });
+ }
+
// ── selection mechanics ───────────────────────────────────────────────
function toggleSelect(id) {
if (ws.auto.has(id) && !ws.selected.has(id)) {
@@ -1124,17 +1543,7 @@
function onJobEnd(job) {
if (ws.cy) ws.cy.nodes(".seeding").removeClass("seeding").addClass("done");
const out = document.getElementById("job-result");
- const r = job.result || {};
- if (typeof r.output === "string") {
- const pre = document.createElement("pre");
- pre.className = "job-log"; pre.textContent = r.output;
- out.appendChild(pre);
- }
- if (Array.isArray(r.gapTables)) {
- const div = document.createElement("div");
- div.innerHTML = `Empty tables: ${r.gapTables.length === 0 ? "none" : r.gapTables.join(", ")}`;
- out.appendChild(div);
- }
+ if (out) renderJobResult(out, job.result || {}, job.name || ws.mode);
refreshCounts();
}
@@ -1168,6 +1577,9 @@
setupConnectForm();
setupRunForm();
setupWorkspace();
+ document.addEventListener("keydown", (ev) => {
+ if (ev.key === "Escape") closeResultModal();
+ });
});
// Lightweight debug surface — useful for poking from the console and for
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
index 3ad159b..3596db5 100644
--- a/internal/web/static/style.css
+++ b/internal/web/static/style.css
@@ -171,6 +171,15 @@ input, select, textarea {
padding: 8px 10px;
font: inherit;
}
+input[type="number"] {
+ appearance: textfield;
+ -moz-appearance: textfield;
+}
+input[type="number"]::-webkit-outer-spin-button,
+input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.input-sm { padding: 4px 8px; font-size: 12px; }
textarea { font-family: var(--mono); font-size: 12px; }
@@ -208,6 +217,101 @@ textarea { font-family: var(--mono); font-size: 12px; }
.tile:hover { border-color: var(--accent); }
.tile span { color: var(--muted); font-size: 12px; }
+/* ── Run utility pages ───────────────────────────────────────────────── */
+.main:has(.run-shell) {
+ max-width: none;
+ padding: 18px;
+}
+.run-shell {
+ width: min(1440px, 100%);
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: minmax(280px, 0.62fr) minmax(560px, 1.38fr);
+ gap: 14px;
+ align-items: start;
+}
+.run-intro,
+.run-panel {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel);
+}
+.run-intro {
+ position: sticky;
+ top: 80px;
+ display: grid;
+ gap: 14px;
+ padding: 22px;
+}
+.run-intro h1 {
+ max-width: 14ch;
+ font-size: 34px;
+ line-height: 1;
+ letter-spacing: -0.04em;
+}
+.run-intro p {
+ max-width: 54ch;
+ margin: 0;
+}
+.run-notes {
+ display: grid;
+ gap: 7px;
+}
+.run-notes span {
+ padding: 8px 10px;
+ border: 1px solid rgba(231,236,232,0.08);
+ border-radius: 7px;
+ background: rgba(11,15,18,0.32);
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 12px;
+}
+.run-panel {
+ min-width: 0;
+ padding: 14px;
+}
+.run-form {
+ display: grid;
+ gap: 14px;
+}
+.run-form-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid rgba(231,236,232,0.08);
+}
+.run-form-head h2 {
+ margin: 0 0 3px;
+ font-size: 17px;
+}
+.run-form-head p {
+ margin: 0;
+}
+.run-form-head .btn-primary {
+ min-height: 42px;
+ white-space: nowrap;
+}
+.run-fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+.run-fields.compact {
+ grid-template-columns: minmax(180px, 0.4fr) minmax(160px, 0.25fr);
+}
+.run-form input,
+.run-form select,
+.run-form textarea {
+ min-height: 42px;
+}
+.run-form textarea {
+ resize: vertical;
+ min-height: 320px;
+ line-height: 1.55;
+}
+
/* ── Connect ─────────────────────────────────────────────────────────── */
.main:has(.connect-shell) {
max-width: none;
@@ -525,6 +629,288 @@ textarea { font-family: var(--mono); font-size: 12px; }
color: #d6dcec;
}
+.result-shell {
+ display: grid;
+ gap: 10px;
+ margin-top: 12px;
+}
+.result-summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
+ gap: 8px;
+}
+.result-stat {
+ display: grid;
+ gap: 3px;
+ padding: 10px 12px;
+ border: 1px solid rgba(231,236,232,0.08);
+ border-radius: 8px;
+ background: rgba(11,15,18,0.34);
+ min-width: 0;
+}
+.result-stat strong {
+ font-family: var(--mono);
+ font-size: 17px;
+ line-height: 1.1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.result-stat span {
+ color: var(--muted);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.result-list,
+.result-output,
+.handoff-note {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: rgba(11,15,18,0.28);
+}
+.result-list-head,
+.result-output-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 9px 10px;
+ border-bottom: 1px solid rgba(231,236,232,0.08);
+}
+.result-table-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 10px;
+}
+.result-table-chips span {
+ max-width: 100%;
+ padding: 3px 7px;
+ border: 1px solid rgba(231,236,232,0.08);
+ border-radius: 5px;
+ background: var(--panel-2);
+ font-family: var(--mono);
+ font-size: 11px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.result-output-title {
+ display: grid;
+ gap: 1px;
+}
+.result-output-toolbar .btn-ghost,
+.result-output-toolbar .btn-primary {
+ padding: 5px 9px;
+ font-size: 12px;
+}
+.result-pre {
+ margin: 0;
+ max-height: 460px;
+ border: none;
+ border-radius: 0 0 8px 8px;
+}
+.handoff-note {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: rgba(121,216,179,0.08);
+ border-color: rgba(121,216,179,0.22);
+}
+.result-modal[hidden] { display: none; }
+.result-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 45;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+}
+.result-modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(4,7,9,0.74);
+ backdrop-filter: blur(6px);
+}
+.result-modal-panel {
+ position: relative;
+ z-index: 1;
+ width: min(1360px, 100%);
+ height: min(88vh, 940px);
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ background: rgba(17,23,28,0.98);
+ border: 1px solid rgba(231,236,232,0.1);
+ border-radius: 14px;
+ box-shadow: 0 32px 90px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.04);
+ overflow: hidden;
+}
+.result-modal-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--line);
+}
+.result-modal-head h2 {
+ margin: 2px 0 4px;
+ font-size: 24px;
+ letter-spacing: -0.03em;
+}
+.result-modal-tabs {
+ display: flex;
+ gap: 2px;
+ padding: 0 20px;
+ border-bottom: 1px solid var(--line);
+}
+.result-modal-tab {
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ padding: 11px 14px;
+ cursor: pointer;
+ font: inherit;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+}
+.result-modal-tab:hover,
+.result-modal-tab.active {
+ color: var(--text);
+}
+.result-modal-tab.active {
+ border-bottom-color: var(--accent);
+}
+.result-modal-body {
+ min-height: 0;
+ overflow: hidden;
+}
+.result-modal-pane {
+ display: none;
+ height: 100%;
+ min-height: 0;
+ overflow: auto;
+}
+.result-modal-pane.active {
+ display: block;
+}
+.result-modal-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 10px;
+ padding: 18px 20px 10px;
+}
+.result-modal-card,
+.result-modal-callout,
+.result-modal-flow > div {
+ border: 1px solid rgba(231,236,232,0.08);
+ border-radius: 8px;
+ background: rgba(11,15,18,0.34);
+}
+.result-modal-card {
+ display: grid;
+ gap: 4px;
+ padding: 13px 14px;
+}
+.result-modal-card strong,
+.result-modal-flow strong {
+ font-family: var(--mono);
+ font-size: 20px;
+ line-height: 1.1;
+}
+.result-modal-card span,
+.result-modal-flow span {
+ color: var(--muted);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.result-modal-callout {
+ display: grid;
+ gap: 4px;
+ margin: 0 20px 12px;
+ padding: 14px;
+ background: rgba(121,216,179,0.07);
+ border-color: rgba(121,216,179,0.2);
+}
+.result-modal-callout span {
+ color: var(--muted);
+}
+.result-modal-flow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 28px minmax(0, 1fr);
+ align-items: center;
+ gap: 10px;
+ padding: 0 20px 20px;
+}
+.result-modal-flow > div {
+ display: grid;
+ gap: 5px;
+ min-height: 88px;
+ align-content: center;
+ padding: 14px;
+}
+.result-modal-flow i {
+ height: 1px;
+ background: var(--line);
+ position: relative;
+}
+.result-modal-flow i::after {
+ content: "";
+ position: absolute;
+ right: 0;
+ top: -4px;
+ width: 8px;
+ height: 8px;
+ border-top: 1px solid var(--line);
+ border-right: 1px solid var(--line);
+ transform: rotate(45deg);
+}
+.result-modal-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--mono);
+ font-size: 12px;
+}
+.result-modal-table th,
+.result-modal-table td {
+ padding: 10px 14px;
+ border-bottom: 1px solid rgba(231,236,232,0.07);
+ text-align: left;
+}
+.result-modal-table th {
+ position: sticky;
+ top: 0;
+ background: var(--panel-2);
+ color: var(--muted);
+ font-weight: 600;
+}
+.result-kind {
+ display: inline-block;
+ padding: 2px 7px;
+ border: 1px solid rgba(121,216,179,0.22);
+ border-radius: 5px;
+ color: var(--accent);
+ font-family: var(--mono);
+ font-size: 10px;
+}
+.result-kind.auto {
+ border-color: rgba(216,181,111,0.26);
+ color: var(--accent-2);
+}
+.result-modal-pre {
+ margin: 0;
+ min-height: 100%;
+ padding: 18px 20px;
+ background: #0b0d12;
+ color: #d6dcec;
+ font-family: var(--mono);
+ font-size: 12px;
+ line-height: 1.55;
+ white-space: pre;
+ tab-size: 2;
+}
+
/* Progress bar shown after the first `progress` event from the job stream. */
.job-progress-wrap {
display: flex; align-items: center; gap: 10px;
@@ -1032,6 +1418,7 @@ textarea { font-family: var(--mono); font-size: 12px; }
right: 12px;
z-index: 2;
display: flex;
+ align-items: center;
gap: 4px;
background: rgba(15,17,21,0.82);
border: 1px solid var(--line);
@@ -1045,6 +1432,23 @@ textarea { font-family: var(--mono); font-size: 12px; }
padding: 5px 8px;
font-size: 12px;
}
+.ws-route-toggle {
+ display: flex;
+ gap: 2px;
+ padding-right: 4px;
+ margin-right: 2px;
+ border-right: 1px solid rgba(231,236,232,0.08);
+}
+.ws-route-toggle .btn-ghost {
+ min-width: 0;
+ padding-inline: 9px;
+}
+.ws-route-toggle .btn-ghost.active {
+ color: #07120e;
+ background: var(--accent);
+ border-color: var(--accent);
+ font-weight: 700;
+}
.ws-legend {
position: absolute; bottom: 10px; right: 12px;
display: flex; gap: 16px; flex-wrap: wrap;
@@ -1067,32 +1471,110 @@ textarea { font-family: var(--mono); font-size: 12px; }
.ws-actionbar {
grid-column: 1 / -1;
grid-row: 2 / 3;
- display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
+ display: grid;
+ grid-template-columns: minmax(360px, auto) minmax(320px, 1fr) minmax(300px, auto) auto;
+ align-items: stretch;
+ gap: 10px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
- padding: 10px 14px;
+ padding: 10px;
+}
+.ws-run-group {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ padding: 8px;
+ border: 1px solid rgba(231,236,232,0.07);
+ border-radius: 9px;
+ background: rgba(11,15,18,0.22);
+}
+.ws-run-label {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ white-space: nowrap;
+}
+.ws-run-modes {
+ align-items: stretch;
+}
+.ws-mode {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 4px;
+ flex: 1;
+ min-width: 0;
}
-.ws-mode { display: flex; gap: 4px; padding: 3px; background: var(--panel-2); border-radius: 8px; }
.ws-mode-pill {
- padding: 6px 12px; border-radius: 6px;
- background: transparent; border: none; cursor: pointer;
- color: var(--muted); font: inherit;
- transition: background 120ms, color 120ms;
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+ padding: 8px 10px;
+ border-radius: 7px;
+ background: var(--panel-2);
+ border: 1px solid transparent;
+ cursor: pointer;
+ color: var(--muted);
+ font: inherit;
+ text-align: left;
+ transition: border-color 120ms, background 120ms, color 120ms;
+}
+.ws-mode-pill strong {
+ color: var(--text);
+ font-size: 13px;
+ line-height: 1.1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.ws-mode-pill span {
+ color: var(--muted);
+ font-size: 10px;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+.ws-mode-pill.active {
+ background: rgba(121,216,179,0.1);
+ border-color: rgba(121,216,179,0.38);
+ box-shadow: inset 0 0 0 1px rgba(121,216,179,0.08);
}
-.ws-mode-pill.active { background: var(--bg); color: var(--text); box-shadow: 0 0 0 1px var(--line); }
.ws-mode-pill:hover { color: var(--text); }
.ws-mode-pill:active,
.btn-primary:active,
.btn-ghost:active { transform: translateY(1px); }
-.ws-config { display: flex; gap: 10px; flex-wrap: wrap; flex: 1; align-items: center; }
+.ws-config { flex-wrap: wrap; flex: 1; align-items: center; }
+.ws-risk {
+ flex-wrap: wrap;
+}
.field-tight { display: flex; flex-direction: column; gap: 2px; }
.field-tight > span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
-.field-tight input[type="number"] { width: 80px; padding: 4px 8px; font-size: 12px; }
+.field-tight input[type="number"] {
+ width: 84px;
+ min-height: 34px;
+ padding: 5px 8px;
+ font-family: var(--mono);
+ font-size: 12px;
+}
.field-tight.inline {
- flex-direction: row; align-items: center; gap: 6px;
+ flex-direction: row; align-items: center; gap: 7px;
+ min-height: 34px;
+ padding: 0 9px;
+ border: 1px solid rgba(231,236,232,0.08);
+ border-radius: 7px;
+ background: var(--panel-2);
font-size: 12px; color: var(--muted);
+ white-space: nowrap;
+}
+.field-tight.inline input {
+ accent-color: var(--accent);
+}
+.ws-actionbar > #ws-run {
+ min-width: 150px;
+ justify-content: center;
+ align-self: stretch;
}
@keyframes ws-pulse {
@@ -1135,9 +1617,26 @@ textarea { font-family: var(--mono); font-size: 12px; }
font-size: 42px;
}
.connect-form,
- .preset-save {
+ .preset-save,
+ .run-shell,
+ .run-fields,
+ .run-fields.compact {
grid-template-columns: 1fr;
}
+ .run-intro {
+ position: static;
+ }
+ .run-intro h1 {
+ max-width: none;
+ font-size: 30px;
+ }
+ .run-form-head {
+ flex-direction: column;
+ }
+ .run-form-head .btn-primary {
+ width: 100%;
+ justify-content: center;
+ }
.connect-form .span-2 { grid-column: auto; }
.connect-actions {
align-items: stretch;
@@ -1155,10 +1654,18 @@ textarea { font-family: var(--mono); font-size: 12px; }
.ws-canvas-wrap,
.ws-actionbar { grid-column: 1; }
.ws-canvas-wrap { min-height: 420px; }
- .ws-actionbar { align-items: stretch; }
+ .ws-actionbar {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+ .ws-run-group {
+ align-items: stretch;
+ flex-direction: column;
+ }
.ws-mode,
.ws-config,
+ .ws-risk,
#ws-run { width: 100%; }
- .ws-mode { overflow-x: auto; }
+ .ws-mode { grid-template-columns: 1fr; }
.ws-mode-pill { white-space: nowrap; }
}
diff --git a/internal/web/templates/enrich.html.tmpl b/internal/web/templates/enrich.html.tmpl
index afd411a..b1d9a45 100644
--- a/internal/web/templates/enrich.html.tmpl
+++ b/internal/web/templates/enrich.html.tmpl
@@ -1,21 +1,37 @@
{{define "content"}}
-
- AI Enrich
- Uses Gemini to replace generic faker mappings with semantically meaningful ones based on column names and table context.
- Requires GEMINI_API_KEY in the environment of the seedstorm process.
-