Add durable reconciliation decisions
This commit is contained in:
@@ -205,6 +205,7 @@ export type TabularSource = {
|
||||
columns: TabularSourceColumn[];
|
||||
schema_version: string;
|
||||
fingerprint: string;
|
||||
decisions_included: boolean;
|
||||
row_count?: number | null;
|
||||
byte_count?: number | null;
|
||||
updated_at?: string | null;
|
||||
@@ -217,6 +218,38 @@ export type TabularSourceCatalogue = {
|
||||
sources: TabularSource[];
|
||||
};
|
||||
|
||||
export type ReconciliationDecisionAction = "accept" | "reject" | "correct" | "defer";
|
||||
|
||||
export type ReconciliationDecision = {
|
||||
ref: string;
|
||||
id: string;
|
||||
revision: number;
|
||||
key_hash: string;
|
||||
input_hash: string;
|
||||
action: ReconciliationDecisionAction;
|
||||
reason: string;
|
||||
correction?: Record<string, unknown> | null;
|
||||
actor_ref: string;
|
||||
decided_at: string;
|
||||
};
|
||||
|
||||
export type ReconciliationDecisionSet = {
|
||||
ref: string;
|
||||
id: string;
|
||||
pipeline_id: string;
|
||||
name: string;
|
||||
node_id?: string | null;
|
||||
resource_revision: number;
|
||||
etag: string;
|
||||
fingerprint: string;
|
||||
current_decisions: ReconciliationDecision[];
|
||||
history: ReconciliationDecision[];
|
||||
created_by?: string | null;
|
||||
updated_by?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PipelineRun = {
|
||||
ref: string;
|
||||
pipeline_id: string;
|
||||
@@ -363,6 +396,58 @@ export function createDataflowSourceSnapshot(
|
||||
});
|
||||
}
|
||||
|
||||
export async function listDataflowDecisionSets(
|
||||
settings: ApiSettings,
|
||||
pipelineId: string
|
||||
): Promise<ReconciliationDecisionSet[]> {
|
||||
const response = await apiFetch<{ decision_sets: ReconciliationDecisionSet[] }>(
|
||||
settings,
|
||||
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/decision-sets`
|
||||
);
|
||||
return response.decision_sets;
|
||||
}
|
||||
|
||||
export function createDataflowDecisionSet(
|
||||
settings: ApiSettings,
|
||||
pipelineId: string,
|
||||
payload: { name: string; node_id?: string | null }
|
||||
): Promise<ReconciliationDecisionSet> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/decision-sets`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export function getDataflowDecisionSet(
|
||||
settings: ApiSettings,
|
||||
decisionSetId: string
|
||||
): Promise<ReconciliationDecisionSet> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/dataflow/decision-sets/${encodeURIComponent(decisionSetId)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function recordDataflowDecision(
|
||||
settings: ApiSettings,
|
||||
decisionSetId: string,
|
||||
payload: {
|
||||
base_revision: number;
|
||||
key_hash: string;
|
||||
input_hash: string;
|
||||
action: ReconciliationDecisionAction;
|
||||
reason: string;
|
||||
correction?: Record<string, unknown> | null;
|
||||
}
|
||||
): Promise<ReconciliationDecisionSet> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/dataflow/decision-sets/${encodeURIComponent(decisionSetId)}/decisions`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listDataflowPipelines(settings: ApiSettings): Promise<Pipeline[]> {
|
||||
const response = await apiFetch<{ pipelines: Pipeline[] }>(settings, "/api/v1/dataflow/pipelines");
|
||||
return response.pipelines;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Code2,
|
||||
CopyPlus,
|
||||
DatabaseZap,
|
||||
ListChecks,
|
||||
Network,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -51,6 +52,7 @@ import { useLocation } from "react-router";
|
||||
import {
|
||||
compileDataflowSql,
|
||||
cancelDataflowPipelineRun,
|
||||
createDataflowDecisionSet,
|
||||
createDataflowTrigger,
|
||||
createDataflowPipeline,
|
||||
createDataflowSourceSnapshot,
|
||||
@@ -58,7 +60,9 @@ import {
|
||||
deleteDataflowTrigger,
|
||||
dataflowScopeReferenceProvider,
|
||||
deriveDataflowPipeline,
|
||||
getDataflowDecisionSet,
|
||||
listDataflowNodeTypes,
|
||||
listDataflowDecisionSets,
|
||||
listDataflowPipelineRuns,
|
||||
listDataflowPipelineDeployments,
|
||||
listDataflowPipelines,
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
listDataflowTriggers,
|
||||
previewDataflowPipeline,
|
||||
promoteDataflowPipeline,
|
||||
recordDataflowDecision,
|
||||
runDataflowPipeline,
|
||||
renderDataflowSql,
|
||||
updateDataflowPipeline,
|
||||
@@ -84,6 +89,8 @@ import {
|
||||
type PipelinePreview,
|
||||
type PipelineDeployment,
|
||||
type PipelineRun,
|
||||
type ReconciliationDecisionAction,
|
||||
type ReconciliationDecisionSet,
|
||||
type TabularSource
|
||||
} from "../../api/dataflow";
|
||||
import DataflowCanvas, { updateGraphNode } from "./DataflowCanvas";
|
||||
@@ -141,6 +148,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
||||
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
|
||||
const [deriveOpen, setDeriveOpen] = useState(false);
|
||||
const [triggersOpen, setTriggersOpen] = useState(false);
|
||||
const [decisionReviewOpen, setDecisionReviewOpen] = useState(false);
|
||||
const [nodeLibrary, setNodeLibrary] = useState<NodeTypeDefinition[]>(FALLBACK_NODE_LIBRARY);
|
||||
const [sources, setSources] = useState<TabularSource[]>([]);
|
||||
const [sourceCatalogueAvailable, setSourceCatalogueAvailable] = useState(false);
|
||||
@@ -932,6 +940,16 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
||||
diagnostics={diagnostics}
|
||||
nodeDiagnostics={nodeDiagnostics}
|
||||
selectedNodeId={selectedNodeId}
|
||||
decisionReviewDisabledReason={
|
||||
!draft.id
|
||||
? "Save the pipeline before recording review decisions."
|
||||
: dirty
|
||||
? "Save the current pipeline revision before recording review decisions."
|
||||
: !canEdit
|
||||
? editBlockedReason
|
||||
: undefined
|
||||
}
|
||||
onReviewDecisions={() => setDecisionReviewOpen(true)}
|
||||
onClose={() => setResultOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1068,6 +1086,22 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
|
||||
editable={canManageTriggers}
|
||||
onClose={() => setTriggersOpen(false)}
|
||||
/>
|
||||
<ReconciliationDecisionDialog
|
||||
open={decisionReviewOpen}
|
||||
settings={settings}
|
||||
pipeline={draft?.id ? { id: draft.id, name: draft.name } : null}
|
||||
node={selectedPreviewNode(draft?.graph.nodes ?? [], preview, selectedNodeId)}
|
||||
preview={preview}
|
||||
editable={canEdit && !dirty}
|
||||
onClose={() => setDecisionReviewOpen(false)}
|
||||
onChanged={() => {
|
||||
void listDataflowSources(settings).then((catalogue) => {
|
||||
setSources(catalogue.sources);
|
||||
setSourceCatalogueAvailable(catalogue.available);
|
||||
setSourceCatalogueWritable(catalogue.writable);
|
||||
}).catch((sourceError) => setError(apiErrorMessage(sourceError)));
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2448,6 +2482,380 @@ function SourceSnapshotDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type ReviewableReconciliationRow = {
|
||||
keyHash: string;
|
||||
inputHash: string;
|
||||
row: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function ReconciliationDecisionDialog({
|
||||
open,
|
||||
settings,
|
||||
pipeline,
|
||||
node,
|
||||
preview,
|
||||
editable,
|
||||
onClose,
|
||||
onChanged
|
||||
}: {
|
||||
open: boolean;
|
||||
settings: ApiSettings;
|
||||
pipeline: { id: string; name: string } | null;
|
||||
node: PipelineGraphNode | null;
|
||||
preview: PipelinePreview | null;
|
||||
editable: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [decisionSets, setDecisionSets] = useState<ReconciliationDecisionSet[]>([]);
|
||||
const [selectedSetId, setSelectedSetId] = useState("");
|
||||
const [selectedKeyHash, setSelectedKeyHash] = useState("");
|
||||
const [newSetName, setNewSetName] = useState("");
|
||||
const [action, setAction] = useState<ReconciliationDecisionAction>("accept");
|
||||
const [reason, setReason] = useState("");
|
||||
const [correctionText, setCorrectionText] = useState("{}");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const rows = useMemo<ReviewableReconciliationRow[]>(() => {
|
||||
const result = preview?.node_preview ?? preview;
|
||||
if (!result || node?.type !== "reconcile.compare") return [];
|
||||
return result.rows.flatMap((row) => {
|
||||
const keyHash = row._reconciliation_key_hash;
|
||||
const inputHash = row._reconciliation_input_hash;
|
||||
if (!isSha256Hex(keyHash) || !isSha256Hex(inputHash)) return [];
|
||||
return [{ keyHash, inputHash, row }];
|
||||
});
|
||||
}, [node?.type, preview]);
|
||||
|
||||
const selectedSet = decisionSets.find((item) => item.id === selectedSetId) ?? null;
|
||||
const selectedRow = rows.find((item) => item.keyHash === selectedKeyHash) ?? null;
|
||||
const currentDecision = selectedSet?.current_decisions.find(
|
||||
(item) => item.key_hash === selectedRow?.keyHash
|
||||
) ?? null;
|
||||
const exactDecision = currentDecision?.input_hash === selectedRow?.inputHash
|
||||
? currentDecision
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedKeyHash((current) => rows.some((item) => item.keyHash === current)
|
||||
? current
|
||||
: rows[0]?.keyHash ?? "");
|
||||
}, [open, rows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !pipeline) {
|
||||
setDecisionSets([]);
|
||||
setSelectedSetId("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void listDataflowDecisionSets(settings, pipeline.id)
|
||||
.then((items) => {
|
||||
if (cancelled) return;
|
||||
const relevant = items.filter((item) => !item.node_id || item.node_id === node?.id);
|
||||
setDecisionSets(relevant);
|
||||
setSelectedSetId((current) => relevant.some((item) => item.id === current)
|
||||
? current
|
||||
: relevant[0]?.id ?? "");
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) setError(apiErrorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [node?.id, open, pipeline?.id, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedSetId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void getDataflowDecisionSet(settings, selectedSetId)
|
||||
.then((item) => {
|
||||
if (cancelled) return;
|
||||
setDecisionSets((current) => current.map(
|
||||
(candidate) => candidate.id === item.id ? item : candidate
|
||||
));
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) setError(apiErrorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, selectedSetId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
setAction(exactDecision?.action ?? "accept");
|
||||
setReason(exactDecision?.reason ?? "");
|
||||
setCorrectionText(JSON.stringify(exactDecision?.correction ?? {}, null, 2));
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}, [exactDecision?.id, selectedKeyHash, selectedSetId]);
|
||||
|
||||
const createSet = async () => {
|
||||
if (!pipeline || !node || !newSetName.trim() || !editable) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const created = await createDataflowDecisionSet(settings, pipeline.id, {
|
||||
name: newSetName.trim(),
|
||||
node_id: node.id
|
||||
});
|
||||
setDecisionSets((current) => [created, ...current.filter((item) => item.id !== created.id)]);
|
||||
setSelectedSetId(created.id);
|
||||
setNewSetName("");
|
||||
setSuccess("Decision set created. It is now available as a governed Dataflow source.");
|
||||
onChanged();
|
||||
} catch (createError) {
|
||||
setError(apiErrorMessage(createError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDecision = async () => {
|
||||
if (!selectedSet || !selectedRow || !editable) return;
|
||||
if (reason.trim().length < 3) {
|
||||
setError("Record a reason of at least three characters.");
|
||||
return;
|
||||
}
|
||||
let correction: Record<string, unknown> | null = null;
|
||||
if (action === "correct") {
|
||||
try {
|
||||
const parsed = JSON.parse(correctionText) as unknown;
|
||||
if (!isRecord(parsed) || !Object.keys(parsed).length) {
|
||||
setError("Correct decisions require a JSON object with at least one corrected field.");
|
||||
return;
|
||||
}
|
||||
correction = parsed;
|
||||
} catch {
|
||||
setError("Correction must be a valid JSON object.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const updated = await recordDataflowDecision(settings, selectedSet.id, {
|
||||
base_revision: selectedSet.resource_revision,
|
||||
key_hash: selectedRow.keyHash,
|
||||
input_hash: selectedRow.inputHash,
|
||||
action,
|
||||
reason: reason.trim(),
|
||||
correction
|
||||
});
|
||||
setDecisionSets((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||
setSuccess(`Recorded decision revision ${updated.resource_revision}.`);
|
||||
onChanged();
|
||||
} catch (saveError) {
|
||||
setError(apiErrorMessage(saveError));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reviewedCount = selectedSet
|
||||
? rows.filter((row) => selectedSet.current_decisions.some(
|
||||
(decision) => decision.key_hash === row.keyHash && decision.input_hash === row.inputHash
|
||||
)).length
|
||||
: 0;
|
||||
const staleCount = selectedSet
|
||||
? rows.filter((row) => selectedSet.current_decisions.some(
|
||||
(decision) => decision.key_hash === row.keyHash && decision.input_hash !== row.inputHash
|
||||
)).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`Review reconciliation${node ? `: ${node.label}` : ""}`}
|
||||
className="dataflow-decision-dialog"
|
||||
bodyClassName="dataflow-decision-dialog-body"
|
||||
closeDisabled={busy}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<span className="dataflow-decision-history-summary">
|
||||
{selectedSet ? `${selectedSet.history.length} immutable decision revision(s)` : "No decision set selected"}
|
||||
</span>
|
||||
<Button onClick={onClose} disabled={busy}>Close</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="dataflow-decision-shell">
|
||||
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
|
||||
<div className="dataflow-decision-set-toolbar">
|
||||
<FormField
|
||||
label="Decision set"
|
||||
help="The current projection is consumable as a source; every prior decision remains in immutable history."
|
||||
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
|
||||
>
|
||||
<select
|
||||
value={selectedSetId}
|
||||
onChange={(event) => setSelectedSetId(event.target.value)}
|
||||
disabled={loading || !decisionSets.length}
|
||||
>
|
||||
{!decisionSets.length ? <option value="">No decision sets</option> : null}
|
||||
{decisionSets.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="New decision set"
|
||||
help="Create one set per review purpose or review round."
|
||||
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
|
||||
>
|
||||
<div className="dataflow-decision-create">
|
||||
<input
|
||||
value={newSetName}
|
||||
onChange={(event) => setNewSetName(event.target.value)}
|
||||
placeholder={`${pipeline?.name ?? "Pipeline"} review`}
|
||||
disabled={!editable || busy}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void createSet()}
|
||||
disabled={!editable || busy || !newSetName.trim()}
|
||||
>
|
||||
<Plus size={16} /> Create
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
<div className="dataflow-decision-counts" aria-label="Review progress">
|
||||
<span><strong>{reviewedCount}</strong> reviewed</span>
|
||||
<span className={staleCount ? "is-warning" : ""}><strong>{staleCount}</strong> stale</span>
|
||||
<span><strong>{Math.max(0, rows.length - reviewedCount - staleCount)}</strong> open</span>
|
||||
</div>
|
||||
</div>
|
||||
{!rows.length ? (
|
||||
<div className="dataflow-results-empty">
|
||||
Run a preview of a reconciliation comparison that emits stable key and input hashes.
|
||||
</div>
|
||||
) : (
|
||||
<div className="dataflow-decision-layout">
|
||||
<div className="dataflow-decision-rows" role="listbox" aria-label="Reconciliation rows">
|
||||
<div className="dataflow-decision-row-heading">
|
||||
<span>Record</span><span>Comparison</span><span>Decision</span>
|
||||
</div>
|
||||
{rows.map((item) => {
|
||||
const decision = selectedSet?.current_decisions.find(
|
||||
(candidate) => candidate.key_hash === item.keyHash
|
||||
);
|
||||
const stale = Boolean(decision && decision.input_hash !== item.inputHash);
|
||||
const statusLabel = stale ? "stale" : decision?.action ?? "open";
|
||||
return (
|
||||
<button
|
||||
key={item.keyHash}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.keyHash === selectedKeyHash}
|
||||
className={item.keyHash === selectedKeyHash ? "is-selected" : ""}
|
||||
onClick={() => setSelectedKeyHash(item.keyHash)}
|
||||
>
|
||||
<span title={item.keyHash}>{reviewRowLabel(item.row)}</span>
|
||||
<span>{formatCell(item.row._reconciliation_status)}</span>
|
||||
<span className={`dataflow-decision-state is-${statusLabel}`}>{statusLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="dataflow-decision-editor">
|
||||
{selectedRow && selectedSet ? (
|
||||
<>
|
||||
<div className="dataflow-decision-editor-heading">
|
||||
<div>
|
||||
<strong>{reviewRowLabel(selectedRow.row)}</strong>
|
||||
<small title={selectedRow.keyHash}>{selectedRow.keyHash}</small>
|
||||
</div>
|
||||
{currentDecision && !exactDecision ? (
|
||||
<StatusBadge status="warning" label="Prior decision is stale" />
|
||||
) : exactDecision ? (
|
||||
<StatusBadge status="success" label={`Current: ${exactDecision.action}`} />
|
||||
) : (
|
||||
<StatusBadge status="inactive" label="Unreviewed" />
|
||||
)}
|
||||
</div>
|
||||
<FormField label="Decision" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
|
||||
<SegmentedControl<ReconciliationDecisionAction>
|
||||
ariaLabel="Reconciliation decision"
|
||||
options={[
|
||||
{ id: "accept", label: "Accept" },
|
||||
{ id: "reject", label: "Reject" },
|
||||
{ id: "correct", label: "Correct" },
|
||||
{ id: "defer", label: "Defer" }
|
||||
]}
|
||||
value={action}
|
||||
onChange={setAction}
|
||||
disabled={!editable || busy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Reason"
|
||||
help="The reason is retained with the actor, exact input hash, and immutable revision."
|
||||
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
|
||||
>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
disabled={!editable || busy}
|
||||
/>
|
||||
</FormField>
|
||||
{action === "correct" ? (
|
||||
<FormField
|
||||
label="Corrected fields (JSON)"
|
||||
help="Corrections are annotations. A downstream governed transform decides whether to apply them."
|
||||
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
|
||||
>
|
||||
<textarea
|
||||
className="dataflow-json-editor"
|
||||
value={correctionText}
|
||||
onChange={(event) => setCorrectionText(event.target.value)}
|
||||
spellCheck={false}
|
||||
disabled={!editable || busy}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<div className="dataflow-decision-editor-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveDecision()}
|
||||
disabled={!editable || busy || reason.trim().length < 3}
|
||||
>
|
||||
<Save size={16} /> {busy ? "Recording..." : "Record revision"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="dataflow-results-empty">
|
||||
{loading ? "Loading decision sets..." : "Create or select a decision set to review this row."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPanel({
|
||||
tab,
|
||||
onTabChange,
|
||||
@@ -2456,6 +2864,8 @@ function ResultPanel({
|
||||
diagnostics,
|
||||
nodeDiagnostics,
|
||||
selectedNodeId,
|
||||
decisionReviewDisabledReason,
|
||||
onReviewDecisions,
|
||||
onClose
|
||||
}: {
|
||||
tab: ResultTab;
|
||||
@@ -2465,6 +2875,8 @@ function ResultPanel({
|
||||
diagnostics: DataflowDiagnostic[];
|
||||
nodeDiagnostics: NodePreviewDiagnostic[];
|
||||
selectedNodeId: string | null;
|
||||
decisionReviewDisabledReason?: string;
|
||||
onReviewDecisions: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const outputNodeId = nodes.find((node) => node.type === "output")?.id ?? "";
|
||||
@@ -2491,7 +2903,18 @@ function ResultPanel({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="ghost" onClick={onClose}>Close</Button>
|
||||
<div className="dataflow-results-actions">
|
||||
{tab === "preview" && previewNode?.type === "reconcile.compare" ? (
|
||||
<Button
|
||||
onClick={onReviewDecisions}
|
||||
disabled={!preview || Boolean(decisionReviewDisabledReason)}
|
||||
disabledReason={decisionReviewDisabledReason}
|
||||
>
|
||||
<ListChecks size={16} /> Review decisions
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
{tab === "preview" ? (
|
||||
<PreviewTable preview={preview} />
|
||||
@@ -2625,6 +3048,27 @@ function formatCell(value: unknown): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function selectedPreviewNode(
|
||||
nodes: PipelineGraphNode[],
|
||||
preview: PipelinePreview | null,
|
||||
selectedNodeId: string | null
|
||||
): PipelineGraphNode | null {
|
||||
const nodeId = preview?.node_preview?.node_id ?? selectedNodeId;
|
||||
return nodes.find((node) => node.id === nodeId) ?? null;
|
||||
}
|
||||
|
||||
function isSha256Hex(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function reviewRowLabel(row: Record<string, unknown>): string {
|
||||
const values = Object.entries(row)
|
||||
.filter(([key, value]) => !key.startsWith("_") && value !== null && value !== undefined)
|
||||
.slice(0, 3)
|
||||
.map(([key, value]) => `${key}: ${formatCell(value)}`);
|
||||
return values.join(" · ") || "Reconciliation row";
|
||||
}
|
||||
|
||||
function sourceNameFromFile(value: string): string {
|
||||
const normalized = value
|
||||
.normalize("NFKD")
|
||||
|
||||
@@ -736,6 +736,210 @@
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.dataflow-decision-dialog {
|
||||
width: min(1080px, calc(100vw - 32px));
|
||||
height: min(760px, calc(100vh - 48px));
|
||||
}
|
||||
|
||||
.dataflow-decision-dialog-body {
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dataflow-decision-shell {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dataflow-decision-set-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 0.8fr) minmax(300px, 1.2fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dataflow-decision-set-toolbar select,
|
||||
.dataflow-decision-set-toolbar input,
|
||||
.dataflow-decision-editor textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dataflow-decision-create,
|
||||
.dataflow-decision-counts,
|
||||
.dataflow-results-actions,
|
||||
.dataflow-decision-editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dataflow-decision-create input {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.dataflow-decision-create .btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dataflow-decision-counts {
|
||||
min-height: 34px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataflow-decision-counts strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.dataflow-decision-counts .is-warning strong {
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.dataflow-decision-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 1.1fr) minmax(340px, 0.9fr);
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dataflow-decision-rows,
|
||||
.dataflow-decision-editor {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dataflow-decision-rows {
|
||||
border-right: var(--border-line);
|
||||
}
|
||||
|
||||
.dataflow-decision-row-heading,
|
||||
.dataflow-decision-rows > button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(90px, 0.45fr) 90px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 0;
|
||||
border-bottom: var(--border-line);
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dataflow-decision-row-heading {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
background: var(--panel-header);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dataflow-decision-rows > button {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dataflow-decision-rows > button:hover,
|
||||
.dataflow-decision-rows > button.is-selected {
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.dataflow-decision-rows > button.is-selected {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.dataflow-decision-rows > button > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataflow-decision-state {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dataflow-decision-state.is-accept,
|
||||
.dataflow-decision-state.is-correct {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.dataflow-decision-state.is-reject,
|
||||
.dataflow-decision-state.is-stale {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.dataflow-decision-state.is-defer {
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.dataflow-decision-editor {
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dataflow-decision-editor-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.dataflow-decision-editor-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dataflow-decision-editor-heading strong,
|
||||
.dataflow-decision-editor-heading small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dataflow-decision-editor-heading small {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataflow-decision-editor .dataflow-json-editor {
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.dataflow-decision-editor-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dataflow-decision-history-summary {
|
||||
margin-right: auto;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dataflow-run-dialog {
|
||||
width: min(860px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user