@growthub/cli 0.14.6 → 0.14.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +5 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +5 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +58 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +31 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/StatusPill.jsx +22 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ToggleField.jsx +5 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/WorkspaceDataModelCanvas.jsx +457 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +188 -2
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +67 -3
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-map/page.jsx +14 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +48 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-node-status.js +55 -0
- package/package.json +1 -1
|
@@ -413,6 +413,11 @@ function buildRunResponse({
|
|
|
413
413
|
if (result && typeof result === "object" && Array.isArray(result.logTree)) {
|
|
414
414
|
base.logTree = result.logTree;
|
|
415
415
|
}
|
|
416
|
+
// Per-node orchestration execution trace (general pipeline, not swarm) so the
|
|
417
|
+
// Workflow Canvas can settle per-node status from the persisted record.
|
|
418
|
+
if (result && typeof result === "object" && Array.isArray(result.nodeTrace)) {
|
|
419
|
+
base.nodeTrace = result.nodeTrace;
|
|
420
|
+
}
|
|
416
421
|
base.exports = {
|
|
417
422
|
available: ["download-json", "copy-output", "download-stdout", "download-stderr", "download-log-node"],
|
|
418
423
|
external: []
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { useEffect, useMemo, useState } from "react";
|
|
22
22
|
import Link from "next/link";
|
|
23
|
-
import { Activity, BarChart3, Check, Copy, Eye, GitBranch, MoreVertical, Search } from "lucide-react";
|
|
23
|
+
import { Activity, BarChart3, Check, Copy, Eye, GitBranch, MoreVertical, Search, Share2 } from "lucide-react";
|
|
24
24
|
import { deriveWorkspaceState, deriveSwarmConditionPacket, deriveWorkspaceContributions, deriveLensWalkthroughState, LENS_WALKTHROUGH_DISMISS_FLAG } from "@/lib/workspace-activation";
|
|
25
25
|
import { WorkspaceContributionGraph } from "./WorkspaceContributionGraph.jsx";
|
|
26
26
|
import { WorkspaceLensWalkthrough } from "./WorkspaceLensWalkthrough.jsx";
|
|
@@ -244,9 +244,10 @@ export function WorkspaceLensPanel({ workspaceConfig, workspaceSourceRecords, me
|
|
|
244
244
|
<h1 className="workspace-lens-title">Workspace Lens</h1>
|
|
245
245
|
<p className="workspace-lens-subtitle">Live derived state for this workspace.</p>
|
|
246
246
|
</div>
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
<Link className="workspace-lens-map-link" href="/workspace-map" aria-label="Open Workspace Map">
|
|
248
|
+
<Share2 size={15} aria-hidden="true" />
|
|
249
|
+
<span>Workspace Map</span>
|
|
250
|
+
</Link>
|
|
250
251
|
</header>
|
|
251
252
|
|
|
252
253
|
{showWalk ? (
|
|
@@ -2390,6 +2390,7 @@ function DataModelTableSurface({
|
|
|
2390
2390
|
const [selectMenuOpen, setSelectMenuOpen] = useState(false);
|
|
2391
2391
|
const [pageSize, setPageSize] = useState(15);
|
|
2392
2392
|
const [pageIndex, setPageIndex] = useState(0);
|
|
2393
|
+
const [search, setSearch] = useState("");
|
|
2393
2394
|
const fieldInputRef = useRef(null);
|
|
2394
2395
|
const selectedOriginalIndex = selectedRecordIndex ?? localSelectedOriginalIndex;
|
|
2395
2396
|
|
|
@@ -2407,6 +2408,7 @@ function DataModelTableSurface({
|
|
|
2407
2408
|
setLastSelectedRowIndex(null);
|
|
2408
2409
|
setSelectMenuOpen(false);
|
|
2409
2410
|
setPageIndex(0);
|
|
2411
|
+
setSearch("");
|
|
2410
2412
|
}, [table.id]);
|
|
2411
2413
|
|
|
2412
2414
|
useEffect(() => {
|
|
@@ -2428,7 +2430,14 @@ function DataModelTableSurface({
|
|
|
2428
2430
|
const visibleColumns = useMemo(() => orderedColumns.filter((column) => !settings.hidden.includes(column)), [orderedColumns, settings.hidden]);
|
|
2429
2431
|
const rowEntries = useMemo(() => {
|
|
2430
2432
|
const indexed = (table.rows || []).map((row, originalIndex) => ({ row, originalIndex }));
|
|
2431
|
-
const
|
|
2433
|
+
const needle = search.trim().toLowerCase();
|
|
2434
|
+
const filtered = indexed.filter((entry) => {
|
|
2435
|
+
if (!rowMatchesFilter(entry.row, settings.filter)) return false;
|
|
2436
|
+
if (!needle) return true;
|
|
2437
|
+
// Quick search across the visible columns only — additive to the
|
|
2438
|
+
// column-level filter clauses, never replaces them.
|
|
2439
|
+
return visibleColumns.some((column) => String(entry.row?.[column] ?? "").toLowerCase().includes(needle));
|
|
2440
|
+
});
|
|
2432
2441
|
if (!settings.sort?.length) return filtered;
|
|
2433
2442
|
const clauses = settings.sort;
|
|
2434
2443
|
return [...filtered].sort((left, right) => {
|
|
@@ -2439,7 +2448,7 @@ function DataModelTableSurface({
|
|
|
2439
2448
|
}
|
|
2440
2449
|
return 0;
|
|
2441
2450
|
});
|
|
2442
|
-
}, [table.rows, settings]);
|
|
2451
|
+
}, [table.rows, settings, search, visibleColumns]);
|
|
2443
2452
|
const selectedRowCount = selectedRows.size;
|
|
2444
2453
|
const pageCount = Math.max(1, Math.ceil(rowEntries.length / pageSize));
|
|
2445
2454
|
const safePageIndex = Math.min(pageIndex, pageCount - 1);
|
|
@@ -2491,7 +2500,7 @@ function DataModelTableSurface({
|
|
|
2491
2500
|
setSelectedRow(null);
|
|
2492
2501
|
setLastSelectedRowIndex(null);
|
|
2493
2502
|
setSelectMenuOpen(false);
|
|
2494
|
-
}, [settings.filter, settings.sort, pageSize]);
|
|
2503
|
+
}, [settings.filter, settings.sort, pageSize, search]);
|
|
2495
2504
|
|
|
2496
2505
|
function commitField() {
|
|
2497
2506
|
const name = fieldName.trim();
|
|
@@ -2690,9 +2699,25 @@ function DataModelTableSurface({
|
|
|
2690
2699
|
))}
|
|
2691
2700
|
</div>
|
|
2692
2701
|
<div className="dm-records-actions">
|
|
2702
|
+
<label className="dm-toolbar-search">
|
|
2703
|
+
<Search size={13} aria-hidden="true" />
|
|
2704
|
+
<input
|
|
2705
|
+
value={search}
|
|
2706
|
+
placeholder="Search records"
|
|
2707
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
2708
|
+
aria-label={`Search ${table.label || "records"}`}
|
|
2709
|
+
/>
|
|
2710
|
+
</label>
|
|
2711
|
+
<span className="dm-toolbar-count">
|
|
2712
|
+
{(search.trim() || settings.filter?.clauses?.length)
|
|
2713
|
+
? `${rowEntries.length} of ${pluralize(table.rows?.length || 0, "record")}`
|
|
2714
|
+
: pluralize(table.rows?.length || 0, "record")}
|
|
2715
|
+
</span>
|
|
2716
|
+
<span className="dm-toolbar-divider" aria-hidden="true" />
|
|
2693
2717
|
<span className="dm-filter-anchor">
|
|
2694
|
-
<button type="button" className=
|
|
2718
|
+
<button type="button" className={`dm-btn-ghost${settings.filter?.clauses?.length ? " is-active" : ""}`} onClick={() => setFilterTarget((current) => current === "toolbar" ? "" : "toolbar")}>
|
|
2695
2719
|
<Filter size={13} />Filter
|
|
2720
|
+
{settings.filter?.clauses?.length > 0 && <span className="dm-filter-chip-count">{settings.filter.clauses.length}</span>}
|
|
2696
2721
|
</button>
|
|
2697
2722
|
{filterTarget === "toolbar" && (
|
|
2698
2723
|
<div className="dm-filter-popover dm-filter-popover-toolbar">
|
|
@@ -2843,6 +2868,35 @@ function DataModelTableSurface({
|
|
|
2843
2868
|
</tr>
|
|
2844
2869
|
</thead>
|
|
2845
2870
|
<tbody>
|
|
2871
|
+
{rowEntries.length === 0 && (
|
|
2872
|
+
<tr className="dm-db-empty-row">
|
|
2873
|
+
<td colSpan={visibleColumns.length + 1 + (table.mutable ? 1 : 0)}>
|
|
2874
|
+
<div className="dm-db-empty-state">
|
|
2875
|
+
{(search.trim() || settings.filter?.clauses?.length) ? (
|
|
2876
|
+
<>
|
|
2877
|
+
<strong>No records match</strong>
|
|
2878
|
+
<span>Try a different search or clear your filters.</span>
|
|
2879
|
+
<div className="dm-db-empty-actions">
|
|
2880
|
+
{search.trim() && <button type="button" className="dm-btn-outline" onClick={() => setSearch("")}>Clear search</button>}
|
|
2881
|
+
{settings.filter?.clauses?.length > 0 && <button type="button" className="dm-btn-outline" onClick={resetView}>Reset view</button>}
|
|
2882
|
+
</div>
|
|
2883
|
+
</>
|
|
2884
|
+
) : (
|
|
2885
|
+
<>
|
|
2886
|
+
<strong>No records yet</strong>
|
|
2887
|
+
<span>{table.mutable ? "Add your first row or import a CSV to start using this object." : "Records resolve at runtime for this live-backed object."}</span>
|
|
2888
|
+
{table.mutable && (
|
|
2889
|
+
<div className="dm-db-empty-actions">
|
|
2890
|
+
<button type="button" className="dm-btn-primary-sm" disabled={saving} onClick={() => onSave((config) => addTableRow(config, table))}><Plus size={13} />Add record</button>
|
|
2891
|
+
<button type="button" className="dm-btn-outline" onClick={() => setCsvOpen(true)}><Upload size={13} />Import CSV</button>
|
|
2892
|
+
</div>
|
|
2893
|
+
)}
|
|
2894
|
+
</>
|
|
2895
|
+
)}
|
|
2896
|
+
</div>
|
|
2897
|
+
</td>
|
|
2898
|
+
</tr>
|
|
2899
|
+
)}
|
|
2846
2900
|
{pageEntries.map(({ row, originalIndex }, rowIndex) => {
|
|
2847
2901
|
const visibleIndex = pageStart + rowIndex;
|
|
2848
2902
|
const displayIndex = visibleIndex + 1;
|
|
@@ -103,6 +103,21 @@ function nodeRecordName(node) {
|
|
|
103
103
|
return "";
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Per-node run-status pill (Attio-style), docked outside the node top-right.
|
|
107
|
+
// Driven only by the real general-orchestration run signal passed in
|
|
108
|
+
// `nodeStatuses` — the streamed orchestration.node.* deltas and the persisted
|
|
109
|
+
// nodeTrace (see lib/orchestration-node-status.js). Never fabricated here.
|
|
110
|
+
const NODE_STATUS_CHIP = {
|
|
111
|
+
completed: { cls: "is-ok", label: "Completed" },
|
|
112
|
+
ok: { cls: "is-ok", label: "Completed" },
|
|
113
|
+
running: { cls: "is-running", label: "Running" },
|
|
114
|
+
executing: { cls: "is-running", label: "Running" },
|
|
115
|
+
failed: { cls: "is-bad", label: "Failed" },
|
|
116
|
+
skipped: { cls: "is-waiting", label: "Skipped" },
|
|
117
|
+
pending: { cls: "is-waiting", label: "Waiting" },
|
|
118
|
+
queued: { cls: "is-waiting", label: "Waiting" },
|
|
119
|
+
};
|
|
120
|
+
|
|
106
121
|
export function OrchestrationGraphCanvas({
|
|
107
122
|
graph,
|
|
108
123
|
selectedNodeId,
|
|
@@ -112,6 +127,8 @@ export function OrchestrationGraphCanvas({
|
|
|
112
127
|
onRunTest,
|
|
113
128
|
runStatus,
|
|
114
129
|
runMessage,
|
|
130
|
+
nodeStatuses,
|
|
131
|
+
onNodeStatusClick,
|
|
115
132
|
statusLabel = "Draft",
|
|
116
133
|
}) {
|
|
117
134
|
const parsed = useMemo(() => parseOrchestrationGraph(graph) || graph, [graph]);
|
|
@@ -222,6 +239,9 @@ export function OrchestrationGraphCanvas({
|
|
|
222
239
|
const isSelected = activeId === id;
|
|
223
240
|
const prevId = index > 0 ? String(nodes[index - 1].id || "") : "";
|
|
224
241
|
const Icon = NODE_ICONS[node.type] || ArrowDownToLine;
|
|
242
|
+
const nodeStatusChip = nodeStatuses
|
|
243
|
+
? NODE_STATUS_CHIP[String(nodeStatuses[id] || "").toLowerCase()] || null
|
|
244
|
+
: null;
|
|
225
245
|
|
|
226
246
|
return (
|
|
227
247
|
<div key={id || index} className="dm-orchestration-canvas__step">
|
|
@@ -285,6 +305,17 @@ export function OrchestrationGraphCanvas({
|
|
|
285
305
|
onSelectNode?.(node);
|
|
286
306
|
}}
|
|
287
307
|
>
|
|
308
|
+
{nodeStatusChip && (
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
className={`dm-status-chip ${nodeStatusChip.cls} dm-orchestration-node__status`}
|
|
312
|
+
title={`${nodeStatusChip.label} — open run trace`}
|
|
313
|
+
onClick={(event) => { event.stopPropagation(); onNodeStatusClick?.(node); }}
|
|
314
|
+
>
|
|
315
|
+
<span className="dm-status-dot" aria-hidden="true" />
|
|
316
|
+
{nodeStatusChip.label}
|
|
317
|
+
</button>
|
|
318
|
+
)}
|
|
288
319
|
<span className="dm-orchestration-node__icon" aria-hidden="true">
|
|
289
320
|
<Icon size={14} />
|
|
290
321
|
</span>
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
// Maps a free-form status string to one calm, consistent chip state.
|
|
4
|
+
// Backwards-compatible: same { value } prop, same default ("untested").
|
|
5
|
+
const OK = ["connected", "approved", "ok", "success", "succeeded", "complete", "completed", "passed", "live", "ready"];
|
|
6
|
+
const BAD = ["failed", "error", "errored", "disconnected", "rejected"];
|
|
7
|
+
const WARN = ["warning", "warn", "pending", "untrusted", "stale", "degraded"];
|
|
8
|
+
const RUNNING = ["running", "in_progress", "in-progress", "active", "executing", "started"];
|
|
9
|
+
const WAITING = ["waiting", "queued", "idle", "scheduled"];
|
|
10
|
+
|
|
11
|
+
function classifyStatus(status) {
|
|
12
|
+
if (OK.includes(status)) return "is-ok";
|
|
13
|
+
if (BAD.includes(status)) return "is-bad";
|
|
14
|
+
if (RUNNING.includes(status)) return "is-running";
|
|
15
|
+
if (WARN.includes(status)) return "is-warn";
|
|
16
|
+
if (WAITING.includes(status)) return "is-waiting";
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
export function StatusPill({ value }) {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const bad = ["failed", "error", "disconnected"].includes(status);
|
|
21
|
+
const raw = value || "untested";
|
|
22
|
+
const state = classifyStatus(String(raw).toLowerCase());
|
|
7
23
|
return (
|
|
8
|
-
<span className={`dm-
|
|
9
|
-
<span />
|
|
10
|
-
{
|
|
24
|
+
<span className={`dm-status-chip ${state}`.trim()}>
|
|
25
|
+
<span className="dm-status-dot" />
|
|
26
|
+
{raw}
|
|
11
27
|
</span>
|
|
12
28
|
);
|
|
13
29
|
}
|
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
export function ToggleField({ checked, disabled, label, onChange, description }) {
|
|
4
4
|
return (
|
|
5
|
-
<label className=
|
|
5
|
+
<label className={`dm-switch-row${disabled ? " is-disabled" : ""}`}>
|
|
6
6
|
<input
|
|
7
7
|
type="checkbox"
|
|
8
8
|
checked={Boolean(checked)}
|
|
9
9
|
disabled={disabled}
|
|
10
10
|
onChange={(event) => onChange(event.target.checked)}
|
|
11
11
|
/>
|
|
12
|
-
<span
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
<span className="dm-switch-track" aria-hidden="true" />
|
|
13
|
+
<span className="dm-switch-label">
|
|
14
|
+
<span>{label}</span>
|
|
15
|
+
{description && <span className="dm-switch-desc">{description}</span>}
|
|
15
16
|
</span>
|
|
16
17
|
</label>
|
|
17
18
|
);
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workspace Map — read-only schema/relationship canvas.
|
|
5
|
+
*
|
|
6
|
+
* Renders the workspace data model as a node canvas using the SAME visual
|
|
7
|
+
* grammar as the workflow OrchestrationGraphCanvas (dotted grid, card nodes,
|
|
8
|
+
* connector edges, zoom controls), but the CONTENT is the metadata graph, not
|
|
9
|
+
* an executable workflow.
|
|
10
|
+
*
|
|
11
|
+
* Hard rules (mirrors the kit's governance invariants):
|
|
12
|
+
* - Graph data comes ONLY from buildWorkspaceMetadataStore →
|
|
13
|
+
* buildWorkspaceMetadataGraph. No ad-hoc config parsing here.
|
|
14
|
+
* - No mutations, no PATCH, no localStorage. This surface only reads
|
|
15
|
+
* /api/workspace and navigates to existing surfaces on click.
|
|
16
|
+
* - Secrets never reach the graph (the store strips them); we render only
|
|
17
|
+
* labels, counts, and types.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
21
|
+
import Link from "next/link";
|
|
22
|
+
import { useRouter } from "next/navigation";
|
|
23
|
+
import { ArrowLeft, Maximize2, Search, X, ZoomIn, ZoomOut } from "lucide-react";
|
|
24
|
+
|
|
25
|
+
import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
|
|
26
|
+
import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
|
|
27
|
+
import { LucideIcon, OBJECT_TYPE_PRESETS, objectTypeBadge, pluralize } from "./dm-shared.jsx";
|
|
28
|
+
|
|
29
|
+
// Which node types appear on the map, in lane (left→right) order, with the
|
|
30
|
+
// icon + legend swatch for each. Workflow execution detail (nodes, inputs,
|
|
31
|
+
// runs) stays in the Workflow Canvas — the map is the structural overview.
|
|
32
|
+
const LANES = [
|
|
33
|
+
{ key: "integration", types: ["integration"], label: "Integrations", icon: "Globe", color: "#0ea5e9" },
|
|
34
|
+
{ key: "source", types: ["sourceRecord"], label: "Sources", icon: "Database", color: "#14b8a6" },
|
|
35
|
+
{ key: "object", types: ["dataModelObject"], label: "Objects", icon: "Box", color: "#4f6bed" },
|
|
36
|
+
{ key: "flow", types: ["workflow", "sandbox"], label: "Workflows", icon: "Zap", color: "#8b5cf6" },
|
|
37
|
+
{ key: "dashboard", types: ["dashboard"], label: "Dashboards", icon: "LayoutDashboard", color: "#f97316" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const RENDERED_TYPES = new Set(LANES.flatMap((lane) => lane.types));
|
|
41
|
+
const NODE_WIDTH = 220;
|
|
42
|
+
const COL_GAP = 60;
|
|
43
|
+
const ROW_GAP = 24;
|
|
44
|
+
const PAD = 28;
|
|
45
|
+
|
|
46
|
+
function nodeHeight(node) {
|
|
47
|
+
// Object cards carry a field strip + stat → taller. Everything else is compact.
|
|
48
|
+
return node.type === "dataModelObject" ? 122 : 80;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nodeIconName(node) {
|
|
52
|
+
if (node.type === "dataModelObject") {
|
|
53
|
+
return node.summary?.objectType
|
|
54
|
+
? OBJECT_TYPE_PRESETS[node.summary.objectType]?.icon || "Box"
|
|
55
|
+
: "Box";
|
|
56
|
+
}
|
|
57
|
+
const lane = LANES.find((entry) => entry.types.includes(node.type));
|
|
58
|
+
return lane?.icon || "Box";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function WorkspaceDataModelCanvas() {
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const [workspaceConfig, setWorkspaceConfig] = useState(null);
|
|
64
|
+
const [sourceRecords, setSourceRecords] = useState({});
|
|
65
|
+
const [loading, setLoading] = useState(true);
|
|
66
|
+
const [error, setError] = useState("");
|
|
67
|
+
const [scale, setScale] = useState(1);
|
|
68
|
+
const [selectedId, setSelectedId] = useState("");
|
|
69
|
+
const [query, setQuery] = useState("");
|
|
70
|
+
const canvasRef = useRef(null);
|
|
71
|
+
const dragRef = useRef(null);
|
|
72
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
73
|
+
|
|
74
|
+
const load = useCallback(async () => {
|
|
75
|
+
setLoading(true);
|
|
76
|
+
setError("");
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch("/api/workspace", { cache: "no-store" });
|
|
79
|
+
const payload = await res.json();
|
|
80
|
+
if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
|
|
81
|
+
setWorkspaceConfig(payload.workspaceConfig || null);
|
|
82
|
+
setSourceRecords(
|
|
83
|
+
payload.workspaceSourceRecords && typeof payload.workspaceSourceRecords === "object"
|
|
84
|
+
? payload.workspaceSourceRecords
|
|
85
|
+
: {}
|
|
86
|
+
);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
setError(err.message || "Failed to load workspace");
|
|
89
|
+
} finally {
|
|
90
|
+
setLoading(false);
|
|
91
|
+
}
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
useEffect(() => { load(); }, [load]);
|
|
95
|
+
|
|
96
|
+
// Derived graph — never throws on malformed config (the store/graph guard).
|
|
97
|
+
const graph = useMemo(() => {
|
|
98
|
+
if (!workspaceConfig) return { nodes: [], edges: [] };
|
|
99
|
+
try {
|
|
100
|
+
const store = buildWorkspaceMetadataStore({
|
|
101
|
+
workspaceConfig,
|
|
102
|
+
workspaceSourceRecords: sourceRecords,
|
|
103
|
+
});
|
|
104
|
+
return buildWorkspaceMetadataGraph(store);
|
|
105
|
+
} catch {
|
|
106
|
+
return { nodes: [], edges: [] };
|
|
107
|
+
}
|
|
108
|
+
}, [workspaceConfig, sourceRecords]);
|
|
109
|
+
|
|
110
|
+
// Group field labels by the object id they belong to, for the object cards.
|
|
111
|
+
const fieldsByObjectId = useMemo(() => {
|
|
112
|
+
const map = new Map();
|
|
113
|
+
for (const node of graph.nodes) {
|
|
114
|
+
if (node.type !== "field") continue;
|
|
115
|
+
const objectId = node.summary?.objectId;
|
|
116
|
+
if (!objectId) continue;
|
|
117
|
+
if (!map.has(objectId)) map.set(objectId, []);
|
|
118
|
+
map.get(objectId).push(node.label);
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
}, [graph.nodes]);
|
|
122
|
+
|
|
123
|
+
// Deterministic lane layout — no randomness, stable across renders.
|
|
124
|
+
const { placed, positions, width, height } = useMemo(() => {
|
|
125
|
+
const positionMap = new Map();
|
|
126
|
+
const placedNodes = [];
|
|
127
|
+
let maxBottom = 0;
|
|
128
|
+
LANES.forEach((lane, laneIndex) => {
|
|
129
|
+
const laneNodes = graph.nodes.filter((node) => lane.types.includes(node.type));
|
|
130
|
+
const x = PAD + laneIndex * (NODE_WIDTH + COL_GAP);
|
|
131
|
+
let y = PAD;
|
|
132
|
+
for (const node of laneNodes) {
|
|
133
|
+
const h = nodeHeight(node);
|
|
134
|
+
positionMap.set(node.id, { x, y, w: NODE_WIDTH, h });
|
|
135
|
+
placedNodes.push(node);
|
|
136
|
+
y += h + ROW_GAP;
|
|
137
|
+
}
|
|
138
|
+
maxBottom = Math.max(maxBottom, y);
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
placed: placedNodes,
|
|
142
|
+
positions: positionMap,
|
|
143
|
+
width: PAD + LANES.length * (NODE_WIDTH + COL_GAP),
|
|
144
|
+
height: Math.max(maxBottom, 460),
|
|
145
|
+
};
|
|
146
|
+
}, [graph.nodes]);
|
|
147
|
+
|
|
148
|
+
// Only edges whose BOTH endpoints are rendered on the map. Deduped by id.
|
|
149
|
+
const edges = useMemo(() => {
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const edge of graph.edges) {
|
|
153
|
+
if (!positions.has(edge.from) || !positions.has(edge.to)) continue;
|
|
154
|
+
if (seen.has(edge.id)) continue;
|
|
155
|
+
seen.add(edge.id);
|
|
156
|
+
out.push(edge);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}, [graph.edges, positions]);
|
|
160
|
+
|
|
161
|
+
const needle = query.trim().toLowerCase();
|
|
162
|
+
const matches = useCallback(
|
|
163
|
+
(node) => {
|
|
164
|
+
if (!needle) return true;
|
|
165
|
+
const s = node.summary || {};
|
|
166
|
+
// Match the fields a customer actually searches by — object type, source
|
|
167
|
+
// id, integration/status, workflow lifecycle, adapter/auth, fetched date —
|
|
168
|
+
// not just label/type.
|
|
169
|
+
const haystack = [
|
|
170
|
+
node.label, node.type,
|
|
171
|
+
s.objectType, s.objectId, s.status, s.lane, s.lifecycleStatus,
|
|
172
|
+
s.adapter, s.authStatus, s.authProvider, s.integrationId,
|
|
173
|
+
s.runLocality, s.fetchedAt, s.sourceAuthority,
|
|
174
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
175
|
+
return haystack.includes(needle);
|
|
176
|
+
},
|
|
177
|
+
[needle]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Human-readable label for the open action, by node type.
|
|
181
|
+
function openLabel(node) {
|
|
182
|
+
if (node.type === "workflow" || node.type === "sandbox") return "Open in Workflow Canvas";
|
|
183
|
+
return "Open in Data Model";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Read-only metadata rows for the detail panel — derived from the graph
|
|
187
|
+
// summary only (no secrets, no config parsing).
|
|
188
|
+
function detailRows(node) {
|
|
189
|
+
const s = node.summary || {};
|
|
190
|
+
const rows = [];
|
|
191
|
+
const push = (k, v) => { if (v !== undefined && v !== null && v !== "") rows.push({ k, v: String(v) }); };
|
|
192
|
+
if (node.type === "dataModelObject") {
|
|
193
|
+
push("Type", s.objectType);
|
|
194
|
+
push("Records", Number.isFinite(s.rowCount) ? s.rowCount : undefined);
|
|
195
|
+
push("Backing", s.isLiveBacked ? "live" : s.readOnly ? "read-only" : "manual");
|
|
196
|
+
push("Source authority", s.sourceAuthority);
|
|
197
|
+
} else if (node.type === "sourceRecord") {
|
|
198
|
+
push("Records", Number.isFinite(s.recordCount) ? s.recordCount : 0);
|
|
199
|
+
push("Integration", s.integrationId);
|
|
200
|
+
push("Fetched", s.fetchedAt ? String(s.fetchedAt).slice(0, 10) : undefined);
|
|
201
|
+
} else if (node.type === "workflow") {
|
|
202
|
+
push("Steps", Number.isFinite(s.nodeCount) ? s.nodeCount : 0);
|
|
203
|
+
push("Lifecycle", s.lifecycleStatus);
|
|
204
|
+
push("Requires input", s.requiresInput ? "yes" : undefined);
|
|
205
|
+
} else if (node.type === "sandbox") {
|
|
206
|
+
push("Adapter", s.adapter);
|
|
207
|
+
push("Auth", s.authStatus);
|
|
208
|
+
push("Locality", s.runLocality);
|
|
209
|
+
push("Lifecycle", s.lifecycleStatus);
|
|
210
|
+
} else if (node.type === "integration") {
|
|
211
|
+
push("Status", s.status);
|
|
212
|
+
push("Lane", s.lane);
|
|
213
|
+
} else if (node.type === "dashboard") {
|
|
214
|
+
push("Widgets", Number.isFinite(s.widgetCount) ? s.widgetCount : 0);
|
|
215
|
+
}
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const selectedNode = useMemo(
|
|
220
|
+
() => placed.find((node) => node.id === selectedId) || null,
|
|
221
|
+
[placed, selectedId]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
function handleOpen(node) {
|
|
225
|
+
const summary = node.summary || {};
|
|
226
|
+
if (node.type === "dataModelObject") {
|
|
227
|
+
router.push(`/data-model?object=${encodeURIComponent(summary.objectId || "")}`);
|
|
228
|
+
} else if (node.type === "workflow" || node.type === "sandbox") {
|
|
229
|
+
const params = new URLSearchParams();
|
|
230
|
+
if (summary.objectId) params.set("object", summary.objectId);
|
|
231
|
+
if (summary.rowId) params.set("row", summary.rowId);
|
|
232
|
+
// Explicit field for consistency with the CEO/Agent Team handoff pattern;
|
|
233
|
+
// WorkflowSurface still falls back to orchestrationGraph if absent.
|
|
234
|
+
params.set("field", "orchestrationConfig");
|
|
235
|
+
router.push(`/workflows${params.toString() ? `?${params.toString()}` : ""}`);
|
|
236
|
+
} else {
|
|
237
|
+
router.push("/data-model");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function zoom(delta) {
|
|
242
|
+
setScale((current) => Math.min(1.4, Math.max(0.6, Math.round((current + delta) * 10) / 10)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const handleCanvasPointerDown = useCallback((event) => {
|
|
246
|
+
if (event.button !== 0) return;
|
|
247
|
+
const target = event.target;
|
|
248
|
+
if (target?.closest?.("button, a, input, label, .wm-detail, .wm-zoom")) return;
|
|
249
|
+
const canvas = canvasRef.current;
|
|
250
|
+
if (!canvas) return;
|
|
251
|
+
dragRef.current = {
|
|
252
|
+
pointerId: event.pointerId,
|
|
253
|
+
startX: event.clientX,
|
|
254
|
+
startY: event.clientY,
|
|
255
|
+
scrollLeft: canvas.scrollLeft,
|
|
256
|
+
scrollTop: canvas.scrollTop,
|
|
257
|
+
};
|
|
258
|
+
setIsPanning(true);
|
|
259
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
260
|
+
}, []);
|
|
261
|
+
|
|
262
|
+
const handleCanvasPointerMove = useCallback((event) => {
|
|
263
|
+
const drag = dragRef.current;
|
|
264
|
+
const canvas = canvasRef.current;
|
|
265
|
+
if (!drag || !canvas || drag.pointerId !== event.pointerId) return;
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
canvas.scrollLeft = drag.scrollLeft - (event.clientX - drag.startX);
|
|
268
|
+
canvas.scrollTop = drag.scrollTop - (event.clientY - drag.startY);
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
271
|
+
const endCanvasPan = useCallback((event) => {
|
|
272
|
+
if (dragRef.current?.pointerId !== event.pointerId) return;
|
|
273
|
+
dragRef.current = null;
|
|
274
|
+
setIsPanning(false);
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
const hasNodes = placed.length > 0;
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div className="wm-shell">
|
|
281
|
+
<div className="wm-toolbar">
|
|
282
|
+
<Link className="wm-back-link" href="/workspace-lens" aria-label="Back to Workspace Lens">
|
|
283
|
+
<ArrowLeft size={16} aria-hidden="true" />
|
|
284
|
+
</Link>
|
|
285
|
+
<div>
|
|
286
|
+
<h1>Workspace Map</h1>
|
|
287
|
+
<span className="wm-sub">
|
|
288
|
+
{hasNodes ? `${pluralize(placed.length, "node")} · ${pluralize(edges.length, "link")}` : "Read-only view of your workspace data model"}
|
|
289
|
+
</span>
|
|
290
|
+
</div>
|
|
291
|
+
<label className="dm-toolbar-search" style={{ marginLeft: 16 }}>
|
|
292
|
+
<Search size={13} aria-hidden="true" />
|
|
293
|
+
<input
|
|
294
|
+
value={query}
|
|
295
|
+
placeholder="Search objects, sources, workflows"
|
|
296
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
297
|
+
aria-label="Search the workspace map"
|
|
298
|
+
/>
|
|
299
|
+
</label>
|
|
300
|
+
<div className="wm-legend">
|
|
301
|
+
{LANES.map((lane) => (
|
|
302
|
+
<span key={lane.key} className="wm-legend-item">
|
|
303
|
+
<span className="wm-legend-swatch" style={{ background: lane.color }} />
|
|
304
|
+
{lane.label}
|
|
305
|
+
</span>
|
|
306
|
+
))}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<div
|
|
311
|
+
ref={canvasRef}
|
|
312
|
+
className={`wm-canvas${isPanning ? " is-panning" : ""}`}
|
|
313
|
+
onPointerDown={handleCanvasPointerDown}
|
|
314
|
+
onPointerMove={handleCanvasPointerMove}
|
|
315
|
+
onPointerUp={endCanvasPan}
|
|
316
|
+
onPointerCancel={endCanvasPan}
|
|
317
|
+
onPointerLeave={endCanvasPan}
|
|
318
|
+
>
|
|
319
|
+
{loading && <div className="wm-empty"><span>Loading workspace map…</span></div>}
|
|
320
|
+
{!loading && error && <div className="wm-empty"><strong>Could not load the map</strong><span>{error}</span></div>}
|
|
321
|
+
{!loading && !error && !hasNodes && (
|
|
322
|
+
<div className="wm-empty">
|
|
323
|
+
<strong>Nothing to map yet</strong>
|
|
324
|
+
<span>Add objects, link a data source, or publish a workflow and they will appear here.</span>
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
{!loading && !error && hasNodes && (
|
|
328
|
+
<>
|
|
329
|
+
{/* Sizer reserves the SCALED footprint so .wm-canvas (overflow:auto)
|
|
330
|
+
scrolls fully when zoomed in on dense workspaces. The inner
|
|
331
|
+
layer holds the unscaled coordinate system and is transformed. */}
|
|
332
|
+
<div className="wm-canvas-inner" style={{ width: width * scale, height: height * scale }}>
|
|
333
|
+
<div className="wm-canvas-scale" style={{ width, height, transform: `scale(${scale})` }}>
|
|
334
|
+
<svg className="wm-edge" width={width} height={height} style={{ left: 0, top: 0 }}>
|
|
335
|
+
<defs>
|
|
336
|
+
<marker id="wm-arrow" markerWidth="7" markerHeight="7" refX="6" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
|
337
|
+
<path className="wm-arrow-head" d="M0,0 L6,3 L0,6 Z" />
|
|
338
|
+
</marker>
|
|
339
|
+
</defs>
|
|
340
|
+
{edges.map((edge) => {
|
|
341
|
+
const a = positions.get(edge.from);
|
|
342
|
+
const b = positions.get(edge.to);
|
|
343
|
+
if (!a || !b) return null;
|
|
344
|
+
const overlapsX = Math.max(a.x, b.x) < Math.min(a.x + a.w, b.x + b.w);
|
|
345
|
+
const [top, bottom] = a.y <= b.y ? [a, b] : [b, a];
|
|
346
|
+
const [left, right] = a.x <= b.x ? [a, b] : [b, a];
|
|
347
|
+
const cls = ["backedBySourceRecord", "boundToIntegration", "belongsToIntegration"].includes(edge.relation)
|
|
348
|
+
? "is-source"
|
|
349
|
+
: "";
|
|
350
|
+
const d = overlapsX
|
|
351
|
+
? `M ${top.x + top.w / 2} ${top.y + top.h} L ${bottom.x + bottom.w / 2} ${bottom.y}`
|
|
352
|
+
: [
|
|
353
|
+
`M ${left.x + left.w} ${left.y + left.h / 2}`,
|
|
354
|
+
`H ${(left.x + left.w + right.x) / 2}`,
|
|
355
|
+
`V ${right.y + right.h / 2}`,
|
|
356
|
+
`H ${right.x}`,
|
|
357
|
+
].join(" ");
|
|
358
|
+
return <path key={edge.id} className={cls} d={d} markerEnd="url(#wm-arrow)" />;
|
|
359
|
+
})}
|
|
360
|
+
</svg>
|
|
361
|
+
{placed.map((node) => {
|
|
362
|
+
const pos = positions.get(node.id);
|
|
363
|
+
const dim = needle && !matches(node);
|
|
364
|
+
const isObject = node.type === "dataModelObject";
|
|
365
|
+
const badge = isObject ? objectTypeBadge(node.summary?.objectType) : null;
|
|
366
|
+
const fields = isObject ? (fieldsByObjectId.get(node.summary?.objectId) || []).slice(0, 5) : [];
|
|
367
|
+
const rowCount = node.summary?.rowCount;
|
|
368
|
+
const recordCount = node.summary?.recordCount;
|
|
369
|
+
return (
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
key={node.id}
|
|
373
|
+
className={`wm-node${node.id === selectedId ? " is-selected" : ""}`}
|
|
374
|
+
style={{ left: pos.x, top: pos.y, width: pos.w, opacity: dim ? 0.32 : 1 }}
|
|
375
|
+
onClick={() => setSelectedId(node.id)}
|
|
376
|
+
aria-pressed={node.id === selectedId}
|
|
377
|
+
title={`Inspect ${node.label}`}
|
|
378
|
+
>
|
|
379
|
+
<span className="wm-node-head">
|
|
380
|
+
<span className="wm-node-icon"><LucideIcon name={nodeIconName(node)} size={14} /></span>
|
|
381
|
+
<span className="wm-node-title">{node.label || node.type}</span>
|
|
382
|
+
{badge && <span className={`dm-badge ${badge.cls}`}>{badge.label}</span>}
|
|
383
|
+
</span>
|
|
384
|
+
<span className="wm-node-body">
|
|
385
|
+
{isObject && (
|
|
386
|
+
<span className="wm-node-stat">
|
|
387
|
+
{Number.isFinite(rowCount) ? pluralize(rowCount, "record") : "—"}
|
|
388
|
+
{node.summary?.isLiveBacked ? " · live" : node.summary?.readOnly ? " · read-only" : " · manual"}
|
|
389
|
+
</span>
|
|
390
|
+
)}
|
|
391
|
+
{node.type === "sourceRecord" && (
|
|
392
|
+
<span className="wm-node-stat">
|
|
393
|
+
{Number.isFinite(recordCount) ? pluralize(recordCount, "record") : "no records"}
|
|
394
|
+
{node.summary?.fetchedAt ? ` · fetched ${String(node.summary.fetchedAt).slice(0, 10)}` : ""}
|
|
395
|
+
</span>
|
|
396
|
+
)}
|
|
397
|
+
{node.type === "workflow" && (
|
|
398
|
+
<span className="wm-node-stat">
|
|
399
|
+
{pluralize(node.summary?.nodeCount || 0, "step")}
|
|
400
|
+
{node.summary?.lifecycleStatus ? ` · ${node.summary.lifecycleStatus}` : ""}
|
|
401
|
+
</span>
|
|
402
|
+
)}
|
|
403
|
+
{node.type === "sandbox" && (
|
|
404
|
+
<span className="wm-node-stat">
|
|
405
|
+
{node.summary?.adapter || "sandbox"}
|
|
406
|
+
{node.summary?.authStatus ? ` · ${node.summary.authStatus}` : ""}
|
|
407
|
+
</span>
|
|
408
|
+
)}
|
|
409
|
+
{node.type === "integration" && (
|
|
410
|
+
<span className="wm-node-stat">{node.summary?.status || node.summary?.lane || "integration"}</span>
|
|
411
|
+
)}
|
|
412
|
+
{node.type === "dashboard" && (
|
|
413
|
+
<span className="wm-node-stat">{pluralize(node.summary?.widgetCount || 0, "widget")}</span>
|
|
414
|
+
)}
|
|
415
|
+
{fields.length > 0 && (
|
|
416
|
+
<span className="wm-node-fields">
|
|
417
|
+
{fields.map((field, index) => (
|
|
418
|
+
<span key={`${node.id}-f-${index}`} className="wm-node-field">{field}</span>
|
|
419
|
+
))}
|
|
420
|
+
</span>
|
|
421
|
+
)}
|
|
422
|
+
</span>
|
|
423
|
+
</button>
|
|
424
|
+
);
|
|
425
|
+
})}
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
<div className="wm-zoom" role="group" aria-label="Zoom controls">
|
|
429
|
+
<button type="button" onClick={() => zoom(-0.1)} aria-label="Zoom out"><ZoomOut size={15} /></button>
|
|
430
|
+
<button type="button" onClick={() => setScale(1)} aria-label="Reset zoom"><Maximize2 size={14} /></button>
|
|
431
|
+
<button type="button" onClick={() => zoom(0.1)} aria-label="Zoom in"><ZoomIn size={15} /></button>
|
|
432
|
+
</div>
|
|
433
|
+
{selectedNode && (
|
|
434
|
+
<aside className="wm-detail" aria-label={`${selectedNode.label} detail`}>
|
|
435
|
+
<div className="wm-detail-head">
|
|
436
|
+
<span className="wm-node-icon"><LucideIcon name={nodeIconName(selectedNode)} size={14} /></span>
|
|
437
|
+
<span className="wm-detail-title">{selectedNode.label || selectedNode.type}</span>
|
|
438
|
+
<button type="button" className="wm-detail-close" aria-label="Close detail" onClick={() => setSelectedId("")}><X size={14} /></button>
|
|
439
|
+
</div>
|
|
440
|
+
<dl className="wm-detail-meta">
|
|
441
|
+
{detailRows(selectedNode).map((row) => (
|
|
442
|
+
<div key={row.k} className="wm-detail-row"><dt>{row.k}</dt><dd>{row.v}</dd></div>
|
|
443
|
+
))}
|
|
444
|
+
</dl>
|
|
445
|
+
<button type="button" className="dm-btn-primary-sm wm-detail-cta" onClick={() => handleOpen(selectedNode)}>
|
|
446
|
+
{openLabel(selectedNode)}
|
|
447
|
+
</button>
|
|
448
|
+
</aside>
|
|
449
|
+
)}
|
|
450
|
+
</>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export default WorkspaceDataModelCanvas;
|
package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css
CHANGED
|
@@ -8977,10 +8977,14 @@ body.workspace-rail-collapsed .workspace-builder.workspace-lens-page,
|
|
|
8977
8977
|
.workspace-lens-surface { width: 100%; padding: 0; background: #f7f7f8; overflow-y: auto; overflow-x: hidden; }
|
|
8978
8978
|
.workspace-lens-shell { width: 100%; max-width: none; margin: 0; padding: 24px 28px 32px; }
|
|
8979
8979
|
.workspace-lens-locked { border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; padding: 28px; max-width: 560px; margin: 48px auto; display: flex; flex-direction: column; gap: 8px; }
|
|
8980
|
-
.workspace-lens-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
|
8980
|
+
.workspace-lens-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
|
8981
|
+
.workspace-lens-head > div { min-width: 0; }
|
|
8981
8982
|
.workspace-lens-title { font-size: 18px; font-weight: 600; color: #111827; margin: 0; }
|
|
8982
8983
|
.workspace-lens-subtitle { font-size: 13px; color: #6b7280; margin: 4px 0 0; }
|
|
8983
|
-
.workspace-lens-
|
|
8984
|
+
.workspace-lens-map-link { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 34px; border: 1px solid #d8dee8; border-radius: 8px; background: #ffffff; color: #111827; padding: 0 12px; text-decoration: none; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
|
|
8985
|
+
.workspace-lens-map-link:hover { background: #f9fafb; border-color: #cfd7e3; color: #111827; }
|
|
8986
|
+
.workspace-lens-map-link:focus-visible { outline: 2px solid #bfdbfe; outline-offset: 2px; }
|
|
8987
|
+
.workspace-lens-map-link svg { color: #4b5563; flex-shrink: 0; }
|
|
8984
8988
|
.workspace-lens-controls.workspace-builder-filterbar { margin: 0 0 14px; border: 1px solid #e8edf3; border-radius: 8px; background: #fff; }
|
|
8985
8989
|
.workspace-lens-filters.workspace-builder-filterbar__segments { max-width: 100%; overflow-x: auto; }
|
|
8986
8990
|
.workspace-lens-filter { white-space: nowrap; }
|
|
@@ -9034,12 +9038,17 @@ body.workspace-rail-collapsed .workspace-builder.workspace-lens-page,
|
|
|
9034
9038
|
.workspace-lens-action-menu button:hover,
|
|
9035
9039
|
.workspace-lens-action-menu a:hover { background: #f3f4f6; }
|
|
9036
9040
|
@media (max-width: 1100px) {
|
|
9041
|
+
.workspace-lens-head { align-items: flex-start; }
|
|
9037
9042
|
.workspace-lens-control-grid { grid-template-columns: 1fr; }
|
|
9038
9043
|
.workspace-lens-branch-row { grid-template-columns: 1fr; align-items: flex-start; padding: 10px 12px; gap: 8px; }
|
|
9039
9044
|
.workspace-lens-branch-actions { justify-content: flex-start; flex-wrap: wrap; }
|
|
9040
9045
|
.workspace-lens-branch-summary,
|
|
9041
9046
|
.workspace-lens-branch-next { margin-left: 22px; }
|
|
9042
9047
|
}
|
|
9048
|
+
@media (max-width: 640px) {
|
|
9049
|
+
.workspace-lens-head { flex-direction: column; }
|
|
9050
|
+
.workspace-lens-map-link { align-self: stretch; }
|
|
9051
|
+
}
|
|
9043
9052
|
.workspace-lens-stream { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
9044
9053
|
.workspace-lens-card { border: 1px solid #e8edf3; border-radius: 10px; background: #fff; padding: 12px 14px; }
|
|
9045
9054
|
.workspace-lens-card.is-ready { opacity: 0.7; }
|
|
@@ -9261,3 +9270,180 @@ body.workspace-rail-collapsed .workspace-builder.workspace-lens-page,
|
|
|
9261
9270
|
.dm-swarm-expand-head { justify-content: flex-start; gap: 8px; }
|
|
9262
9271
|
.dm-swarm-expand-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px; }
|
|
9263
9272
|
.dm-swarm-expand-body .dm-helper-toolcall-json { max-height: none; }
|
|
9273
|
+
|
|
9274
|
+
/* ════════════════════════════════════════════════════════════════════════
|
|
9275
|
+
SaaS facelift layer — Attio-inspired polish (v0.14.8)
|
|
9276
|
+
Additive only. Refines existing dm-* light-theme primitives via the
|
|
9277
|
+
cascade (equal specificity, later wins) and introduces the shared
|
|
9278
|
+
vocabulary consumed by the table, drawer, Workspace Map, and run-status
|
|
9279
|
+
work. No new theme, no token renames — existing classes keep working.
|
|
9280
|
+
──────────────────────────────────────────────────────────────────────── */
|
|
9281
|
+
:root {
|
|
9282
|
+
/* Calm, unified surface system for the light data-model surfaces. */
|
|
9283
|
+
--dm-line: #e7eaee; /* default hairline border */
|
|
9284
|
+
--dm-line-soft: #f0f2f5; /* inner cell / row separators */
|
|
9285
|
+
--dm-surface: #ffffff;
|
|
9286
|
+
--dm-surface-soft: #fafbfc; /* headers, toolbars, footers */
|
|
9287
|
+
--dm-hover: #f6f8fa; /* calm row/control hover */
|
|
9288
|
+
--dm-ink: #1f2733; /* primary text */
|
|
9289
|
+
--dm-muted: #6b7686; /* secondary text */
|
|
9290
|
+
--dm-faint: #9aa4b2; /* tertiary / placeholder */
|
|
9291
|
+
--dm-accent: #4f6bed; /* single accent — active states only */
|
|
9292
|
+
--dm-accent-soft: #eef1fe; /* accent background wash */
|
|
9293
|
+
--dm-accent-line: #c9d3fb; /* accent border */
|
|
9294
|
+
--dm-radius: 8px;
|
|
9295
|
+
--dm-radius-sm: 6px;
|
|
9296
|
+
--dm-radius-lg: 12px;
|
|
9297
|
+
--dm-shadow-pop: 0 12px 32px rgba(20, 28, 46, 0.12), 0 2px 6px rgba(20, 28, 46, 0.06);
|
|
9298
|
+
--dm-ok: #16a34a;
|
|
9299
|
+
--dm-ok-soft: #f0fdf4;
|
|
9300
|
+
--dm-ok-line: #bbf7d0;
|
|
9301
|
+
--dm-bad: #dc2626;
|
|
9302
|
+
--dm-bad-soft: #fef2f2;
|
|
9303
|
+
--dm-bad-line: #fecaca;
|
|
9304
|
+
--dm-warn: #d97706;
|
|
9305
|
+
--dm-warn-soft: #fffbeb;
|
|
9306
|
+
--dm-warn-line: #fde68a;
|
|
9307
|
+
--dm-run: #4f6bed;
|
|
9308
|
+
--dm-run-soft: #eef1fe;
|
|
9309
|
+
--dm-run-line: #c9d3fb;
|
|
9310
|
+
}
|
|
9311
|
+
|
|
9312
|
+
/* ── Toolbar + buttons: softer borders, unified radius/hover ───────────── */
|
|
9313
|
+
.dm-db-toolbar { border-bottom-color: var(--dm-line); }
|
|
9314
|
+
.dm-btn-ghost { border-radius: var(--dm-radius-sm); border-color: var(--dm-line); color: var(--dm-muted); }
|
|
9315
|
+
.dm-btn-ghost:hover { background: var(--dm-hover); border-color: #d3d9e0; color: var(--dm-ink); }
|
|
9316
|
+
.dm-btn-ghost.is-active { background: var(--dm-accent-soft); border-color: var(--dm-accent-line); color: var(--dm-accent); }
|
|
9317
|
+
.dm-btn-outline { border-radius: var(--dm-radius-sm); border-color: var(--dm-line); }
|
|
9318
|
+
.dm-btn-primary-sm { border-radius: var(--dm-radius-sm); }
|
|
9319
|
+
|
|
9320
|
+
/* ── Compact search field for the table toolbar (P1) ──────────────────── */
|
|
9321
|
+
.dm-toolbar-search { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 9px; border: 1px solid var(--dm-line); border-radius: var(--dm-radius-sm); background: var(--dm-surface); color: var(--dm-faint); transition: border-color .12s, box-shadow .12s; }
|
|
9322
|
+
.dm-toolbar-search:focus-within { border-color: var(--dm-accent-line); box-shadow: 0 0 0 3px var(--dm-accent-soft); }
|
|
9323
|
+
.dm-toolbar-search input { border: 0; outline: 0; background: transparent; font: inherit; font-size: 12px; color: var(--dm-ink); width: 150px; }
|
|
9324
|
+
.dm-toolbar-search input::placeholder { color: var(--dm-faint); }
|
|
9325
|
+
.dm-toolbar-count { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: var(--dm-surface-soft); border: 1px solid var(--dm-line); color: var(--dm-muted); font-size: 11px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
9326
|
+
.dm-toolbar-divider { width: 1px; align-self: stretch; margin: 2px 2px; background: var(--dm-line); }
|
|
9327
|
+
|
|
9328
|
+
/* ── Filter / sort pill chips: calmer, clearer active state ───────────── */
|
|
9329
|
+
.dm-filter-chip { border-radius: var(--dm-radius-sm); border-color: var(--dm-accent-line); background: var(--dm-accent-soft); color: var(--dm-accent); font-weight: 600; transition: background .12s, border-color .12s; }
|
|
9330
|
+
.dm-filter-chip:hover { background: #e4e9fd; }
|
|
9331
|
+
.dm-filter-chip-count { margin-left: 1px; min-width: 16px; height: 16px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: var(--dm-accent); color: #fff; font-size: 10px; font-weight: 700; }
|
|
9332
|
+
.dm-selection-count { border-color: var(--dm-line); background: var(--dm-surface); color: var(--dm-muted); }
|
|
9333
|
+
|
|
9334
|
+
/* ── Grid: calmer hairlines + hover, refined sticky header ────────────── */
|
|
9335
|
+
.dm-db-grid-wrap { border-color: var(--dm-line); border-radius: var(--dm-radius); }
|
|
9336
|
+
.dm-db-grid th { background: var(--dm-surface-soft); color: var(--dm-muted); border-bottom-color: var(--dm-line); border-right-color: var(--dm-line-soft); letter-spacing: .01em; }
|
|
9337
|
+
.dm-db-grid td { border-bottom-color: var(--dm-line-soft); border-right-color: var(--dm-line-soft); }
|
|
9338
|
+
.dm-db-grid tbody tr:hover td { background: var(--dm-hover); }
|
|
9339
|
+
.dm-db-grid tbody tr.selected td { background: var(--dm-accent-soft); box-shadow: inset 2px 0 0 var(--dm-accent); }
|
|
9340
|
+
.dm-db-field-type { background: var(--dm-surface-soft); color: var(--dm-faint); border-radius: 5px; }
|
|
9341
|
+
|
|
9342
|
+
/* ── Object sidebar: calmer active state (less heavy than solid black) ── */
|
|
9343
|
+
.dm-obj-row { border-radius: var(--dm-radius-sm); }
|
|
9344
|
+
.dm-obj-row:hover { background: var(--dm-hover); }
|
|
9345
|
+
.dm-obj-row.active { background: var(--dm-accent-soft); color: var(--dm-accent); font-weight: 600; }
|
|
9346
|
+
.dm-obj-row.active .dm-obj-icon { color: var(--dm-accent); }
|
|
9347
|
+
.dm-obj-row.active .dm-badge { background: rgba(79,107,237,.12); color: var(--dm-accent); border-color: var(--dm-accent-line); }
|
|
9348
|
+
/* Record count on the live object picker rows (P2). The picker — not the
|
|
9349
|
+
legacy ObjectSidebar — is the rendered object nav. */
|
|
9350
|
+
.dm-picker-meta { margin-left: auto; flex: 0 0 auto; color: var(--dm-faint); font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
9351
|
+
.dm-picker-item.active .dm-picker-meta { color: var(--dm-muted); font-weight: 500; }
|
|
9352
|
+
|
|
9353
|
+
/* ── Badges: thinner, calmer ──────────────────────────────────────────── */
|
|
9354
|
+
.dm-badge { border-radius: 5px; border-color: var(--dm-line); }
|
|
9355
|
+
|
|
9356
|
+
/* ════ Live status chips — shared by StatusPill + run-status work (P4) ══ */
|
|
9357
|
+
.dm-status-chip { display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px; border: 1px solid var(--dm-line); border-radius: 999px; background: var(--dm-surface-soft); color: var(--dm-muted); font-size: 11px; font-weight: 650; line-height: 1; white-space: nowrap; }
|
|
9358
|
+
.dm-status-chip > .dm-status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dm-faint); flex: 0 0 auto; }
|
|
9359
|
+
.dm-status-chip.is-ok { border-color: var(--dm-ok-line); background: var(--dm-ok-soft); color: #166534; }
|
|
9360
|
+
.dm-status-chip.is-ok > .dm-status-dot { background: var(--dm-ok); }
|
|
9361
|
+
.dm-status-chip.is-bad { border-color: var(--dm-bad-line); background: var(--dm-bad-soft); color: #991b1b; }
|
|
9362
|
+
.dm-status-chip.is-bad > .dm-status-dot { background: var(--dm-bad); }
|
|
9363
|
+
.dm-status-chip.is-warn { border-color: var(--dm-warn-line); background: var(--dm-warn-soft); color: #92400e; }
|
|
9364
|
+
.dm-status-chip.is-warn > .dm-status-dot { background: var(--dm-warn); }
|
|
9365
|
+
.dm-status-chip.is-running { border-color: var(--dm-run-line); background: var(--dm-run-soft); color: #3a4fc0; }
|
|
9366
|
+
.dm-status-chip.is-running > .dm-status-dot { background: var(--dm-run); animation: dm-pulse 1.15s ease-in-out infinite; }
|
|
9367
|
+
.dm-status-chip.is-waiting > .dm-status-dot { background: var(--dm-faint); }
|
|
9368
|
+
@keyframes dm-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.72); } }
|
|
9369
|
+
@media (prefers-reduced-motion: reduce) { .dm-status-chip.is-running > .dm-status-dot { animation: none; } }
|
|
9370
|
+
|
|
9371
|
+
/* ── Toggle switch — upgrades ToggleField's checkbox into a real switch ─ */
|
|
9372
|
+
.dm-switch-row { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; font-size: 13px; color: var(--dm-ink); }
|
|
9373
|
+
.dm-switch-row.is-disabled { opacity: .55; cursor: not-allowed; }
|
|
9374
|
+
.dm-switch-row > input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
9375
|
+
.dm-switch-track { position: relative; flex: 0 0 auto; width: 34px; height: 20px; margin-top: 1px; border-radius: 999px; background: #d7dce3; transition: background .14s; }
|
|
9376
|
+
.dm-switch-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(20,28,46,.28); transition: transform .14s; }
|
|
9377
|
+
.dm-switch-row > input:checked + .dm-switch-track { background: var(--dm-accent); }
|
|
9378
|
+
.dm-switch-row > input:checked + .dm-switch-track::after { transform: translateX(14px); }
|
|
9379
|
+
.dm-switch-row > input:focus-visible + .dm-switch-track { box-shadow: 0 0 0 3px var(--dm-accent-soft); }
|
|
9380
|
+
.dm-switch-label { display: grid; gap: 2px; }
|
|
9381
|
+
.dm-switch-desc { color: var(--dm-muted); font-size: 12px; }
|
|
9382
|
+
|
|
9383
|
+
/* ════ Workspace Map / Data Model Canvas (P3) ═════════════════════════════
|
|
9384
|
+
Schema node-canvas reusing the dotted-grid + card language. Read-only. */
|
|
9385
|
+
.wm-shell { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--dm-surface); }
|
|
9386
|
+
.wm-toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--dm-line); background: var(--dm-surface); flex-wrap: wrap; }
|
|
9387
|
+
.wm-back-link { width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--dm-line); border-radius: 8px; background: var(--dm-surface); color: var(--dm-muted); text-decoration: none; box-shadow: 0 1px 2px rgba(20,28,46,.04); flex: 0 0 auto; }
|
|
9388
|
+
.wm-back-link:hover { background: var(--dm-hover); color: var(--dm-ink); border-color: #cfd6df; }
|
|
9389
|
+
.wm-back-link:focus-visible { outline: 2px solid #bfdbfe; outline-offset: 2px; }
|
|
9390
|
+
.wm-toolbar h1 { margin: 0; font-size: 15px; font-weight: 650; color: var(--dm-ink); }
|
|
9391
|
+
.wm-toolbar .wm-sub { font-size: 12px; color: var(--dm-faint); }
|
|
9392
|
+
.wm-legend { display: inline-flex; align-items: center; gap: 12px; margin-left: auto; flex-wrap: wrap; }
|
|
9393
|
+
.wm-legend-item { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--dm-muted); }
|
|
9394
|
+
.wm-legend-swatch { width: 9px; height: 9px; border-radius: 3px; }
|
|
9395
|
+
.wm-canvas { position: relative; flex: 1; min-height: 460px; overflow: auto; background-color: #fbfcfd; background-image: radial-gradient(circle, #dde2e8 1px, transparent 1px); background-size: 22px 22px; cursor: grab; touch-action: none; }
|
|
9396
|
+
.wm-canvas.is-panning { cursor: grabbing; user-select: none; }
|
|
9397
|
+
.wm-canvas-inner { position: relative; }
|
|
9398
|
+
.wm-canvas-scale { position: relative; transform-origin: 0 0; }
|
|
9399
|
+
.wm-edge { position: absolute; pointer-events: none; }
|
|
9400
|
+
.wm-edge path { stroke: #cbd5e1; stroke-width: 1.25; fill: none; stroke-linecap: round; stroke-linejoin: round; }
|
|
9401
|
+
.wm-edge .wm-arrow-head { fill: #cbd5e1; stroke: none; }
|
|
9402
|
+
.wm-edge path.is-source { stroke: #cbd5e1; }
|
|
9403
|
+
.wm-edge path.is-warn { stroke: var(--dm-warn-line); stroke-dasharray: 4 4; }
|
|
9404
|
+
.wm-node { position: absolute; width: 220px; border: 1px solid var(--dm-line); border-radius: var(--dm-radius-lg); background: var(--dm-surface); box-shadow: 0 1px 2px rgba(20,28,46,.06); cursor: pointer; transition: box-shadow .12s, border-color .12s, transform .12s; text-align: left; padding: 0; font: inherit; }
|
|
9405
|
+
.wm-node:hover { border-color: #cfd6df; box-shadow: var(--dm-shadow-pop); transform: translateY(-1px); }
|
|
9406
|
+
.wm-node.is-selected { border-color: var(--dm-accent); box-shadow: 0 0 0 3px var(--dm-accent-soft); }
|
|
9407
|
+
.wm-node-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--dm-line-soft); }
|
|
9408
|
+
.wm-node-icon { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 7px; background: var(--dm-surface-soft); color: var(--dm-muted); flex: 0 0 auto; }
|
|
9409
|
+
.wm-node-title { flex: 1; min-width: 0; font-size: 13px; font-weight: 650; color: var(--dm-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
9410
|
+
.wm-node-body { padding: 9px 12px; display: grid; gap: 4px; }
|
|
9411
|
+
.wm-node-stat { font-size: 12px; color: var(--dm-muted); font-variant-numeric: tabular-nums; }
|
|
9412
|
+
.wm-node-fields { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
|
9413
|
+
.wm-node-field { font-size: 10.5px; color: var(--dm-faint); background: var(--dm-surface-soft); border: 1px solid var(--dm-line-soft); border-radius: 4px; padding: 1px 5px; }
|
|
9414
|
+
.wm-node-warn { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--dm-warn); }
|
|
9415
|
+
.wm-zoom { position: sticky; bottom: 12px; left: 12px; display: inline-flex; gap: 4px; padding: 4px; border: 1px solid var(--dm-line); border-radius: 999px; background: var(--dm-surface); box-shadow: var(--dm-shadow-pop); width: fit-content; margin: 12px; }
|
|
9416
|
+
.wm-zoom button { width: 28px; height: 28px; border: 0; border-radius: 999px; background: transparent; color: var(--dm-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
|
9417
|
+
.wm-zoom button:hover { background: var(--dm-hover); color: var(--dm-ink); }
|
|
9418
|
+
.wm-empty { display: grid; place-items: center; gap: 8px; padding: 64px 24px; text-align: center; color: var(--dm-muted); }
|
|
9419
|
+
.wm-empty strong { font-size: 16px; color: var(--dm-ink); }
|
|
9420
|
+
/* Read-only selected-node detail panel — additive, docks top-right of canvas. */
|
|
9421
|
+
.wm-detail { position: sticky; top: 12px; margin: -12px 12px 12px auto; float: right; width: 248px; background: var(--dm-surface); border: 1px solid var(--dm-line); border-radius: var(--dm-radius-lg); box-shadow: var(--dm-shadow-pop); z-index: 5; }
|
|
9422
|
+
.wm-detail-head { display: flex; align-items: center; gap: 8px; padding: 10px 10px 10px 12px; border-bottom: 1px solid var(--dm-line-soft); }
|
|
9423
|
+
.wm-detail-title { flex: 1; min-width: 0; font-size: 13px; font-weight: 650; color: var(--dm-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
9424
|
+
.wm-detail-close { width: 24px; height: 24px; border: 0; border-radius: 6px; background: transparent; color: var(--dm-faint); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; }
|
|
9425
|
+
.wm-detail-close:hover { background: var(--dm-hover); color: var(--dm-ink); }
|
|
9426
|
+
.wm-detail-meta { margin: 0; padding: 10px 12px; display: grid; gap: 6px; }
|
|
9427
|
+
.wm-detail-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
|
|
9428
|
+
.wm-detail-row dt { color: var(--dm-faint); }
|
|
9429
|
+
.wm-detail-row dd { margin: 0; color: var(--dm-ink); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
9430
|
+
.wm-detail-cta { margin: 0 12px 12px; width: calc(100% - 24px); justify-content: center; }
|
|
9431
|
+
|
|
9432
|
+
/* Per-node run-status pill on the Workflow Canvas — docked outside the node's
|
|
9433
|
+
top-right corner. Driven by the general orchestration stream
|
|
9434
|
+
(orchestration.node.* deltas) + the persisted nodeTrace; clickable to open
|
|
9435
|
+
the Live Runs trace. */
|
|
9436
|
+
.dm-orchestration-node { position: relative; overflow: visible; }
|
|
9437
|
+
.dm-orchestration-canvas__step { overflow: visible; }
|
|
9438
|
+
.dm-orchestration-node__status { position: absolute; top: -18px; right: 14px; z-index: 3; height: 16px; min-height: 16px; padding: 0 6px; gap: 3px; border: 1px solid var(--dm-line); background: var(--dm-surface); box-shadow: 0 1px 3px rgba(20,28,46,.12); cursor: pointer; font-size: 9.5px; font-weight: 700; line-height: 1; transform: translateY(-2px); }
|
|
9439
|
+
.dm-orchestration-node__status > .dm-status-dot { width: 5px; height: 5px; }
|
|
9440
|
+
.dm-orchestration-node__status:hover { box-shadow: 0 2px 6px rgba(20,28,46,.2); }
|
|
9441
|
+
.dm-orchestration-node__status:focus-visible { outline: 2px solid var(--dm-accent); outline-offset: 1px; }
|
|
9442
|
+
|
|
9443
|
+
/* ── Table empty / no-results state (P1) ──────────────────────────────── */
|
|
9444
|
+
.dm-db-empty-row td { padding: 0 !important; background: var(--dm-surface) !important; cursor: default !important; }
|
|
9445
|
+
.dm-db-empty-row:hover td { background: var(--dm-surface) !important; }
|
|
9446
|
+
.dm-db-empty-state { display: grid; justify-items: center; gap: 6px; padding: 48px 24px; text-align: center; }
|
|
9447
|
+
.dm-db-empty-state strong { font-size: 15px; color: var(--dm-ink); font-weight: 650; }
|
|
9448
|
+
.dm-db-empty-state span { font-size: 12.5px; color: var(--dm-muted); max-width: 360px; }
|
|
9449
|
+
.dm-db-empty-actions { display: inline-flex; gap: 8px; margin-top: 8px; }
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
validateOrchestrationGraph
|
|
45
45
|
} from "@/lib/orchestration-graph";
|
|
46
46
|
import { resolveConnectorAction } from "@/lib/orchestration-sidecar-routing";
|
|
47
|
+
import { deriveOrchestrationNodeStatuses } from "@/lib/orchestration-node-status";
|
|
47
48
|
import {
|
|
48
49
|
nodeSandboxRecordRef,
|
|
49
50
|
patchSandboxRowInConfig,
|
|
@@ -83,6 +84,45 @@ function withUiCacheFlag(workspaceConfig, flag, value) {
|
|
|
83
84
|
return { ...workspaceConfig, dataModel: { ...dm, objects: nextObjects } };
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// Read the sandbox-run NDJSON delta stream (same shape SwarmRunCockpit
|
|
88
|
+
// consumes): push each growthub-sandbox-run-delta-v1 event to `onEvent` for
|
|
89
|
+
// live canvas hydration, and return the sandbox-run.final payload (the run
|
|
90
|
+
// result). Falls back to plain JSON when the response is not a stream.
|
|
91
|
+
async function readSandboxRunStream(response, onEvent) {
|
|
92
|
+
if (!response?.body || typeof response.body.getReader !== "function") {
|
|
93
|
+
return response.json().catch(() => null);
|
|
94
|
+
}
|
|
95
|
+
const reader = response.body.getReader();
|
|
96
|
+
const decoder = new TextDecoder();
|
|
97
|
+
let buffer = "";
|
|
98
|
+
let finalPayload = null;
|
|
99
|
+
const handle = async (line) => {
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
if (!trimmed) return;
|
|
102
|
+
try {
|
|
103
|
+
const event = JSON.parse(trimmed);
|
|
104
|
+
if (event.kind !== "growthub-sandbox-run-delta-v1") return;
|
|
105
|
+
if (event.type === "sandbox-run.final") finalPayload = event.payload || finalPayload;
|
|
106
|
+
else if (typeof onEvent === "function") {
|
|
107
|
+
onEvent((prev) => [...prev, event].slice(-300));
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 90));
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore malformed cosmetic chunks; the final payload still arrives.
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
while (true) {
|
|
115
|
+
const { value, done } = await reader.read();
|
|
116
|
+
if (done) break;
|
|
117
|
+
buffer += decoder.decode(value, { stream: true });
|
|
118
|
+
const lines = buffer.split("\n");
|
|
119
|
+
buffer = lines.pop() || "";
|
|
120
|
+
for (const line of lines) await handle(line);
|
|
121
|
+
}
|
|
122
|
+
if (buffer.trim()) await handle(buffer);
|
|
123
|
+
return finalPayload;
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
// Workspace Metadata Graph V1 — read-only dependency metadata for workflow
|
|
87
127
|
// sidecars. The runtime path (sandbox-run, publish, draft/live) is
|
|
88
128
|
// unchanged; this only exposes typed dependency descriptors so the sidecar
|
|
@@ -281,6 +321,7 @@ export default function WorkflowSurface() {
|
|
|
281
321
|
const [publishing, setPublishing] = useState(false);
|
|
282
322
|
const [saveMessage, setSaveMessage] = useState("");
|
|
283
323
|
const [running, setRunning] = useState(false);
|
|
324
|
+
const [liveRunEvents, setLiveRunEvents] = useState([]);
|
|
284
325
|
const [runMessage, setRunMessage] = useState("");
|
|
285
326
|
const [sidecarMode, setSidecarMode] = useState(runId ? "trace" : "graph");
|
|
286
327
|
|
|
@@ -327,12 +368,29 @@ export default function WorkflowSurface() {
|
|
|
327
368
|
|
|
328
369
|
useEffect(() => { load(); }, [load]);
|
|
329
370
|
|
|
371
|
+
// Reset live per-node deltas when the active workflow changes, so a prior
|
|
372
|
+
// run's stream never bleeds onto a different workflow's canvas — the new
|
|
373
|
+
// workflow settles from its own persisted nodeTrace until it is run.
|
|
374
|
+
useEffect(() => { setLiveRunEvents([]); }, [objectId, rowId]);
|
|
375
|
+
|
|
330
376
|
const resolved = useMemo(
|
|
331
377
|
() => (workspaceConfig ? findSandboxRowByWorkflowRef(workspaceConfig, objectId, rowId) : { object: null, row: null, rowIndex: -1 }),
|
|
332
378
|
[workspaceConfig, objectId, rowId]
|
|
333
379
|
);
|
|
334
380
|
|
|
335
381
|
const sandboxRow = resolved.row;
|
|
382
|
+
|
|
383
|
+
// Per-node Workflow Canvas pill status — GENERAL orchestration (not swarm).
|
|
384
|
+
// Live from the streamed orchestration.node.* deltas while a run is in
|
|
385
|
+
// flight; settled from the persisted run record's nodeTrace once complete.
|
|
386
|
+
const runNodeStatuses = useMemo(() => {
|
|
387
|
+
let record = sandboxRow?.lastResponse;
|
|
388
|
+
if (typeof record === "string") {
|
|
389
|
+
try { record = JSON.parse(record); } catch { record = null; }
|
|
390
|
+
}
|
|
391
|
+
const map = deriveOrchestrationNodeStatuses({ events: liveRunEvents, record });
|
|
392
|
+
return Object.keys(map).length ? map : null;
|
|
393
|
+
}, [liveRunEvents, sandboxRow]);
|
|
336
394
|
const hasGraphValue = (value) => Boolean(parseOrchestrationGraph(value));
|
|
337
395
|
const effectiveFieldName = hasGraphValue(sandboxRow?.[fieldName])
|
|
338
396
|
? fieldName
|
|
@@ -564,17 +622,21 @@ export default function WorkflowSurface() {
|
|
|
564
622
|
: null;
|
|
565
623
|
setRunning(true);
|
|
566
624
|
setRunMessage("");
|
|
625
|
+
setLiveRunEvents([]);
|
|
567
626
|
try {
|
|
568
627
|
const draft = await saveDraft({ orchestrationDraftStatus: "testing" });
|
|
569
628
|
const draftGraph = draft?.serialized || serializeCurrentGraph();
|
|
570
|
-
const body = { objectId, name: rowId, useDraft: true, draftGraph };
|
|
629
|
+
const body = { objectId, name: rowId, useDraft: true, draftGraph, stream: true };
|
|
571
630
|
if (runInputs) body.runInputs = runInputs;
|
|
572
631
|
const res = await fetch("/api/workspace/sandbox-run", {
|
|
573
632
|
method: "POST",
|
|
574
|
-
headers: { "content-type": "application/json" },
|
|
633
|
+
headers: { "content-type": "application/json", accept: "application/x-ndjson" },
|
|
575
634
|
body: JSON.stringify(body),
|
|
576
635
|
});
|
|
577
|
-
|
|
636
|
+
// Consume the NDJSON delta stream: per-node orchestration.node.* events
|
|
637
|
+
// hydrate the canvas pills live; the sandbox-run.final payload is the
|
|
638
|
+
// run result we persist. Falls back to plain JSON if not a stream.
|
|
639
|
+
const payload = (await readSandboxRunStream(res, setLiveRunEvents)) || {};
|
|
578
640
|
const responseText = redactSecretsFromText(JSON.stringify(payload.response ?? payload, null, 2));
|
|
579
641
|
const status = payload.ok && String(payload.status || "").toLowerCase() === "connected" ? "connected" : "failed";
|
|
580
642
|
const pass = isPassingRun(payload);
|
|
@@ -1042,6 +1104,8 @@ export default function WorkflowSurface() {
|
|
|
1042
1104
|
setConfigTab("node");
|
|
1043
1105
|
}}
|
|
1044
1106
|
onConnectorAction={handleConnectorAction}
|
|
1107
|
+
nodeStatuses={runNodeStatuses}
|
|
1108
|
+
onNodeStatusClick={(node) => { setSelectedNodeId(String(node?.id || "")); openTraceMode(); }}
|
|
1045
1109
|
statusLabel={isDraftMode ? "Draft" : "Live"}
|
|
1046
1110
|
/>
|
|
1047
1111
|
{nextNodeId && (
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Suspense } from "react";
|
|
4
|
+
import WorkspaceDataModelCanvas from "../data-model/components/WorkspaceDataModelCanvas.jsx";
|
|
5
|
+
|
|
6
|
+
// Read-only workspace-level schema canvas. No mutation lane, no new runtime —
|
|
7
|
+
// it reads /api/workspace and renders the derived metadata graph.
|
|
8
|
+
export default function WorkspaceMapPage() {
|
|
9
|
+
return (
|
|
10
|
+
<Suspense fallback={null}>
|
|
11
|
+
<WorkspaceDataModelCanvas />
|
|
12
|
+
</Suspense>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -301,11 +301,43 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs,
|
|
|
301
301
|
const inputPayload = { ...baseInputPayload, ...manualPayload };
|
|
302
302
|
const consumedInputKeys = Object.keys(manualPayload);
|
|
303
303
|
const transformConfig = extractTransformConfig(graph);
|
|
304
|
+
const transformNode = (Array.isArray(graph.nodes) ? graph.nodes : [])
|
|
305
|
+
.find((n) => n?.type === "transform-filter" || n?.type === "normalize-output");
|
|
304
306
|
const resultNode = graph.nodes?.find((n) => n?.type === "tool-result");
|
|
305
307
|
const successCodes = Array.isArray(resultNode?.config?.successStatusCodes)
|
|
306
308
|
? resultNode.config.successStatusCodes.map(Number).filter(Number.isFinite)
|
|
307
309
|
: [200];
|
|
308
310
|
|
|
311
|
+
// Per-node execution deltas for the GENERAL orchestration pipeline (not
|
|
312
|
+
// swarm-specific): stream node lifecycle through the same onEvent NDJSON hook
|
|
313
|
+
// the route already wires, and record a terminal nodeTrace persisted on the
|
|
314
|
+
// run record. Every event corresponds to a real pipeline stage executing.
|
|
315
|
+
const onEvent = typeof executionContext?.onEvent === "function" ? executionContext.onEvent : null;
|
|
316
|
+
const runId = String(executionContext?.runId || "").trim();
|
|
317
|
+
const nodeTrace = [];
|
|
318
|
+
const emitNode = (nodeId, status, error) => {
|
|
319
|
+
const id = String(nodeId || "").trim();
|
|
320
|
+
if (!id) return;
|
|
321
|
+
if (status === "completed" || status === "failed" || status === "skipped") {
|
|
322
|
+
nodeTrace.push(error ? { id, status, error: String(error) } : { id, status });
|
|
323
|
+
}
|
|
324
|
+
if (onEvent) {
|
|
325
|
+
onEvent({
|
|
326
|
+
kind: "growthub-sandbox-run-delta-v1",
|
|
327
|
+
type: `orchestration.node.${status}`,
|
|
328
|
+
nodeId: id,
|
|
329
|
+
runId,
|
|
330
|
+
emittedAt: new Date().toISOString(),
|
|
331
|
+
...(error ? { error: String(error) } : {})
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Input stage — payload is assembled above; it ran.
|
|
337
|
+
if (inputNode?.id) { emitNode(inputNode.id, "started"); emitNode(inputNode.id, "completed"); }
|
|
338
|
+
|
|
339
|
+
// API Registry call stage.
|
|
340
|
+
if (apiNode?.id) emitNode(apiNode.id, "started");
|
|
309
341
|
const raw = await executeApiRegistryCall(
|
|
310
342
|
workspaceConfig,
|
|
311
343
|
apiNode.config,
|
|
@@ -320,14 +352,29 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs,
|
|
|
320
352
|
raw.exitCode = 1;
|
|
321
353
|
raw.error = `HTTP ${httpStatus} is not in successStatusCodes`;
|
|
322
354
|
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (apiNode?.id) {
|
|
358
|
+
if (raw.ok) emitNode(apiNode.id, "completed");
|
|
359
|
+
else emitNode(apiNode.id, "failed", raw.error);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (raw.ok && raw.rawPayload !== undefined) {
|
|
363
|
+
if (transformNode?.id) emitNode(transformNode.id, "started");
|
|
323
364
|
const transformed = transformProviderPayload(raw.rawPayload, transformConfig);
|
|
324
365
|
raw.stdout = typeof transformed === "string"
|
|
325
366
|
? transformed
|
|
326
367
|
: normalizeJsonAtPath(transformed, "");
|
|
327
368
|
delete raw.rawPayload;
|
|
328
369
|
delete raw.httpStatus;
|
|
370
|
+
if (transformNode?.id) emitNode(transformNode.id, "completed");
|
|
371
|
+
if (resultNode?.id) { emitNode(resultNode.id, "started"); emitNode(resultNode.id, "completed"); }
|
|
329
372
|
} else if (raw.error) {
|
|
330
373
|
raw.error = redactSecretsFromText(raw.error);
|
|
374
|
+
// Downstream stages never executed → record them as skipped (not-run), not
|
|
375
|
+
// failed: only the api stage actually failed.
|
|
376
|
+
if (transformNode?.id) emitNode(transformNode.id, "skipped");
|
|
377
|
+
if (resultNode?.id) emitNode(resultNode.id, "skipped");
|
|
331
378
|
}
|
|
332
379
|
|
|
333
380
|
if (raw.stdout) {
|
|
@@ -336,6 +383,7 @@ async function runOrchestrationGraphIfPresent({ workspaceConfig, row, timeoutMs,
|
|
|
336
383
|
|
|
337
384
|
return {
|
|
338
385
|
...raw,
|
|
386
|
+
nodeTrace,
|
|
339
387
|
adapterMeta: {
|
|
340
388
|
...(raw.adapterMeta || {}),
|
|
341
389
|
orchestrationProvider: graph.provider,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration per-node run status — GENERAL (not swarm).
|
|
3
|
+
*
|
|
4
|
+
* Maps the real per-node execution signal of a sandbox/orchestration run onto
|
|
5
|
+
* a plain { nodeId: status } map the Workflow Canvas pills consume:
|
|
6
|
+
*
|
|
7
|
+
* - live: growthub-sandbox-run-delta-v1 events of type
|
|
8
|
+
* `orchestration.node.{started|completed|failed|skipped}` streamed
|
|
9
|
+
* from POST /api/workspace/sandbox-run while the run is in flight.
|
|
10
|
+
* - settled: the persisted run record's `nodeTrace` (written by the
|
|
11
|
+
* orchestration runner) once the run completes.
|
|
12
|
+
*
|
|
13
|
+
* This is the general orchestration pipeline signal (input → api-registry-call
|
|
14
|
+
* → transform → tool-result, human-input, etc.) — distinct from the swarm
|
|
15
|
+
* cockpit projection. Each entry corresponds to a real pipeline stage that
|
|
16
|
+
* executed; nothing is fabricated. Pure, never throws.
|
|
17
|
+
*
|
|
18
|
+
* Status vocabulary returned: "running" | "completed" | "failed" | "skipped".
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const NODE_EVENT_PREFIX = "orchestration.node.";
|
|
22
|
+
|
|
23
|
+
function deriveOrchestrationNodeStatuses({ events, record } = {}) {
|
|
24
|
+
const out = {};
|
|
25
|
+
try {
|
|
26
|
+
// Live events win while a run streams — later events overwrite earlier.
|
|
27
|
+
const list = Array.isArray(events) ? events : [];
|
|
28
|
+
for (const event of list) {
|
|
29
|
+
if (!event || typeof event !== "object") continue;
|
|
30
|
+
if (event.kind && event.kind !== "growthub-sandbox-run-delta-v1") continue;
|
|
31
|
+
const type = String(event.type || "");
|
|
32
|
+
if (!type.startsWith(NODE_EVENT_PREFIX)) continue;
|
|
33
|
+
const id = String(event.nodeId || "").trim();
|
|
34
|
+
if (!id) continue;
|
|
35
|
+
const phase = type.slice(NODE_EVENT_PREFIX.length);
|
|
36
|
+
out[id] = phase === "started" ? "running" : phase;
|
|
37
|
+
}
|
|
38
|
+
if (Object.keys(out).length) return out;
|
|
39
|
+
|
|
40
|
+
// Settled from the persisted per-node trace.
|
|
41
|
+
const trace = record && Array.isArray(record.nodeTrace) ? record.nodeTrace : [];
|
|
42
|
+
for (const entry of trace) {
|
|
43
|
+
if (!entry || typeof entry !== "object") continue;
|
|
44
|
+
const id = String(entry.id || "").trim();
|
|
45
|
+
if (!id) continue;
|
|
46
|
+
const status = String(entry.status || "").trim();
|
|
47
|
+
if (status) out[id] = status;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export { deriveOrchestrationNodeStatuses };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growthub/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.8",
|
|
4
4
|
"description": "CLI control plane for Growthub Local and Agent Workspace as Code: export, fork, inspect, operate, sync, and optionally activate governed AI workspaces.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|