From 4796ccc9e515bfdcc006f63b135f3609bf909fab Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Sun, 17 May 2026 14:16:09 +0200 Subject: [PATCH 1/6] feat(web): improve run preview workflows --- internal/web/handlers_pages_test.go | 2 + internal/web/runners.go | 73 ++- internal/web/runners_test.go | 28 ++ internal/web/static/app.js | 368 +++++++++++++-- internal/web/static/style.css | 508 ++++++++++++++++++++- internal/web/templates/enrich.html.tmpl | 50 +- internal/web/templates/export.html.tmpl | 65 ++- internal/web/templates/generate.html.tmpl | 57 ++- internal/web/templates/workspace.html.tmpl | 26 +- 9 files changed, 1049 insertions(+), 128 deletions(-) create mode 100644 internal/web/runners_test.go diff --git a/internal/web/handlers_pages_test.go b/internal/web/handlers_pages_test.go index a00a182..939b7d2 100644 --- a/internal/web/handlers_pages_test.go +++ b/internal/web/handlers_pages_test.go @@ -27,6 +27,8 @@ 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/style.css", http.StatusOK, ".result-shell"}, } 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..dea7594 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -4,6 +4,7 @@ "use strict"; const PRESET_KEY = "seedstorm.connections.v1"; + const GENERATED_DRAFT_KEY = "seedstorm.generatedData.v1"; // ── localStorage presets for the connection form ─────────────────────── function loadPresets() { @@ -262,6 +263,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 +294,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 = ` +
+ + `; + 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 ` + + + ${rows} +
#TableRelationship roleRows
+ `; + } + + 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, @@ -371,7 +690,7 @@ document.getElementById("ws-zoom-out")?.addEventListener("click", () => zoomGraph(0.84)); 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(); @@ -1124,17 +1443,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 +1477,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..aba6539 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -208,6 +208,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 +620,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; @@ -1067,32 +1444,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 +1590,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 +1627,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.

-
- - -
- +
+ + +
+
+
+
+

Enrichment prompt

+

Give the model a short domain cue so generated names, labels, statuses, and descriptions fit the app.

+
+ +
+
+ + +
+
+ {{template "joblog" .}} +
{{end}} diff --git a/internal/web/templates/export.html.tmpl b/internal/web/templates/export.html.tmpl index 66861cd..d145582 100644 --- a/internal/web/templates/export.html.tmpl +++ b/internal/web/templates/export.html.tmpl @@ -1,30 +1,45 @@ {{define "content"}} -
-

Export

-

Convert a generated data YAML payload to SQL, CSV, or JSON.

-
- -
- +
+ + +
+ +
+
+

Export payload

+

If you arrived from Generate, the YAML payload is loaded automatically.

+
+ +
-
-
- -
-
- {{template "joblog" .}} +
+ + +
+ + {{template "joblog" .}} +
{{end}} diff --git a/internal/web/templates/generate.html.tmpl b/internal/web/templates/generate.html.tmpl index 73089cd..e77dde1 100644 --- a/internal/web/templates/generate.html.tmpl +++ b/internal/web/templates/generate.html.tmpl @@ -1,24 +1,41 @@ {{define "content"}} -
-

Generate

-

Generates fake rows from the live schema without writing to the database. Result is downloadable.

-
- - -
- +
+ + +
+
+
+
+

Generation settings

+

The schema is read from the active connection. No insert statements are executed.

+
+ +
+
+ + +
+
+ {{template "joblog" .}} +
{{end}} diff --git a/internal/web/templates/workspace.html.tmpl b/internal/web/templates/workspace.html.tmpl index af0f042..85dc3f9 100644 --- a/internal/web/templates/workspace.html.tmpl +++ b/internal/web/templates/workspace.html.tmpl @@ -96,12 +96,25 @@