Skip to content

Commit 6be34c7

Browse files
authored
[codex] Polish DAG viewer metadata and previews (#1340)
Polish DAG viewer metadata and previews
1 parent 087df55 commit 6be34c7

19 files changed

Lines changed: 534 additions & 102 deletions

DAG_viewer/public/app.js

Lines changed: 260 additions & 92 deletions
Large diffs are not rendered by default.

DAG_viewer/public/styles.css

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ pre {
120120
}
121121

122122
.dag-launcher {
123-
flex: 0 1 360px;
123+
flex: 1 1 680px;
124+
max-width: 820px;
124125
min-width: 0;
125126
position: relative;
126127
}
@@ -559,6 +560,30 @@ body.is-panning {
559560
z-index: 0;
560561
}
561562

563+
.system-overview-group-frame {
564+
background: rgba(255, 255, 255, 0.2);
565+
border: 1.5px dashed rgba(39, 38, 35, 0.46);
566+
border-radius: 28px;
567+
pointer-events: none;
568+
position: absolute;
569+
z-index: 0;
570+
}
571+
572+
.system-overview-group-label {
573+
color: rgba(36, 35, 32, 0.78);
574+
font-size: 0.76rem;
575+
font-weight: 700;
576+
letter-spacing: 0.08em;
577+
line-height: 1.2;
578+
pointer-events: none;
579+
position: absolute;
580+
text-align: center;
581+
text-transform: uppercase;
582+
transform: translateX(-50%);
583+
width: 240px;
584+
z-index: 1;
585+
}
586+
562587
.system-overview-entry {
563588
align-items: center;
564589
appearance: none;
@@ -843,6 +868,7 @@ body.is-panning {
843868
display: grid;
844869
gap: 12px;
845870
grid-auto-flow: row;
871+
min-height: 0;
846872
overflow: auto;
847873
padding: 8px 12px 12px;
848874
}
@@ -1076,7 +1102,7 @@ body.is-panning {
10761102
}
10771103

10781104
.seed-table-wrap {
1079-
max-height: 420px;
1105+
max-height: none;
10801106
}
10811107

10821108
.seed-preview-table th,

DAG_viewer/scripts/build-lineage.mjs

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,8 +1741,7 @@ function buildSeedViewer(manifestNode) {
17411741
}
17421742

17431743
if (normalizedPath.includes("seeds/value_sets/")) {
1744-
const datasetKey = buildValueSetSeedPublicKey({ manifestNode, normalizedPath });
1745-
const fileName = datasetKey ? `${datasetKey}.csv_0_0_0.csv.gz` : null;
1744+
const fileName = buildValueSetSeedObjectFileName({ manifestNode, normalizedPath });
17461745

17471746
return {
17481747
sourceType: "seed_preview",
@@ -1758,7 +1757,10 @@ function buildSeedViewer(manifestNode) {
17581757
}
17591758

17601759
function buildTerminologySeedDownloadUrl({ manifestNode, baseDomain }) {
1761-
const datasetKey = (manifestNode?.name || "").replace(/^terminology__/, "");
1760+
const seedName = manifestNode?.name || "";
1761+
const datasetKey = seedName === "terminology__icd10_pcs_cms_ontology"
1762+
? "icd_10_pcs_cms_ontology"
1763+
: seedName.replace(/^terminology__/, "");
17621764

17631765
if (!datasetKey || datasetKey === "provider") {
17641766
return null;
@@ -1767,18 +1769,74 @@ function buildTerminologySeedDownloadUrl({ manifestNode, baseDomain }) {
17671769
return `${baseDomain}/versioned_terminology/latest/${datasetKey}.csv_0_0_0.csv.gz`;
17681770
}
17691771

1770-
function buildValueSetSeedPublicKey({ manifestNode, normalizedPath }) {
1772+
function buildValueSetSeedObjectFileName({ manifestNode, normalizedPath }) {
17711773
const seedName = manifestNode?.name || path.posix.basename(normalizedPath).replace(/\.csv$/i, "");
17721774

1775+
if (!seedName) {
1776+
return null;
1777+
}
1778+
1779+
if (seedName === "encounter_group_sk" || seedName === "encounter_type_sk" || seedName === "predictor_encounter_xwalk") {
1780+
return `${seedName}.csv.gz`;
1781+
}
1782+
1783+
if (seedName.startsWith("pqi__")) {
1784+
return null;
1785+
}
1786+
17731787
if (seedName === "ed_classification__categories") {
1774-
return "ed_classification_categories";
1788+
return "ed_classification_categories.csv_0_0_0.csv.gz";
17751789
}
17761790

17771791
if (seedName.startsWith("ed_classification__")) {
1778-
return seedName.replace(/^ed_classification__/, "");
1792+
return `${seedName.replace(/^ed_classification__/, "")}.csv_0_0_0.csv.gz`;
1793+
}
1794+
1795+
if (seedName.startsWith("ccsr__")) {
1796+
return `${seedName.replace(/^ccsr__/, "")}.csv_0_0_0.csv.gz`;
1797+
}
1798+
1799+
if (seedName.startsWith("chronic_conditions__")) {
1800+
return `${seedName.replace(/^chronic_conditions__/, "")}.csv_0_0_0.csv.gz`;
1801+
}
1802+
1803+
if (seedName === "cms_hcc__disease_hierarchy_flat") {
1804+
return null;
1805+
}
1806+
1807+
if (seedName.startsWith("cms_hcc__")) {
1808+
return `${seedName.replace(/^cms_hcc__/, "cms_hcc_")}.csv_0_0_0.csv.gz`;
1809+
}
1810+
1811+
if (seedName.startsWith("data_quality__")) {
1812+
return `${seedName.replace(/^data_quality__/, "data_quality_")}.csv_0_0_0.csv.gz`;
1813+
}
1814+
1815+
if (seedName === "hcc_suspecting__hcc_descriptions") {
1816+
return "hcc_suspecting_descriptions.csv_0_0_0.csv.gz";
1817+
}
1818+
1819+
if (seedName.startsWith("hcc_suspecting__")) {
1820+
return `${seedName.replace(/^hcc_suspecting__/, "hcc_suspecting_")}.csv_0_0_0.csv.gz`;
1821+
}
1822+
1823+
if (seedName.startsWith("pharmacy__")) {
1824+
return `${seedName.replace(/^pharmacy__/, "")}.csv_0_0_0.csv.gz`;
1825+
}
1826+
1827+
if (seedName === "quality_measures__value_sets") {
1828+
return "quality_measures_value_set_codes.csv_0_0_0.csv.gz";
1829+
}
1830+
1831+
if (seedName.startsWith("quality_measures__")) {
1832+
return `${seedName.replace(/^quality_measures__/, "quality_measures_")}.csv_0_0_0.csv.gz`;
1833+
}
1834+
1835+
if (seedName.startsWith("readmissions__")) {
1836+
return `${seedName.replace(/^readmissions__/, "")}.csv_0_0_0.csv.gz`;
17791837
}
17801838

1781-
return seedName || null;
1839+
return `${seedName}.csv_0_0_0.csv.gz`;
17821840
}
17831841

17841842
function sortNodeType(nodeType) {

DAG_viewer/scripts/build-static-data.mjs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
22
import { existsSync } from "node:fs";
33
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
44
import path from "node:path";
5+
import { gunzipSync } from "node:zlib";
56
import { fileURLToPath, pathToFileURL } from "node:url";
67

78
import { parse as parseYaml } from "yaml";
@@ -15,6 +16,7 @@ const manifestPath = path.join(cacheRoot, "manifest.json");
1516
const lineageCacheRoot = path.join(cacheRoot, "lineage");
1617
const defaultRepoUrl = "https://github.com/tuva-health/tuva.git";
1718
const defaultGithubRef = "main";
19+
const staticSeedPreviewRowLimit = Number(process.env.TUVA_DAG_SEED_PREVIEW_ROW_LIMIT || 1000) || 1000;
1820

1921
async function main() {
2022
const sourceRoot = await resolveSourceRoot();
@@ -281,14 +283,18 @@ async function exportLineage(sourceRoot) {
281283

282284
const lineageModule = await import(pathToFileURL(path.join(scriptDir, "build-lineage.mjs")).href);
283285
const targets = await lineageModule.listTargetConfigs();
286+
const seedPreviewNodes = new Map();
284287

285288
for (const target of targets) {
286289
const payload = await lineageModule.buildLineagePayload({ targetKey: target.key });
287290
const staticResponse = buildStaticResponse({ payload, targets });
288291
const outputPath = path.join(distRoot, "data", `${target.key}-lineage.json`);
289292

290293
await writeFile(outputPath, `${JSON.stringify(staticResponse, null, 2)}\n`, "utf8");
294+
collectSeedPreviewNodes(seedPreviewNodes, payload);
291295
}
296+
297+
await exportStaticSeedPreviews(seedPreviewNodes);
292298
}
293299

294300
function buildStaticResponse({ payload, targets }) {
@@ -314,6 +320,151 @@ function buildStaticResponse({ payload, targets }) {
314320
};
315321
}
316322

323+
function collectSeedPreviewNodes(seedPreviewNodes, payload) {
324+
for (const node of payload.nodes || []) {
325+
if (node?.resourceType !== "seed" || seedPreviewNodes.has(node.id)) {
326+
continue;
327+
}
328+
329+
seedPreviewNodes.set(node.id, {
330+
nodeId: node.id,
331+
name: node.name,
332+
csvPath: node.paths?.sql || null,
333+
seedViewer: node.seedViewer || null,
334+
columns: node.columns || []
335+
});
336+
}
337+
}
338+
339+
async function exportStaticSeedPreviews(seedPreviewNodes) {
340+
const outputRoot = path.join(distRoot, "data", "seed-previews");
341+
await mkdir(outputRoot, { recursive: true });
342+
343+
for (const seedNode of seedPreviewNodes.values()) {
344+
const snapshot = await buildStaticSeedPreviewSnapshot(seedNode);
345+
const outputPath = path.join(outputRoot, `${seedNode.nodeId}.json`);
346+
await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
347+
}
348+
}
349+
350+
async function buildStaticSeedPreviewSnapshot(seedNode) {
351+
const fallbackHeaders = seedNode.columns.map((column) => column.name).filter(Boolean);
352+
let preview = null;
353+
let sourceError = null;
354+
355+
if (seedNode.seedViewer?.downloadUrl) {
356+
try {
357+
preview = await readCsvPreviewFromUrl(seedNode.seedViewer.downloadUrl, staticSeedPreviewRowLimit, fallbackHeaders);
358+
} catch (error) {
359+
sourceError = error instanceof Error ? error.message : String(error);
360+
}
361+
}
362+
363+
if (!preview && seedNode.csvPath && existsSync(seedNode.csvPath)) {
364+
try {
365+
preview = await readCsvPreviewFromFile(seedNode.csvPath, staticSeedPreviewRowLimit, fallbackHeaders);
366+
} catch (error) {
367+
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
368+
}
369+
}
370+
371+
return {
372+
nodeId: seedNode.nodeId,
373+
name: seedNode.name,
374+
generatedAt: new Date().toISOString(),
375+
sourceUrl: seedNode.seedViewer?.downloadUrl || null,
376+
sourceError,
377+
headers: preview?.headers?.length ? preview.headers : fallbackHeaders,
378+
rows: preview?.rows || [],
379+
cachedRows: preview?.rows?.length || 0,
380+
totalRows: preview?.totalRows ?? null,
381+
rowLimit: staticSeedPreviewRowLimit,
382+
truncated: Boolean(preview?.truncated)
383+
};
384+
}
385+
386+
async function readCsvPreviewFromUrl(url, rowLimit, preferredHeaders = []) {
387+
const response = await fetch(url);
388+
389+
if (!response.ok) {
390+
throw new Error(`CSV source returned ${response.status}`);
391+
}
392+
393+
const sourceBuffer = Buffer.from(await response.arrayBuffer());
394+
const csvBuffer = isGzipBuffer(sourceBuffer) ? gunzipSync(sourceBuffer) : sourceBuffer;
395+
return readCsvPreviewFromText(csvBuffer.toString("utf8"), rowLimit, preferredHeaders);
396+
}
397+
398+
async function readCsvPreviewFromFile(filePath, rowLimit, preferredHeaders = []) {
399+
return readCsvPreviewFromText(await readFile(filePath, "utf8"), rowLimit, preferredHeaders);
400+
}
401+
402+
function readCsvPreviewFromText(csvText, rowLimit, preferredHeaders = []) {
403+
const lines = csvText.split(/\r?\n/);
404+
const firstLine = lines.length ? parseCsvLine(lines[0].replace(/^\uFEFF/, "")) : [];
405+
const hasPreferredHeaders = preferredHeaders.length > 0;
406+
const headers = hasPreferredHeaders ? preferredHeaders : firstLine;
407+
const firstLineIsHeader = hasPreferredHeaders
408+
? doCsvHeadersMatch(firstLine, preferredHeaders)
409+
: firstLine.length > 0;
410+
const dataLines = lines.slice(firstLineIsHeader ? 1 : 0).filter((line) => line.length);
411+
const rows = dataLines.slice(0, rowLimit).map(parseCsvLine);
412+
413+
return {
414+
headers,
415+
rows,
416+
totalRows: dataLines.length,
417+
truncated: dataLines.length > rowLimit
418+
};
419+
}
420+
421+
function doCsvHeadersMatch(csvHeaders, preferredHeaders) {
422+
if (csvHeaders.length !== preferredHeaders.length) {
423+
return false;
424+
}
425+
426+
return csvHeaders.every((header, index) => normalizeCsvHeader(header) === normalizeCsvHeader(preferredHeaders[index]));
427+
}
428+
429+
function normalizeCsvHeader(header) {
430+
return String(header || "").trim().toLowerCase();
431+
}
432+
433+
function isGzipBuffer(buffer) {
434+
return buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b;
435+
}
436+
437+
function parseCsvLine(line) {
438+
const cells = [];
439+
let cell = "";
440+
let inQuotes = false;
441+
442+
for (let index = 0; index < line.length; index += 1) {
443+
const character = line[index];
444+
445+
if (character === "\"") {
446+
if (inQuotes && line[index + 1] === "\"") {
447+
cell += "\"";
448+
index += 1;
449+
} else {
450+
inQuotes = !inQuotes;
451+
}
452+
continue;
453+
}
454+
455+
if (character === "," && !inQuotes) {
456+
cells.push(cell);
457+
cell = "";
458+
continue;
459+
}
460+
461+
cell += character;
462+
}
463+
464+
cells.push(cell);
465+
return cells;
466+
}
467+
317468
async function walkSelectedSourceFiles(sourceRoot) {
318469
const roots = ["models", "seeds"];
319470
const files = [];

models/input_layer/input_layer__appointment.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ models:
55
The appointment table contains information related to appointments at a healthcare facility. This table may include canceled, completed, or scheduled appointments.
66
grain: One record per appointment per patient.
77
transformation_steps: |-
8-
This model brings the input layer appointment table into the Tuva package -- it does not do any transformation.
8+
This model brings the input layer appointment table into the Tuva package. It does not do any transformation; it's just `select *`.
99
mapping_instructions: 'No explicit mapping instructions provided.'
1010
config:
1111
schema: |
@@ -121,3 +121,4 @@ models:
121121
config:
122122
meta:
123123
data_type: varchar
124+
is_primary_key: true

models/input_layer/input_layer__condition.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ models:
66
patients have been officially diagnosed with or are suspected of having.
77
This includes problems, admitting diagnosis codes, and billable diagnoses.
88
grain: Intended to be one record per condition record per data source.
9+
transformation_steps: |-
10+
This model brings the input layer condition table into the Tuva package. It does not do any transformation; it's just `select *`.
911
mapping_instructions: 'No explicit mapping instructions provided.'
1012
config:
1113
schema: "{%- if var('tuva_schema_prefix',None) != None -%}{{var('tuva_schema_prefix')}}_input_layer{% else %}input_layer{%- endif -%}\n"

models/input_layer/input_layer__eligibility.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ models:
99
Eligibility data is also used to calculate member months, which are
1010
needed for measures such as PMPM.
1111
grain: Intended to be one record per person, member, enrollment span, payer, plan, and data source.
12+
transformation_steps: |-
13+
This model brings the input layer eligibility table into the Tuva package. It does not do any transformation; it's just `select *`.
1214
mapping_instructions: ''
1315
tests:
1416
- dbt_utils.unique_combination_of_columns:

models/input_layer/input_layer__encounter.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ models:
88
encounter_type field correctly for clinical data; in claims data this
99
field is determined automatically.
1010
grain: Intended to be one record per encounter per data source.
11+
transformation_steps: |-
12+
This model brings the input layer encounter table into the Tuva package. It does not do any transformation; it's just `select *`.
1113
mapping_instructions: 'No explicit mapping instructions provided.'
1214
config:
1315
schema: |

models/input_layer/input_layer__immunization.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ models:
55
The immunization table contains information about immunizations
66
administered to patients.
77
grain: Intended to be one record per immunization event per data source.
8+
transformation_steps: |-
9+
This model brings the input layer immunization table into the Tuva package. It does not do any transformation; it's just `select *`.
810
mapping_instructions: 'No explicit mapping instructions provided.'
911
config:
1012
schema: |

0 commit comments

Comments
 (0)