From c1d8f695f1b048241bca32679d7af1d5b034f86e Mon Sep 17 00:00:00 2001 From: Maximiliano Osorio Date: Sun, 9 Aug 2026 18:37:27 -0400 Subject: [PATCH] fix(ui-react): load the thread execution pipeline from Hasura (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Parameters, Runs and Results steps were stubs. threadExecutionData started null in MintThread, no query ever loaded it, and both setters bailed out on null, so the steps were fed an empty object. Parameters showed "Please select model(s) first." with no Continue button, Runs and Results never unlocked, and a satisfied Datasets step still read Pending because datasetsComplete read the same dead field. The Datasets step was a stub too, which the issue did not name: it sent UpdateThreadData with empty `data` and `modelIO`, and that mutation deletes every binding for the thread first. So Continue destroyed the bindings instead of writing them, and the assignment did not survive a reload. - GetThreadExecution loads models, ensembles, bindings, dataslices and execution summaries; threadExecutionFromGQL maps them, mirroring the Lit adapters. - DatasetsStep persists dataslices and thread_model_io, and seeds itself from what is already bound. - UpdateThreadParameters writes thread_model_parameter and the execution summaries, so the Runs step has something to submit. - handleFetchRuns queries executions for a thread model; the summary is polled while runs are in flight. Three defects found while wiring it, each fixed here: - Resource ids were a djb2 hash where Lit writes an MD5 of the URL, and TACC's rows carry the MD5. A dataslice written here would have stored a second row for a file the deployment already had. Verified against a live TACC dataslice. - execution.run_progress is a fraction, not a percentage. The progress bar drew a completed run as a 1%-wide sliver. - total_runs counted parameter values only. A run is one combination of input resources and parameter values, so a 6-file dataslice is 6 runs, not 1 — which is what TACC's own summaries hold. parametersComplete now also requires an execution summary: a model with no adjustable parameters satisfied the binding test vacuously, so the step read done before it had written anything. Corrects docs/adr/0002: parameters/runs/results were listed as ported. --- ...2-react-frontend-replaces-litelement-ui.md | 10 +- ui-react/package-lock.json | 10 + ui-react/package.json | 1 + ui-react/src/graphql/generated/execution.ts | 2 + ui-react/src/graphql/generated/modeling.ts | 11 +- .../src/graphql/generated/thread-execution.ts | 448 ++++++++++++++++++ .../src/lib/__tests__/thread-datasets.test.ts | 98 ++++ .../lib/__tests__/thread-execution.test.ts | 373 +++++++++++++++ ui-react/src/lib/thread-datasets.ts | 101 ++++ ui-react/src/lib/thread-execution.ts | 294 ++++++++++++ ui-react/src/pages/modeling/MintThread.tsx | 251 ++++++---- .../pages/modeling/thread/MintDatasets.tsx | 20 +- .../pages/modeling/thread/MintParameters.tsx | 26 +- .../src/pages/modeling/thread/MintRuns.tsx | 5 +- .../thread/__tests__/MintThread.test.tsx | 171 ++++++- .../modeling/thread/wizard/DatasetsStep.tsx | 188 ++++++-- .../wizard/__tests__/DatasetsStep.test.tsx | 69 ++- .../__tests__/buildThreadModels.test.ts | 92 ---- .../thread/wizard/buildThreadModels.ts | 58 --- 19 files changed, 1905 insertions(+), 323 deletions(-) create mode 100644 ui-react/src/graphql/generated/thread-execution.ts create mode 100644 ui-react/src/lib/__tests__/thread-datasets.test.ts create mode 100644 ui-react/src/lib/__tests__/thread-execution.test.ts create mode 100644 ui-react/src/lib/thread-datasets.ts create mode 100644 ui-react/src/lib/thread-execution.ts delete mode 100644 ui-react/src/pages/modeling/thread/wizard/__tests__/buildThreadModels.test.ts delete mode 100644 ui-react/src/pages/modeling/thread/wizard/buildThreadModels.ts diff --git a/docs/adr/0002-react-frontend-replaces-litelement-ui.md b/docs/adr/0002-react-frontend-replaces-litelement-ui.md index f118ad3..e4a56b2 100644 --- a/docs/adr/0002-react-frontend-replaces-litelement-ui.md +++ b/docs/adr/0002-react-frontend-replaces-litelement-ui.md @@ -96,7 +96,7 @@ Do **not** port LitElement patterns — Redux, Webpack, Lit decorators, `@lit/reactive-element` — into `ui-react`. Screens ported "1:1" mean behaviour parity, not structural parity. -## Current state (2026-08-08) +## Current state (2026-08-09) **Ported and merged** (route → page): `/` home, `/models` faceted configuration browser, `/models/register` and `/models/configure/:slug` (config-first registration replacing the @@ -105,6 +105,14 @@ thread wizard and the datasets/parameters/runs/results steps, `/datasets/*`, `/r including the editor, `/variables` as a standard-variable-primary searchable catalog, plus OAuth2 callback and login-required routes. +> **Correction (2026-08-09, issue #104).** Until this date the parameters, runs and results +> steps were listed above but were **stubs**: their component was rendered, and nothing ever +> loaded the execution state it reads, so the wizard dead-ended at Parameters. The Datasets +> step wrote no binding either. Both are fixed — `GetThreadExecution` loads the pipeline and +> the two steps persist what they collect. Do not read the paragraph above as a porting +> inventory: a route being listed means a component exists at it, not that its data path is +> wired. + **Not ported** (still Lit-only): Analysis, Emulators, Messages, `models-compare` / `models-calibrate` / `models-cromo`, thread Visualize and Summary, and the 3,256-LOC `model-view` detail screen. Some of these are dead or stubbed in the Lit app anyway diff --git a/ui-react/package-lock.json b/ui-react/package-lock.json index 41b0212..7e971c2 100644 --- a/ui-react/package-lock.json +++ b/ui-react/package-lock.json @@ -37,6 +37,7 @@ "react-router-dom": "^6.26.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", + "ts-md5": "^1.3.1", "uuid": "^14.0.0", "zod": "^3.23.0" }, @@ -11617,6 +11618,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ts-md5": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-1.3.1.tgz", + "integrity": "sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/ui-react/package.json b/ui-react/package.json index 95f8c16..27f2d67 100644 --- a/ui-react/package.json +++ b/ui-react/package.json @@ -53,6 +53,7 @@ "react-router-dom": "^6.26.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", + "ts-md5": "^1.3.1", "uuid": "^14.0.0", "zod": "^3.23.0" }, diff --git a/ui-react/src/graphql/generated/execution.ts b/ui-react/src/graphql/generated/execution.ts index c4d75bf..b285eba 100644 --- a/ui-react/src/graphql/generated/execution.ts +++ b/ui-react/src/graphql/generated/execution.ts @@ -39,6 +39,8 @@ export interface ModelInputFile { resources?: Array<{ id: string; name: string; url?: string | null; selected?: boolean | null }>; } | null; variables?: string[]; + /** The model runs without this input bound; the Datasets step may skip it. */ + isOptional?: boolean; } /** Mirrors ModelIO (output file) from ui legacy */ diff --git a/ui-react/src/graphql/generated/modeling.ts b/ui-react/src/graphql/generated/modeling.ts index 4e7e1c7..b989d77 100644 --- a/ui-react/src/graphql/generated/modeling.ts +++ b/ui-react/src/graphql/generated/modeling.ts @@ -1335,7 +1335,10 @@ export type ModelConfigInfo = { }; /** A configuration or setup that carries inputs/outputs — the unit extractModelIO consumes. */ -export type ModelIOConfig = Pick & { +// `regions` is optional: the thread execution query reads a configuration's I/O +// without its regions, and extractModelIO never looks at them. +export type ModelIOConfig = Pick & { + regions?: ConfigRegionRef[]; child_configurations?: ModelSetupInfo[]; }; @@ -1370,8 +1373,10 @@ export type ModelIO = { }; function specToVar(spec: DatasetSpecRef, optional: boolean): ModelInputVar { - const svs = spec.presentations - .map((p) => p.presentation.standard_variable) + // An input with no variable presentation is a real state in the catalog, and + // it must not take the whole step down with it. + const svs = (spec.presentations ?? []) + .map((p) => p.presentation?.standard_variable) .filter((sv): sv is StandardVariableRef => !!sv); return { id: spec.id, diff --git a/ui-react/src/graphql/generated/thread-execution.ts b/ui-react/src/graphql/generated/thread-execution.ts new file mode 100644 index 0000000..d4a7aa9 --- /dev/null +++ b/ui-react/src/graphql/generated/thread-execution.ts @@ -0,0 +1,448 @@ +/** + * GraphQL operations for the thread execution pipeline. + * + * Covers the `thread_model*`, `thread_data`, `dataslice` and `execution` tables + * that the Datasets, Parameters, Runs and Results steps read and write. + * + * Hand-authored in the same style as modeling.ts, and mirroring the legacy Lit + * operations one for one: + * ui/src/queries/thread/get.graphql -> GetThreadExecution + * ui/src/queries/thread/update-parameters.graphql -> UpdateThreadParameters + * ui/src/queries/execution/executions-for-thread-model -> GetThreadModelExecutions + * + * No aggregate field is selected here on purpose: `*_aggregate` is not exposed + * to Hasura's `anonymous` role on either deployment, so an aggregate would fail + * the whole document for a signed-out reader. Resource rows are counted + * client-side instead (see threadExecutionFromGQL). + */ +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; + +const defaultOptions = {} as const; + +// ─── Row types ──────────────────────────────────────────────────────────────── + +export type ExecutionSummaryRow = { + __typename?: 'thread_model_execution_summary'; + total_runs: number; + submitted_runs: number; + successful_runs: number; + failed_runs: number; + ingested_runs: number; + registered_runs: number; + published_runs: number; + fetched_run_outputs: number; + submission_time?: string | null; + submitted_for_execution: boolean; + submitted_for_ingestion: boolean; + submitted_for_publishing: boolean; + submitted_for_registration: boolean; + workflow_name?: string | null; +}; + +export type StandardVariableRow = { id: string; label?: string | null }; + +export type DatasetSpecRow = { + id: string; + label?: string | null; + presentations: Array<{ + dataset_specification_id?: string; + presentation_id?: string; + presentation: { id: string; standard_variable?: StandardVariableRow | null }; + }>; +}; + +export type ParameterRow = { + id: string; + label?: string | null; + description?: string | null; + has_data_type?: string | null; + has_default_value?: string | null; + has_fixed_value?: string | null; + has_minimum_accepted_value?: string | null; + has_maximum_accepted_value?: string | null; + has_accepted_values?: string[] | null; + position?: number | null; +}; + +export type ThreadModelRow = { + __typename?: 'thread_model'; + id: string; + modelcatalog_configuration_id?: string | null; + execution_summary: ExecutionSummaryRow[]; + modelcatalog_configuration?: { + id: string; + label?: string | null; + description?: string | null; + usage_notes?: string | null; + inputs: Array<{ + configuration_id?: string; + input_id?: string; + is_optional?: boolean | null; + input: DatasetSpecRow; + }>; + outputs: Array<{ + configuration_id?: string; + output_id?: string; + output: DatasetSpecRow; + }>; + parameters: Array<{ + configuration_id?: string; + parameter_id?: string; + parameter: ParameterRow; + }>; + } | null; + data_bindings: Array<{ + thread_model_id?: string; + model_io_id: string; + dataslice_id: string; + }>; + parameter_bindings: Array<{ + thread_model_id?: string; + model_parameter_id: string; + parameter_value: string; + }>; +}; + +export type ThreadDataRow = { + __typename?: 'thread_data'; + thread_id?: string; + dataslice: { + id: string; + name: string; + start_date?: string | null; + end_date?: string | null; + resource_count: number; + dataset: { id: string; name: string }; + resources: Array<{ + dataslice_id?: string; + resource_id?: string; + selected: boolean; + resource: { id: string; dcid?: string | null; name: string; url: string }; + }>; + }; +}; + +export type ThreadExecutionRow = { + __typename?: 'thread'; + id: string; + response_variable_id?: string | null; + thread_data: ThreadDataRow[]; + thread_models: ThreadModelRow[]; +}; + +// ─── Query: GetThreadExecution ─────────────────────────────────────────────── + +export type GetThreadExecutionQueryVariables = { id: string }; + +export type GetThreadExecutionQuery = { + __typename?: 'query_root'; + thread_by_pk?: ThreadExecutionRow | null; +}; + +const DATASET_SPEC_IO = gql` + fragment thread_dataset_spec on modelcatalog_dataset_specification { + id + label + presentations { + dataset_specification_id + presentation_id + presentation { + id + standard_variable { + id + label + } + } + } + } +`; + +export const GetThreadExecutionDocument = gql` + ${DATASET_SPEC_IO} + query GetThreadExecution($id: String!) { + thread_by_pk(id: $id) { + id + response_variable_id + thread_data { + thread_id + dataslice { + id + name + start_date + end_date + resource_count + dataset { + id + name + } + resources { + dataslice_id + resource_id + selected + resource { + id + dcid + name + url + } + } + } + } + thread_models { + id + modelcatalog_configuration_id + execution_summary { + total_runs + submitted_runs + successful_runs + failed_runs + ingested_runs + registered_runs + published_runs + fetched_run_outputs + submission_time + submitted_for_execution + submitted_for_ingestion + submitted_for_publishing + submitted_for_registration + workflow_name + } + modelcatalog_configuration { + id + label + description + usage_notes + inputs { + configuration_id + input_id + is_optional + input { + ...thread_dataset_spec + } + } + outputs { + configuration_id + output_id + output { + ...thread_dataset_spec + } + } + parameters { + configuration_id + parameter_id + parameter { + id + label + description + has_data_type + has_default_value + has_fixed_value + has_minimum_accepted_value + has_maximum_accepted_value + has_accepted_values + position + } + } + } + data_bindings { + thread_model_id + model_io_id + dataslice_id + } + parameter_bindings { + thread_model_id + model_parameter_id + parameter_value + } + } + } + } +`; + +export function useGetThreadExecutionQuery( + baseOptions: Apollo.QueryHookOptions< + GetThreadExecutionQuery, + GetThreadExecutionQueryVariables + > & { variables: GetThreadExecutionQueryVariables }, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery( + GetThreadExecutionDocument, + options, + ); +} + +// ─── Mutation: UpdateThreadParameters ──────────────────────────────────────── + +export type ThreadModelParameterInsert = { + thread_model_id: string; + model_parameter_id: string; + parameter_value: string; +}; + +export type ThreadModelSummaryInsert = { + thread_model_id: string; + total_runs: number; + submitted_runs: number; + successful_runs: number; + failed_runs: number; +}; + +export type UpdateThreadParametersMutationVariables = { + threadId: string; + event: { + thread_id: string; + event: string; + userid: string; + notes?: string | null; + }; + summaries: ThreadModelSummaryInsert[]; + modelParams: ThreadModelParameterInsert[]; +}; + +export type UpdateThreadParametersMutation = { + insert_thread_model_parameter?: { returning: Array<{ model_parameter_id: string }> } | null; + insert_thread_model_execution_summary?: { + returning: Array<{ thread_model_id: string }>; + } | null; + insert_thread_provenance_one?: { thread_id: string } | null; +}; + +/** + * Replace a thread's parameter bindings and execution summaries in one document. + * + * The deletes are not optional: a parameter change invalidates every run that + * came before it, so the executions and their summaries go with it. Hasura runs + * a mutation's root fields in a single transaction, so the thread is never left + * with bindings from one save and summaries from another. + */ +export const UpdateThreadParametersDocument = gql` + mutation UpdateThreadParameters( + $threadId: String! + $event: thread_provenance_insert_input! + $summaries: [thread_model_execution_summary_insert_input!]! + $modelParams: [thread_model_parameter_insert_input!]! + ) { + delete_thread_model_execution_summary( + where: { thread_model: { thread_id: { _eq: $threadId } } } + ) { + affected_rows + } + delete_thread_model_parameter(where: { thread_model: { thread_id: { _eq: $threadId } } }) { + affected_rows + } + delete_thread_model_execution(where: { thread_model: { thread_id: { _eq: $threadId } } }) { + affected_rows + } + insert_thread_model_parameter(objects: $modelParams) { + returning { + model_parameter_id + } + } + insert_thread_model_execution_summary(objects: $summaries) { + returning { + thread_model_id + } + } + insert_thread_provenance_one(object: $event) { + thread_id + } + } +`; + +export function useUpdateThreadParametersMutation( + baseOptions?: Apollo.MutationHookOptions< + UpdateThreadParametersMutation, + UpdateThreadParametersMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + UpdateThreadParametersMutation, + UpdateThreadParametersMutationVariables + >(UpdateThreadParametersDocument, options); +} + +// ─── Query: GetThreadModelExecutions ───────────────────────────────────────── + +export type ExecutionRow = { + __typename?: 'execution'; + id: string; + status?: string | null; + run_progress: number; + run_id?: string | null; + start_time?: string | null; + end_time?: string | null; + execution_engine?: string | null; + modelcatalog_configuration_id?: string | null; + parameter_bindings: Array<{ + execution_id?: string; + model_parameter_id: string; + parameter_value: string; + }>; + data_bindings: Array<{ + execution_id?: string; + model_io_id: string; + resource: { id: string; name: string; url?: string | null }; + }>; + results: Array<{ + execution_id?: string; + model_io_id: string; + resource: { id: string; name: string; url?: string | null }; + }>; +}; + +export type GetThreadModelExecutionsQueryVariables = { + threadModelId: string; + offset: number; + limit: number; +}; + +export type GetThreadModelExecutionsQuery = { + __typename?: 'query_root'; + execution: ExecutionRow[]; +}; + +export const GetThreadModelExecutionsDocument = gql` + query GetThreadModelExecutions($threadModelId: uuid!, $offset: Int!, $limit: Int!) { + execution( + offset: $offset + limit: $limit + order_by: { start_time: desc } + where: { thread_model_executions: { thread_model_id: { _eq: $threadModelId } } } + ) { + id + status + run_progress + run_id + start_time + end_time + execution_engine + modelcatalog_configuration_id + parameter_bindings { + execution_id + model_parameter_id + parameter_value + } + data_bindings { + execution_id + model_io_id + resource { + id + name + url + } + } + results { + execution_id + model_io_id + resource { + id + name + url + } + } + } + } +`; diff --git a/ui-react/src/lib/__tests__/thread-datasets.test.ts b/ui-react/src/lib/__tests__/thread-datasets.test.ts new file mode 100644 index 0000000..c0a393e --- /dev/null +++ b/ui-react/src/lib/__tests__/thread-datasets.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import type { DataCatalogResource } from '@/lib/data-catalog'; +import { buildThreadDataInsert, hashResourceId, newDatasliceId } from '@/lib/thread-datasets'; + +const resources: DataCatalogResource[] = [ + { + id: 'ckan-res-1', + name: 'a.tif', + url: 'http://x/a.tif', + time_period: { start_date: new Date('2020-01-01'), end_date: new Date('2020-12-31') }, + selected: true, + }, + { id: 'ckan-res-2', name: 'b.tif', url: 'http://x/b.tif', selected: false }, +]; + +function build() { + return buildThreadDataInsert({ + threadId: 't1', + threadName: 'Flood extent', + regionId: 'texas', + startDate: '2020-01-01', + endDate: '2020-12-31', + datasliceId: 'slice-1', + dataset: { id: 'ckan-dem', name: 'National DEM' }, + resources, + }); +} + +describe('buildThreadDataInsert', () => { + it('keeps only the selected resources and counts those', () => { + const insert = build(); + const data = insert.dataslice.data; + expect(data.resources.data).toHaveLength(1); + expect(data.resources.data[0]?.resource.data.name).toBe('a.tif'); + // resource_count describes what is bound, not what the package holds. + expect(data.resource_count).toBe(1); + }); + + it('stores the catalog resource id as dcid and the URL hash as the key', () => { + const res = build().dataslice.data.resources.data[0]!.resource.data; + expect(res.dcid).toBe('ckan-res-1'); + expect(res.id).toBe(hashResourceId('http://x/a.tif')); + }); + + it('carries the thread window and region onto the dataslice', () => { + const data = build().dataslice.data; + expect(data).toMatchObject({ + id: 'slice-1', + region_id: 'texas', + start_date: '2020-01-01', + end_date: '2020-12-31', + }); + expect(data.name).toContain('National DEM'); + expect(data.dataset.data).toEqual({ id: 'ckan-dem', name: 'National DEM' }); + }); + + it('narrows a resource date to a plain date, which is what the column takes', () => { + expect(build().dataslice.data.resources.data[0]?.resource.data.start_date).toBe('2020-01-01'); + }); + + it('leaves a resource with no coverage null rather than inventing one', () => { + const insert = buildThreadDataInsert({ + threadId: 't1', + datasliceId: 'slice-2', + dataset: { id: 'd', name: 'D' }, + resources: [{ id: 'r', name: 'c.tif', url: 'http://x/c.tif' }], + }); + const res = insert.dataslice.data.resources.data[0]!.resource.data; + expect(res.start_date).toBeNull(); + expect(res.end_date).toBeNull(); + }); +}); + +describe('hashResourceId', () => { + it('is stable for the same URL, so a re-save upserts instead of duplicating', () => { + expect(hashResourceId('http://x/a.tif')).toBe(hashResourceId('http://x/a.tif')); + expect(hashResourceId('http://x/a.tif')).not.toBe(hashResourceId('http://x/b.tif')); + }); + + it('agrees with the digest Lit already stored for this file at TACC', () => { + // Read live from dataslice a342f730-30c1-47e4-8b4d-0226bfea1a30. A different + // hash would make this app insert a second resource row for the same file. + expect( + hashResourceId( + 'https://ckan.tacc.utexas.edu/dataset/ca9b6a10-402f-4a58-b50c-9bf324162958/resource/4de67e7f-9585-4191-9fbd-79019f8d3388/download/heatseekervideo.mp4', + ), + ).toBe('e9a1d75696d0158aac85a8bf7f15d2c2'); + }); +}); + +describe('newDatasliceId', () => { + it('produces a v4-shaped uuid, which the uuid column requires', () => { + expect(newDatasliceId()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); +}); diff --git a/ui-react/src/lib/__tests__/thread-execution.test.ts b/ui-react/src/lib/__tests__/thread-execution.test.ts new file mode 100644 index 0000000..8dfc570 --- /dev/null +++ b/ui-react/src/lib/__tests__/thread-execution.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from 'vitest'; + +import type { ThreadExecutionData } from '@/graphql/generated/execution'; +import type { + ExecutionRow, + ThreadExecutionRow, + ThreadModelRow, +} from '@/graphql/generated/thread-execution'; +import { + bindingsFromGQL, + datasetsComplete, + executionFromGQL, + hasUnfinishedRuns, + parametersComplete, + runsComplete, + threadExecutionFromGQL, + totalConfigs, +} from '@/lib/thread-execution'; + +function spec(id: string, label: string, variable?: string) { + return { + id, + label, + presentations: variable + ? [ + { + presentation: { + id: `${id}-pres`, + standard_variable: { id: `sv-${variable}`, label: variable }, + }, + }, + ] + : [], + }; +} + +function threadModel(overrides: Partial = {}): ThreadModelRow { + return { + id: 'tm-1', + modelcatalog_configuration_id: 'cfgA', + execution_summary: [], + modelcatalog_configuration: { + id: 'cfgA', + label: 'HAND setup', + inputs: [ + { is_optional: false, input: spec('inA', 'DEM', 'land_surface__elevation') }, + { is_optional: true, input: spec('inB', 'Mask') }, + ], + outputs: [{ output: spec('outA', 'Depth', 'flood__depth') }], + parameters: [ + { + parameter: { + id: 'pFixed', + label: 'resolution', + has_fixed_value: '30', + position: 1, + }, + }, + { + parameter: { + id: 'pAdj', + label: 'threshold', + has_data_type: 'float', + has_default_value: '0.5', + has_minimum_accepted_value: '0', + has_maximum_accepted_value: '1', + position: 2, + }, + }, + ], + }, + data_bindings: [{ model_io_id: 'inA', dataslice_id: 'slice-1' }], + parameter_bindings: [{ model_parameter_id: 'pAdj', parameter_value: '0.7' }], + ...overrides, + }; +} + +function threadRow(overrides: Partial = {}): ThreadExecutionRow { + return { + id: 't1', + response_variable_id: 'var-flood', + thread_data: [ + { + dataslice: { + id: 'slice-1', + name: 'DEM for thread', + start_date: '2020-01-01', + end_date: '2020-12-31', + resource_count: 2, + dataset: { id: 'ckan-dem', name: 'National DEM' }, + resources: [ + { + selected: true, + resource: { id: 'hash1', dcid: 'ckan-res-1', name: 'a.tif', url: 'http://x/a.tif' }, + }, + { + selected: false, + resource: { id: 'hash2', dcid: 'ckan-res-2', name: 'b.tif', url: 'http://x/b.tif' }, + }, + ], + }, + }, + ], + thread_models: [threadModel()], + ...overrides, + }; +} + +describe('threadExecutionFromGQL', () => { + it('returns null when the thread is absent, so "not loaded" is distinguishable', () => { + expect(threadExecutionFromGQL(null)).toBeNull(); + expect(threadExecutionFromGQL(undefined)).toBeNull(); + }); + + it('keys models, ensembles and summaries by the configuration id', () => { + const out = threadExecutionFromGQL( + threadRow({ + thread_models: [ + threadModel({ + execution_summary: [ + { + total_runs: 4, + submitted_runs: 4, + successful_runs: 3, + failed_runs: 1, + ingested_runs: 0, + registered_runs: 0, + published_runs: 0, + fetched_run_outputs: 0, + submission_time: '2026-08-09T00:00:00', + submitted_for_execution: true, + submitted_for_ingestion: false, + submitted_for_publishing: false, + submitted_for_registration: false, + }, + ], + }), + ], + }), + )!; + expect(Object.keys(out.models)).toEqual(['cfgA']); + // The ensemble id is the thread_model row, not the configuration: it is the + // FK thread_model_io and thread_model_parameter are written against. + expect(out.model_ensembles['cfgA']?.id).toBe('tm-1'); + expect(out.execution_summary['cfgA']?.total_runs).toBe(4); + expect(out.response_variables).toEqual(['var-flood']); + }); + + it('splits fixed from adjustable parameters and sorts by position', () => { + const params = threadExecutionFromGQL(threadRow())!.models['cfgA']!.input_parameters; + expect(params.map((p) => p.id)).toEqual(['pFixed', 'pAdj']); + expect(params[0]!.value).toBe('30'); + expect(params[1]!.value).toBeNull(); + expect(params[1]!.min).toBe('0'); + expect(params[1]!.max).toBe('1'); + }); + + it('carries input variable labels and the optional flag', () => { + const inputs = threadExecutionFromGQL(threadRow())!.models['cfgA']!.input_files; + expect(inputs[0]).toMatchObject({ + id: 'inA', + variables: ['land_surface__elevation'], + isOptional: false, + }); + expect(inputs[1]!.isOptional).toBe(true); + }); + + it('counts selected resources without an aggregate field', () => { + const slice = threadExecutionFromGQL(threadRow())!.data['slice-1']!; + expect(slice['selected_resources']).toBe(1); + expect(slice['total_resources']).toBe(2); + // The catalog id, not the URL-hash primary key. + expect((slice['resources'] as Array<{ id: string }>)[0]!.id).toBe('ckan-res-1'); + }); + + it('skips a thread model whose configuration no longer resolves', () => { + const out = threadExecutionFromGQL( + threadRow({ thread_models: [threadModel({ modelcatalog_configuration: null })] }), + )!; + expect(out.models).toEqual({}); + }); +}); + +describe('bindingsFromGQL', () => { + it('merges data and parameter bindings into one map', () => { + expect(bindingsFromGQL(threadModel())).toEqual({ inA: ['slice-1'], pAdj: ['0.7'] }); + }); + + it('collects every value of a multi-value parameter sweep', () => { + const bindings = bindingsFromGQL( + threadModel({ + parameter_bindings: [ + { model_parameter_id: 'pAdj', parameter_value: '0.1' }, + { model_parameter_id: 'pAdj', parameter_value: '0.2' }, + ], + }), + ); + expect(bindings['pAdj']).toEqual(['0.1', '0.2']); + }); +}); + +// ─── Step completion ───────────────────────────────────────────────────────── + +function execData(overrides: Partial = {}): ThreadExecutionData { + return { + id: 't1', + models: { + cfgA: { + id: 'cfgA', + name: 'HAND', + input_files: [ + { id: 'inA', name: 'DEM', isOptional: false }, + { id: 'inB', name: 'Mask', isOptional: true }, + ], + output_files: [], + input_parameters: [ + { id: 'pFixed', name: 'resolution', value: '30' }, + { id: 'pAdj', name: 'threshold' }, + ], + }, + }, + model_ensembles: { cfgA: { id: 'tm-1', bindings: {} } }, + execution_summary: {}, + data: {}, + ...overrides, + }; +} + +describe('datasetsComplete', () => { + it('is false before anything is bound', () => { + expect(datasetsComplete(execData())).toBe(false); + }); + + it('ignores an unbound optional input', () => { + expect( + datasetsComplete( + execData({ model_ensembles: { cfgA: { id: 'tm-1', bindings: { inA: ['s1'] } } } }), + ), + ).toBe(true); + }); + + it('is false with no models, not vacuously true', () => { + expect(datasetsComplete(execData({ models: {} }))).toBe(false); + expect(datasetsComplete(null)).toBe(false); + }); +}); + +describe('parametersComplete', () => { + const bound = { cfgA: { id: 'tm-1', bindings: { pAdj: ['0.7'] } } }; + + it('needs the execution summary as well as the bindings', () => { + expect(parametersComplete(execData({ model_ensembles: bound }))).toBe(false); + expect( + parametersComplete( + execData({ + model_ensembles: bound, + execution_summary: { + cfgA: { total_runs: 1, submitted_runs: 0, failed_runs: 0, successful_runs: 0 }, + }, + }), + ), + ).toBe(true); + }); + + it('is false for a model with no adjustable parameters until the step has saved', () => { + const noParams = execData({ + models: { + cfgA: { + id: 'cfgA', + name: 'HAND', + input_files: [], + output_files: [], + input_parameters: [], + }, + }, + }); + expect(parametersComplete(noParams)).toBe(false); + }); +}); + +describe('runsComplete / hasUnfinishedRuns', () => { + const running = { + cfgA: { + total_runs: 4, + submitted_runs: 4, + successful_runs: 1, + failed_runs: 0, + submitted_for_execution: true, + }, + }; + const finished = { + cfgA: { + total_runs: 4, + submitted_runs: 4, + successful_runs: 3, + failed_runs: 1, + submitted_for_execution: true, + }, + }; + + it('is complete only once every run has landed', () => { + expect(runsComplete(execData({ execution_summary: running }))).toBe(false); + expect(runsComplete(execData({ execution_summary: finished }))).toBe(true); + }); + + it('polls while runs are outstanding and stops when they are not', () => { + expect(hasUnfinishedRuns(running)).toBe(true); + expect(hasUnfinishedRuns(finished)).toBe(false); + // Nothing submitted yet is not "in flight" — it is waiting on the user. + expect(hasUnfinishedRuns({ cfgA: { ...running.cfgA, submitted_for_execution: false } })).toBe( + false, + ); + }); +}); + +describe('executionFromGQL', () => { + const row: ExecutionRow = { + id: 'ex-1', + status: 'SUCCESS', + run_progress: 100, + start_time: '2026-08-09T10:00:00', + end_time: '2026-08-09T10:05:00', + execution_engine: 'tapis', + modelcatalog_configuration_id: 'cfgA', + parameter_bindings: [{ model_parameter_id: 'pAdj', parameter_value: '0.7' }], + data_bindings: [ + { model_io_id: 'inA', resource: { id: 'r1', name: 'dem.tif', url: 'http://x/dem.tif' } }, + ], + results: [ + { model_io_id: 'outA', resource: { id: 'r2', name: 'depth.tif', url: 'http://x/d.tif' } }, + ], + }; + + it('flattens bindings and results into the maps the run table renders', () => { + const ex = executionFromGQL(row); + expect(ex.modelid).toBe('cfgA'); + expect(ex.bindings['pAdj']).toBe('0.7'); + expect((ex.bindings['inA'] as { name: string }).name).toBe('dem.tif'); + expect(ex.results['outA']?.url).toBe('http://x/d.tif'); + }); + + it('falls back to WAITING when the engine has not set a status', () => { + expect(executionFromGQL({ ...row, status: null }).status).toBe('WAITING'); + }); +}); + +// totalConfigs lives with the Parameters step but is the number the Runs step +// judges completion against, so it is tested alongside the other predicates. +describe('totalConfigs', () => { + const model = { + id: 'cfgA', + name: 'HAND', + input_files: [{ id: 'inA', name: 'DEM' }], + output_files: [], + input_parameters: [ + { id: 'pFixed', name: 'resolution', value: '30' }, + { id: 'pAdj', name: 'threshold' }, + ], + }; + const data = { 'slice-1': { id: 'slice-1', selected_resources: 5 } }; + + it('multiplies input resources by parameter values', () => { + expect(totalConfigs(model, { inA: ['slice-1'], pAdj: ['0.1', '0.2', '0.3'] }, data)).toBe(15); + }); + + it('ignores fixed parameters, which contribute no sweep', () => { + expect(totalConfigs(model, { inA: ['slice-1'], pAdj: ['0.1'] }, data)).toBe(5); + }); + + it('treats an unbound input as no constraint rather than zero runs', () => { + expect(totalConfigs(model, { pAdj: ['0.1'] }, {})).toBe(1); + }); +}); diff --git a/ui-react/src/lib/thread-datasets.ts b/ui-react/src/lib/thread-datasets.ts new file mode 100644 index 0000000..9897d9e --- /dev/null +++ b/ui-react/src/lib/thread-datasets.ts @@ -0,0 +1,101 @@ +/** + * Builders for the nested `thread_data` insert the UpdateThreadData mutation + * takes when a dataset is bound to a model input. + * + * Extracted so the wizard's Datasets step and the standalone MintDatasets + * component write the same rows. The shape is dictated by + * ui/src/queries/thread/update-datasets.graphql and its Lit adapter. + */ +import { Md5 } from 'ts-md5'; + +import type { UpdateThreadDataMutationVariables } from '@/graphql/generated/modeling'; +import type { DataCatalogDataset, DataCatalogResource } from '@/lib/data-catalog'; + +export type ThreadDataInsert = UpdateThreadDataMutationVariables['data'][number]; + +/** Deterministic UUID v4 substitute — mirrors legacy uuidv4(). */ +export function newDatasliceId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +/** + * Stable id for a resource: the MD5 hex digest of its URL. + * + * The URL is the only value CKAN guarantees is the same file across calls, and + * `resource.id` is a text PK, so the same file must hash to the same key or the + * insert duplicates it. **The algorithm has to be MD5, not merely stable** — + * Lit writes `getMd5Hash(url)` (ui/src/util/graphql_adapter.ts) and TACC's rows + * carry those digests, so any other hash makes this app store a second row for + * a file the deployment already has. Verified against a live TACC dataslice. + */ +export function hashResourceId(url: string): string { + return Md5.hashStr(url); +} + +function isoDate(d: Date | null | undefined): string | null { + if (!d) return null; + return d.toISOString().split('T')[0] ?? null; +} + +/** + * One `thread_data` row: a dataslice of the chosen dataset, narrowed to the + * resources the caller kept, with the dataset and resources upserted alongside. + */ +export function buildThreadDataInsert(params: { + threadId: string; + threadName?: string | null; + regionId?: string | null; + startDate?: string | null; + endDate?: string | null; + datasliceId: string; + /** Only the id and name are stored; a full catalog dataset satisfies this. */ + dataset: Pick; + resources: DataCatalogResource[]; +}): ThreadDataInsert { + const { dataset, resources } = params; + const kept = resources.filter((r) => r.selected !== false); + return { + thread_id: params.threadId, + dataslice: { + data: { + id: params.datasliceId, + name: `${dataset.name} for thread: ${params.threadName ?? ''}`, + region_id: params.regionId ?? '', + start_date: params.startDate ?? null, + end_date: params.endDate ?? null, + // The count of what is actually bound, not what the package holds: a + // dataset matches an input because *some* of its resources carry the + // variable, and only those are kept. + resource_count: kept.length, + dataset: { + data: { id: dataset.id, name: dataset.name }, + on_conflict: { constraint: 'dataset_pkey', update_columns: ['name'] }, + }, + resources: { + data: kept.map((r) => ({ + resource: { + data: { + id: hashResourceId(r.url), + dcid: r.id, + name: r.name, + url: r.url, + start_date: isoDate(r.time_period?.start_date), + end_date: isoDate(r.time_period?.end_date), + }, + on_conflict: { constraint: 'resource_pkey', update_columns: ['name'] }, + }, + selected: true, + })), + on_conflict: { + constraint: 'dataslice_resource_pkey', + update_columns: ['dataslice_id'], + }, + }, + }, + on_conflict: { constraint: 'dataslice_pkey', update_columns: ['id'] }, + }, + }; +} diff --git a/ui-react/src/lib/thread-execution.ts b/ui-react/src/lib/thread-execution.ts new file mode 100644 index 0000000..428b04e --- /dev/null +++ b/ui-react/src/lib/thread-execution.ts @@ -0,0 +1,294 @@ +/** + * Adapters between the thread execution GraphQL rows and the in-memory + * ThreadExecutionData the Datasets, Parameters, Runs and Results steps consume. + * + * Mirrors the legacy Lit adapters in ui/src/util/graphql_adapter.ts + * (threadFromGQL, modelFromGQL, modelEnsembleFromGQL, + * threadModelExecutionSummaryFromGQL, executionFromGQL). Pure functions only — + * no Apollo, no React — so the mapping is testable on its own. + */ +import { extractModelIO } from '@/graphql/generated/modeling'; +import type { + Execution, + ExecutionResult, + ExecutionSummary, + IOBindings, + ModelParameter, + ThreadExecutionData, + ThreadModel, +} from '@/graphql/generated/execution'; +import type { + ExecutionRow, + ExecutionSummaryRow, + ParameterRow, + ThreadExecutionRow, + ThreadModelRow, +} from '@/graphql/generated/thread-execution'; + +/** A model-catalog parameter row as the adjustable/fixed parameter the UI edits. */ +export function parameterFromGQL(p: ParameterRow): ModelParameter { + return { + id: p.id, + name: p.label ?? p.id, + description: p.description ?? null, + type: p.has_data_type ?? null, + min: p.has_minimum_accepted_value ?? null, + max: p.has_maximum_accepted_value ?? null, + default: p.has_default_value ?? null, + accepted_values: p.has_accepted_values ?? null, + position: p.position ?? null, + // `value` is what separates an expert-fixed parameter from an adjustable + // one everywhere downstream — MintParameters, MintRuns and the step rail + // all branch on `!p.value`. + value: p.has_fixed_value ?? null, + }; +} + +/** Counters for one model, from its (at most one) execution-summary row. */ +export function summaryFromGQL(row: ExecutionSummaryRow): ExecutionSummary { + return { + total_runs: row.total_runs, + submitted_runs: row.submitted_runs, + successful_runs: row.successful_runs, + failed_runs: row.failed_runs, + ingested_runs: row.ingested_runs, + published_runs: row.published_runs, + fetched_run_outputs: row.fetched_run_outputs, + submission_time: row.submission_time ?? null, + submitted_for_execution: row.submitted_for_execution, + submitted_for_ingestion: row.submitted_for_ingestion, + submitted_for_publishing: row.submitted_for_publishing, + }; +} + +/** + * Merge a thread model's data and parameter bindings into one map. + * + * Both live in the same `bindings` record, keyed by the model input id or the + * model parameter id — that is what MintParameters and MintRuns read. Values + * are dataslice ids for inputs and literal strings for parameters. + */ +export function bindingsFromGQL(tm: ThreadModelRow): IOBindings { + const bindings: IOBindings = {}; + for (const db of tm.data_bindings ?? []) { + (bindings[db.model_io_id] ??= []).push(db.dataslice_id); + } + for (const pb of tm.parameter_bindings ?? []) { + (bindings[pb.model_parameter_id] ??= []).push(pb.parameter_value); + } + return bindings; +} + +/** The configuration a thread model points at, as the model the steps render. */ +export function threadModelFromGQL(tm: ThreadModelRow): ThreadModel | null { + const cfg = tm.modelcatalog_configuration; + if (!cfg) return null; + const io = extractModelIO(cfg); + return { + id: cfg.id, + name: cfg.label ?? cfg.id, + usage_notes: cfg.usage_notes ?? null, + // The data catalog filters by standard-variable NAME, so inputs carry the + // labels. Outputs carry the ids, because the response variable they are + // matched against is an id. Same split as the model-tree path. + input_files: io.inputs.map((i) => ({ + id: i.id, + name: i.name, + variables: i.variableLabels, + isOptional: i.optional, + })), + output_files: io.outputs.map((o) => ({ + id: o.id, + name: o.name, + variables: o.variableIds, + })), + input_parameters: (cfg.parameters ?? []) + .map((cp) => parameterFromGQL(cp.parameter)) + .sort((a, b) => { + if (a.position != null && b.position != null) return a.position - b.position; + return (a.name ?? '').localeCompare(b.name ?? ''); + }), + }; +} + +/** + * Build the execution-pipeline view of a thread from the GetThreadExecution row. + * + * Returns null when the thread is absent so the caller can tell "not loaded + * yet" from "loaded and empty" — the distinction the wizard rail depends on. + */ +export function threadExecutionFromGQL( + thread: ThreadExecutionRow | null | undefined, +): ThreadExecutionData | null { + if (!thread) return null; + + const data: ThreadExecutionData['data'] = {}; + for (const td of thread.thread_data ?? []) { + const ds = td.dataslice; + if (!ds) continue; + const resources = ds.resources ?? []; + data[ds.id] = { + id: ds.id, + name: ds.name, + dataset: ds.dataset, + start_date: ds.start_date ?? null, + end_date: ds.end_date ?? null, + resource_count: ds.resource_count, + total_resources: resources.length, + // Counted here rather than with a `*_aggregate` field: aggregates are not + // exposed to Hasura's anonymous role, and one absent field fails the + // whole document. + selected_resources: resources.filter((r) => r.selected).length, + // `dcid` is the data catalog's own resource id; `id` is the URL hash this + // app stores as the PK. Handing back the catalog id keeps a re-save from + // overwriting `dcid` with the hash. + resources: resources.map((r) => ({ + id: r.resource.dcid ?? r.resource.id, + name: r.resource.name, + url: r.resource.url, + selected: r.selected, + })), + }; + } + + const models: ThreadExecutionData['models'] = {}; + const model_ensembles: ThreadExecutionData['model_ensembles'] = {}; + const execution_summary: ThreadExecutionData['execution_summary'] = {}; + + for (const tm of thread.thread_models ?? []) { + const model = threadModelFromGQL(tm); + if (!model) continue; + models[model.id] = model; + model_ensembles[model.id] = { id: tm.id, bindings: bindingsFromGQL(tm) }; + const summaryRow = (tm.execution_summary ?? [])[0]; + if (summaryRow) execution_summary[model.id] = summaryFromGQL(summaryRow); + } + + return { + id: thread.id, + models, + model_ensembles, + execution_summary, + data, + response_variables: thread.response_variable_id ? [thread.response_variable_id] : [], + }; +} + +/** One execution row as the run the Runs and Results tables render. */ +export function executionFromGQL(ex: ExecutionRow): Execution { + const bindings: Record = {}; + for (const pb of ex.parameter_bindings ?? []) { + bindings[pb.model_parameter_id] = pb.parameter_value; + } + for (const db of ex.data_bindings ?? []) { + bindings[db.model_io_id] = db.resource; + } + const results: Record = {}; + for (const r of ex.results ?? []) { + results[r.model_io_id] = r.resource; + } + return { + id: ex.id, + modelid: ex.modelcatalog_configuration_id ?? '', + status: ex.status ?? 'WAITING', + run_progress: ex.run_progress, + start_time: ex.start_time ?? null, + end_time: ex.end_time ?? null, + execution_engine: ex.execution_engine ?? null, + bindings, + results, + }; +} + +/** + * How many runs this model's ensemble expands to. + * + * A run is one combination of input resources and parameter values, so both + * sides multiply: a dataslice of 5 files crossed with 3 threshold values is 15 + * runs, not 3. Counting parameters alone understated the total, and the Runs + * step compares finished runs against it to decide the step is done. + * Mirrors getTotalConfigs in ui/src/util/graphql_adapter.ts. + */ +export function totalConfigs( + model: ThreadModel, + bindings: Record, + data: ThreadExecutionData['data'], +): number { + let total = 1; + for (const io of model.input_files) { + if (io.value) { + total *= (io.value.resources ?? []).filter((r) => r.selected !== false).length || 1; + continue; + } + const slices = bindings[io.id] ?? []; + if (slices.length === 0) continue; + const resources = slices.reduce( + (acc, sliceId) => acc + (data?.[sliceId]?.selected_resources ?? 0), + 0, + ); + total *= resources || 1; + } + for (const p of model.input_parameters.filter((x) => !x.value)) { + total *= (bindings[p.id ?? ''] ?? []).length || 1; + } + return total; +} + +// ─── Step completion ───────────────────────────────────────────────────────── +// +// One definition per step, shared by the wizard rail and the step component, so +// the rail can never disagree with the panel it links to. Each answers the same +// question: has this step's write landed in the database? + +/** Every required input of every selected model has at least one dataslice bound. */ +export function datasetsComplete(threadData: ThreadExecutionData | null): boolean { + if (!threadData) return false; + const modelIds = Object.keys(threadData.models ?? {}); + if (modelIds.length === 0) return false; + return modelIds.every((mid) => { + const bindings = threadData.model_ensembles[mid]?.bindings ?? {}; + return threadData.models[mid]!.input_files.filter((f) => !f.isOptional).every( + (f) => (bindings[f.id] ?? []).length > 0, + ); + }); +} + +/** + * Every adjustable parameter is bound and every model has an execution summary. + * + * The summary is what makes this a completion test rather than a vacuous one: a + * model with no adjustable parameters satisfies the binding half by definition, + * and without the summary row the Runs step has nothing to submit. + */ +export function parametersComplete(threadData: ThreadExecutionData | null): boolean { + if (!threadData) return false; + const modelIds = Object.keys(threadData.models ?? {}); + if (modelIds.length === 0) return false; + return modelIds.every((mid) => { + if (!threadData.execution_summary[mid]) return false; + const bindings = threadData.model_ensembles[mid]?.bindings ?? {}; + return threadData.models[mid]!.input_parameters.filter((p) => !p.value).every( + (p) => (bindings[p.id ?? ''] ?? []).length > 0, + ); + }); +} + +/** Every model's runs have been submitted and have all finished. */ +export function runsComplete(threadData: ThreadExecutionData | null): boolean { + if (!threadData) return false; + const modelIds = Object.keys(threadData.execution_summary ?? {}); + if (modelIds.length === 0) return false; + return modelIds.every((mid) => { + const s = threadData.execution_summary[mid]!; + return ( + s.submitted_runs > 0 && s.successful_runs + s.failed_runs >= s.total_runs && s.total_runs > 0 + ); + }); +} + +/** True while any model still has runs the execution engine has not finished. */ +export function hasUnfinishedRuns(summary: ThreadExecutionData['execution_summary']): boolean { + return Object.values(summary ?? {}).some( + (s) => s.submitted_for_execution && s.successful_runs + s.failed_runs < s.total_runs, + ); +} diff --git a/ui-react/src/pages/modeling/MintThread.tsx b/ui-react/src/pages/modeling/MintThread.tsx index 64fa7a1..e4e6aa2 100644 --- a/ui-react/src/pages/modeling/MintThread.tsx +++ b/ui-react/src/pages/modeling/MintThread.tsx @@ -5,24 +5,42 @@ * Parameters → Runs → Results → Summary) and renders the appropriate atomic * step component based on the active section. * - * This component loads thread data via Apollo and handles step transitions. + * Two queries feed it. GetThread holds the thread's metadata and permissions; + * GetThreadExecution holds the execution pipeline — the selected models with + * their catalog I/O, the data and parameter bindings, and the run summaries. + * Everything downstream of the Models step reads the second one, so a step is + * only ever as complete as what the database actually holds. */ import { Maximize2, Minimize2 } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useApolloClient } from '@apollo/client'; import { useParams } from 'react-router-dom'; import { Skeleton } from '@/components/ui/skeleton'; -import { - getUserPermission, - useGetThreadQuery, - useGetModelTreeWithRegionsQuery, -} from '@/graphql/generated/modeling'; +import { getUserPermission, useGetThreadQuery } from '@/graphql/generated/modeling'; import { ExecutionSummaryMap, ModelEnsembleMap, ModelExecutionsMap, ThreadExecutionData, } from '@/graphql/generated/execution'; +import { + GetThreadModelExecutionsDocument, + useGetThreadExecutionQuery, + useUpdateThreadParametersMutation, + type GetThreadModelExecutionsQuery, + type GetThreadModelExecutionsQueryVariables, + type ThreadModelParameterInsert, + type ThreadModelSummaryInsert, +} from '@/graphql/generated/thread-execution'; +import { + datasetsComplete, + executionFromGQL, + hasUnfinishedRuns, + parametersComplete, + runsComplete, + threadExecutionFromGQL, +} from '@/lib/thread-execution'; import { submitRuns } from '@/lib/ensemble-manager'; import { useAuth } from '@/lib/auth/useAuth'; import { cn } from '@/lib/utils'; @@ -38,42 +56,13 @@ import { FramingStep } from './thread/wizard/FramingStep'; import { VariablesStep } from './thread/wizard/VariablesStep'; import { ModelsStep } from './thread/wizard/ModelsStep'; import { DatasetsStep } from './thread/wizard/DatasetsStep'; -import { buildThreadModels } from './thread/wizard/buildThreadModels'; // ─── Step order (module scope so nav helpers have a stable reference) ─────────── const stepOrder = WIZARD_STEPS.map((s) => s.id); -// ─── Status helpers ──────────────────────────────────────────────────────────── - -type StepStatus = 'not_started' | 'in_progress' | 'done'; - -function getParametersStatus(threadData: ThreadExecutionData | null): StepStatus { - if (!threadData) return 'not_started'; - const modelIds = Object.keys(threadData.models ?? {}); - if (modelIds.length === 0) return 'not_started'; - const allBound = modelIds.every((mid) => { - const model = threadData.models[mid]!; - const bindings = threadData.model_ensembles[mid]?.bindings ?? {}; - return model.input_parameters - .filter((p) => !p.value) - .every((p) => (bindings[p.id ?? ''] ?? []).length > 0); - }); - return allBound ? 'done' : 'not_started'; -} - -function getRunsStatus(threadData: ThreadExecutionData | null): StepStatus { - if (!threadData) return 'not_started'; - const modelIds = Object.keys(threadData.execution_summary ?? {}); - if (modelIds.length === 0) return 'not_started'; - const allDone = modelIds.every((mid) => { - const s = threadData.execution_summary[mid]!; - return ( - s.submitted_runs > 0 && s.successful_runs + s.failed_runs >= s.total_runs && s.total_runs > 0 - ); - }); - return allDone ? 'done' : 'not_started'; -} +/** How often to re-read the execution summary while runs are still in flight. */ +const RUN_POLL_MS = 10_000; // ─── MintThread ──────────────────────────────────────────────────────────────── @@ -90,6 +79,7 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { const { id: routeThreadId } = useParams<{ id: string }>(); const threadId = threadIdProp ?? routeThreadId; const { user } = useAuth(); + const apollo = useApolloClient(); const [maximized, setMaximized] = useState(false); const [currentSection, setCurrentSection] = useState('framing'); @@ -102,10 +92,6 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { setCurrentSection((cur) => stepOrder[Math.max(stepOrder.indexOf(cur) - 1, 0)]!); }, []); - // ── Execution state (local for this 1:1 port) ──────────────────────────── - // In the legacy app this state lives in Redux. Here we keep it local so the - // component can function without a Hasura subscription for execution tables. - const [threadExecutionData, setThreadExecutionData] = useState(null); const [modelExecutions, setModelExecutions] = useState({}); const { data, loading, error, refetch } = useGetThreadQuery({ @@ -116,35 +102,119 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { const thread = data?.thread_by_pk ?? null; - const { data: modelTree } = useGetModelTreeWithRegionsQuery(); + const { + data: execRaw, + refetch: refetchExecution, + startPolling, + stopPolling, + } = useGetThreadExecutionQuery({ + variables: { id: threadId! }, + skip: !threadId, + fetchPolicy: 'cache-and-network', + }); + + const threadExecutionData = useMemo( + () => threadExecutionFromGQL(execRaw?.thread_by_pk), + [execRaw], + ); + + // The execution engine writes the run counters; nothing pushes them back, so + // poll while a submitted run is still unfinished and stop as soon as it is. + const runsInFlight = hasUnfinishedRuns(threadExecutionData?.execution_summary ?? {}); + useEffect(() => { + if (runsInFlight) startPolling(RUN_POLL_MS); + else stopPolling(); + return () => stopPolling(); + }, [runsInFlight, startPolling, stopPolling]); + + const handleThreadUpdated = useCallback(async () => { + await Promise.all([refetch(), refetchExecution()]); + }, [refetch, refetchExecution]); - const handleThreadUpdated = useCallback(() => { - void refetch(); - }, [refetch]); + const [updateThreadParameters] = useUpdateThreadParametersMutation(); // ── Execution handlers ────────────────────────────────────────────────── const handleSaveParameters = useCallback( - async (ensembles: ModelEnsembleMap, summary: ExecutionSummaryMap, _notes: string) => { - setThreadExecutionData((prev) => - prev ? { ...prev, model_ensembles: ensembles, execution_summary: summary } : prev, - ); - // In production, also persist to Hasura via mutation + async (ensembles: ModelEnsembleMap, summary: ExecutionSummaryMap, notes: string) => { + if (!threadId || !threadExecutionData) return; + const modelParams: ThreadModelParameterInsert[] = []; + const summaries: ThreadModelSummaryInsert[] = []; + + for (const [modelId, ensemble] of Object.entries(ensembles)) { + const model = threadExecutionData.models[modelId]; + // `bindings` holds data and parameter bindings side by side; only the + // adjustable parameters belong in thread_model_parameter. + if (!model || !ensemble.id) continue; + for (const param of model.input_parameters.filter((p) => !p.value)) { + for (const value of ensemble.bindings[param.id] ?? []) { + modelParams.push({ + thread_model_id: ensemble.id, + model_parameter_id: param.id, + parameter_value: value, + }); + } + } + const counters = summary[modelId]; + summaries.push({ + thread_model_id: ensemble.id, + total_runs: counters?.total_runs ?? 0, + submitted_runs: 0, + successful_runs: 0, + failed_runs: 0, + }); + } + + await updateThreadParameters({ + variables: { + threadId, + event: { + thread_id: threadId, + event: 'SELECT_PARAMETERS', + userid: user?.username ?? 'anonymous', + notes: notes || null, + }, + summaries, + modelParams, + }, + }); + await handleThreadUpdated(); }, - [], + [threadId, threadExecutionData, updateThreadParameters, user, handleThreadUpdated], ); - const handleFetchRuns = useCallback((modelId: string, page: number, pageSize: number) => { - // In a full port this dispatches a Hasura query / Apollo query with pagination. - // Placeholder: mark as loading - void modelId; - void page; - void pageSize; - setModelExecutions((prev) => ({ - ...prev, - [modelId]: prev[modelId] ?? { executions: [], loading: false }, - })); - }, []); + const handleFetchRuns = useCallback( + (modelId: string, page: number, pageSize: number) => { + const threadModelId = threadExecutionData?.model_ensembles[modelId]?.id; + if (!threadModelId) return; + setModelExecutions((prev) => ({ + ...prev, + [modelId]: { executions: prev[modelId]?.executions ?? [], loading: true }, + })); + apollo + .query({ + query: GetThreadModelExecutionsDocument, + variables: { threadModelId, offset: (page - 1) * pageSize, limit: pageSize }, + fetchPolicy: 'network-only', + }) + .then((res) => { + setModelExecutions((prev) => ({ + ...prev, + [modelId]: { + executions: (res.data?.execution ?? []).map(executionFromGQL), + loading: false, + }, + })); + }) + .catch(() => { + setModelExecutions((prev) => ({ + ...prev, + [modelId]: { executions: prev[modelId]?.executions ?? [], loading: false }, + })); + }); + }, + [apollo, threadExecutionData], + ); const handleSubmitRuns = useCallback( async (modelId: string) => { @@ -159,29 +229,11 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { thread_id: threadId, model_id: modelId, }); - // Mark submitted - setThreadExecutionData((prev) => - prev - ? { - ...prev, - execution_summary: { - ...prev.execution_summary, - [modelId]: { - ...(prev.execution_summary[modelId] ?? { - total_runs: 0, - submitted_runs: 0, - failed_runs: 0, - successful_runs: 0, - }), - submitted_for_execution: true, - submission_time: new Date().toISOString(), - }, - }, - } - : prev, - ); + // The engine writes the counters itself; read them back rather than + // guessing at them locally. + await refetchExecution(); }, - [threadId], + [threadId, refetchExecution], ); // ── render ───────────────────────────────────────────────────────────────── @@ -211,7 +263,8 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { const perm = getUserPermission(thread.permissions, thread.events, user?.username ?? null); - // Derive a minimal threadExecutionData for parameter/run/result steps + // Until the execution query resolves, the pipeline is empty rather than + // wrong: the steps that read it show their "nothing selected yet" state. const execData: ThreadExecutionData = threadExecutionData ?? { id: thread.id, models: {}, @@ -221,25 +274,27 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { response_variables: thread.response_variable_id ? [thread.response_variable_id] : [], }; - const datasetsComplete = Object.values(threadExecutionData?.model_ensembles ?? {}).some((ens) => - Object.values(ens.bindings ?? {}).some((b) => b.length > 0), - ); const stepStates = deriveStepStates(thread, { - datasetsComplete, - parametersComplete: getParametersStatus(threadExecutionData) === 'done', - runsComplete: getRunsStatus(threadExecutionData) === 'done', + datasetsComplete: datasetsComplete(threadExecutionData), + parametersComplete: parametersComplete(threadExecutionData), + runsComplete: runsComplete(threadExecutionData), }); - const builtModels = buildThreadModels(thread, modelTree); function renderStep() { switch (currentSection) { case 'framing': - return ; + return ( + void handleThreadUpdated()} + onContinue={goNext} + /> + ); case 'variables': return ( void handleThreadUpdated()} onContinue={goNext} onBack={goBack} /> @@ -248,7 +303,7 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { return ( void handleThreadUpdated()} onContinue={goNext} onBack={goBack} onEditIndicator={() => setCurrentSection('variables')} @@ -258,7 +313,9 @@ export function MintThread({ threadId: threadIdProp }: MintThreadProps = {}) { return ( { - const r = (Math.random() * 16) | 0; - return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); - }); -} - -/** MD5-like hash for resource IDs (mirrors legacy getMd5Hash — use URL as the unique key) */ -function hashResourceId(url: string): string { - // Simple djb2 hash converted to hex — not cryptographic, just ID-stable - let h = 5381; - for (let i = 0; i < url.length; i++) { - h = ((h << 5) + h) ^ url.charCodeAt(i); - h = h >>> 0; - } - return h.toString(16).padStart(8, '0'); -} - // ─── Sub-components ─────────────────────────────────────────────────────────── /** Resource selection dialog for a dataset or dataslice */ diff --git a/ui-react/src/pages/modeling/thread/MintParameters.tsx b/ui-react/src/pages/modeling/thread/MintParameters.tsx index cc2755a..760a138 100644 --- a/ui-react/src/pages/modeling/thread/MintParameters.tsx +++ b/ui-react/src/pages/modeling/thread/MintParameters.tsx @@ -16,6 +16,7 @@ import { ModelParameter, ThreadExecutionData, } from '@/graphql/generated/execution'; +import { parametersComplete, totalConfigs } from '@/lib/thread-execution'; // ─── Constants ───────────────────────────────────────────────────────────────── @@ -23,15 +24,6 @@ const MAX_PARAMETER_COMBINATIONS = 100000; // ─── Helpers ─────────────────────────────────────────────────────────────────── -function totalConfigs(bindings: Record, parameters: ModelParameter[]): number { - return parameters - .filter((p) => !p.value) - .reduce((acc, p) => { - const vals = bindings[p.id ?? ''] ?? []; - return acc * Math.max(vals.length, 1); - }, 1); -} - function formatDate(ts: number): string { const date = new Date(ts); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -69,16 +61,10 @@ export function MintParameters({ const modelIds = Object.keys(threadData.models ?? {}); const isConfigured = modelIds.length > 0; - // Derive whether parameters have been selected: every adjustable param has a binding - const isDone = - isConfigured && - modelIds.every((mid) => { - const model = threadData.models[mid]!; - const bindings = threadData.model_ensembles[mid]?.bindings ?? {}; - return model.input_parameters - .filter((p) => !p.value) - .every((p) => (bindings[p.id ?? ''] ?? []).length > 0); - }); + // Shared with the wizard rail. Note it also requires an execution summary: a + // model with no adjustable parameters would otherwise read as done before the + // step had written anything, and the Runs step would have nothing to submit. + const isDone = parametersComplete(threadData); const [editMode, setEditMode] = useState(!isDone); const [waiting, setWaiting] = useState(false); @@ -156,7 +142,7 @@ export function MintParameters({ } } - const cfg = totalConfigs(newEnsembles[mid]?.bindings ?? {}, model.input_parameters); + const cfg = totalConfigs(model, newEnsembles[mid]?.bindings ?? {}, threadData.data); if (cfg > MAX_PARAMETER_COMBINATIONS) { alert( `Too many parameter combinations (${cfg}) for the model '${model.name}'. Please reduce the number of values.`, diff --git a/ui-react/src/pages/modeling/thread/MintRuns.tsx b/ui-react/src/pages/modeling/thread/MintRuns.tsx index 3cdc8f6..2d1f95f 100644 --- a/ui-react/src/pages/modeling/thread/MintRuns.tsx +++ b/ui-react/src/pages/modeling/thread/MintRuns.tsx @@ -52,7 +52,10 @@ interface StatusBarProps { } function StatusBar({ status, progress }: StatusBarProps) { - const pct = status === 'FAILURE' ? 100 : (progress ?? 0); + // `execution.run_progress` is a fraction, not a percentage — a finished run + // stores 1. Read live from TACC, where every completed run reads 1. Treating + // it as a percentage drew every running job as a 1%-wide sliver. + const pct = status === 'FAILURE' ? 100 : Math.min(100, Math.max(0, (progress ?? 0) * 100)); const color = STATUS_BAR_CLASSES[status] ?? 'bg-gray-300'; return (
[] = [getThreadMock, regionsMock, modelTreeMock], + apolloMocks: MockedResponse[] = [ + getThreadMock, + emptyExecutionMock, + regionsMock, + modelTreeMock, + ], authState: AuthState = mockAuthState, ) { return render( @@ -136,6 +283,28 @@ describe('MintThread', () => { ); }); + it('feeds the Parameters step from the thread execution query', async () => { + // The regression this guards: threadExecutionData used to start null and + // nothing ever loaded it, so Parameters read an empty models map and showed + // "Please select model(s) first." with no way forward. + renderMintThread([getThreadWithModelMock, executionMock, regionsMock, modelTreeMock]); + + const parametersStep = await screen.findByTestId('rail-step-parameters'); + await waitFor(() => expect(parametersStep).not.toBeDisabled(), { timeout: 3000 }); + parametersStep.click(); + + expect(await screen.findByTestId('param-input-pAdj')).toBeInTheDocument(); + expect(screen.queryByText(/select model\(s\) first/i)).not.toBeInTheDocument(); + }); + + it('marks Datasets done once a binding exists in the database', async () => { + renderMintThread([getThreadWithModelMock, executionMock, regionsMock, modelTreeMock]); + const datasets = await screen.findByTestId('rail-step-datasets'); + await waitFor(() => expect(datasets).toHaveTextContent(/all inputs assigned/i), { + timeout: 3000, + }); + }); + it('shows the mint-thread container after data loads', async () => { renderMintThread(); await waitFor( diff --git a/ui-react/src/pages/modeling/thread/wizard/DatasetsStep.tsx b/ui-react/src/pages/modeling/thread/wizard/DatasetsStep.tsx index 41f4213..2ea0b9e 100644 --- a/ui-react/src/pages/modeling/thread/wizard/DatasetsStep.tsx +++ b/ui-react/src/pages/modeling/thread/wizard/DatasetsStep.tsx @@ -5,12 +5,26 @@ import { getUserPermission, useUpdateThreadDataMutation, } from '@/graphql/generated/modeling'; +import type { + ModelEnsembleMap, + ThreadExecutionData, + ThreadModel, +} from '@/graphql/generated/execution'; import { useDataCatalogDatasets } from '@/hooks/useDataCatalog'; -import type { DataCatalogDataset, DataCatalogTimePeriod } from '@/lib/data-catalog'; +import type { + DataCatalogDataset, + DataCatalogResource, + DataCatalogTimePeriod, +} from '@/lib/data-catalog'; +import { loadDatasetResources } from '@/lib/data-catalog'; +import { + buildThreadDataInsert, + newDatasliceId, + type ThreadDataInsert, +} from '@/lib/thread-datasets'; import { useAuth } from '@/lib/auth/useAuth'; import { useToast } from '@/components/ui/use-toast'; import { cn } from '@/lib/utils'; -import type { ThreadModel } from '../MintDatasets'; import { StepShell } from './StepShell'; import { FilteredByBanner } from './FilteredByBanner'; @@ -19,6 +33,21 @@ interface RequestedRange { end: Date; } +/** + * A dataset bound to one model input. + * + * `resources` is present when the binding was read back from Hasura — the + * dataslice already holds the exact files that were bound, so re-saving it does + * not have to ask the catalog again. A freshly picked dataset leaves it unset + * and the files are fetched at save time. + */ +interface Assignment { + datasetId: string; + datasetName: string; + timePeriod?: DataCatalogTimePeriod | null; + resources?: DataCatalogResource[]; +} + /** Classify a dataset's temporal coverage against the requested window. */ export function dateCoverage( requested: RequestedRange | null, @@ -38,12 +67,42 @@ function toPeriod( return { start: tp.start_date, end: tp.end_date }; } +/** + * Bindings already written for this thread, as the assignment map the step + * renders: model id -> input id -> the dataset behind the bound dataslice. + */ +export function assignmentsFromBindings( + ensembles: ModelEnsembleMap, + data: ThreadExecutionData['data'], +): Record> { + const out: Record> = {}; + for (const [modelId, ensemble] of Object.entries(ensembles ?? {})) { + for (const [inputId, sliceIds] of Object.entries(ensemble.bindings ?? {})) { + const slice = data?.[sliceIds[0] ?? '']; + // A parameter binding shares this map and has no dataslice behind it. + if (!slice) continue; + const dataset = slice['dataset'] as { id: string; name: string } | undefined; + if (!dataset) continue; + (out[modelId] ??= {})[inputId] = { + datasetId: dataset.id, + datasetName: dataset.name, + resources: (slice['resources'] as DataCatalogResource[] | undefined) ?? [], + }; + } + } + return out; +} + interface DatasetsStepProps { thread: Thread; - /** Built via buildThreadModels(thread, modelTreeData). */ + /** Selected models, keyed by configuration id (from the thread execution query). */ models: Record; + /** Existing bindings, keyed the same way — supplies each model's thread_model id. */ + ensembles: ModelEnsembleMap; + /** Dataslices already persisted for this thread, keyed by dataslice id. */ + persistedData: ThreadExecutionData['data']; regionGeometry?: unknown; - onUpdated: () => void; + onUpdated: () => void | Promise; onContinue: () => void; onBack?: () => void; } @@ -110,6 +169,8 @@ function InputPicker({ export function DatasetsStep({ thread, models, + ensembles, + persistedData, regionGeometry, onUpdated, onContinue, @@ -120,15 +181,27 @@ export function DatasetsStep({ const perm = getUserPermission(thread.permissions, thread.events, user?.username ?? null); const [saving, setSaving] = useState(false); - // assignments: modelId -> inputId -> { datasetId, dataset } - const [assignments, setAssignments] = useState< - Record> - >({}); + // What the database already holds. Recomputed whenever the thread execution + // query refetches, so a save is reflected without remounting the step. + const persisted = useMemo( + () => assignmentsFromBindings(ensembles, persistedData), + [ensembles, persistedData], + ); + + // Edits made in this session. `null` is a deliberate clear, which is why the + // lookup below tests for `undefined` rather than falsiness. + const [overrides, setOverrides] = useState>>({}); const [updateThreadData] = useUpdateThreadDataMutation(); const modelIds = Object.keys(models); + function assignmentFor(modelId: string, inputId: string): Assignment | null { + const override = overrides[modelId]?.[inputId]; + if (override !== undefined) return override; + return persisted[modelId]?.[inputId] ?? null; + } + const requested: RequestedRange | null = useMemo(() => { if (!thread.start_date || !thread.end_date) return null; return { start: new Date(thread.start_date), end: new Date(thread.end_date) }; @@ -143,14 +216,10 @@ export function DatasetsStep({ [modelIds, models], ); - const assignedCount = useMemo( - () => - modelIds.reduce((acc, mid) => { - const reqInputs = models[mid]?.input_files.filter((i) => !i.isOptional) ?? []; - return acc + reqInputs.filter((i) => assignments[mid]?.[i.id]).length; - }, 0), - [modelIds, models, assignments], - ); + const assignedCount = modelIds.reduce((acc, mid) => { + const reqInputs = models[mid]?.input_files.filter((i) => !i.isOptional) ?? []; + return acc + reqInputs.filter((i) => assignmentFor(mid, i.id)).length; + }, 0); const allAssigned = requiredInputCount > 0 && assignedCount === requiredInputCount; @@ -160,10 +229,12 @@ export function DatasetsStep({ datasetId: string | null, dataset?: DataCatalogDataset, ) { - setAssignments((prev) => { + setOverrides((prev) => { const bucket = { ...(prev[modelId] ?? {}) }; - if (!datasetId) delete bucket[inputId]; - else bucket[inputId] = { datasetId, dataset }; + bucket[inputId] = + datasetId && dataset + ? { datasetId, datasetName: dataset.name, timePeriod: dataset.time_period } + : null; return { ...prev, [modelId]: bucket }; }); } @@ -172,9 +243,67 @@ export function DatasetsStep({ if (!allAssigned) return; setSaving(true); try { - // NOTE: writes a minimal SELECT_DATA provenance event; full dataslice/resource - // persistence is lifted from MintDatasets.handleSubmit in a follow-up once per-resource - // filtering is wired. For the core chain we persist the event and advance. + const data: ThreadDataInsert[] = []; + const modelIO: Array<{ + thread_model_id: string; + model_io_id: string; + dataslice_id: string; + }> = []; + + for (const modelId of modelIds) { + const model = models[modelId]; + // The thread_model row id, not the configuration id — thread_model_io + // is keyed by the former. + const threadModelId = ensembles[modelId]?.id; + if (!model || !threadModelId) continue; + + for (const input of model.input_files) { + const assignment = assignmentFor(modelId, input.id); + if (!assignment) continue; + + // The mutation drops every dataslice for the thread before inserting, + // so a binding that is being carried over has to be rebuilt too. A + // binding read back from Hasura already carries its files; a freshly + // picked dataset does not, and the catalog only narrows resources to + // the input's variables on demand. + const resources = + assignment.resources ?? + (await loadDatasetResources({ + datasetId: assignment.datasetId, + variableNames: input.variables ?? [], + })); + + if (resources.length === 0) { + toast({ + title: `No matching files in ${assignment.datasetName}`, + description: `Nothing in this dataset carries ${(input.variables ?? []).join(', ') || 'the input variable'}.`, + variant: 'destructive', + }); + setSaving(false); + return; + } + + const datasliceId = newDatasliceId(); + data.push( + buildThreadDataInsert({ + threadId: thread.id, + threadName: thread.name, + regionId: thread.region_id, + startDate: thread.start_date, + endDate: thread.end_date, + datasliceId, + dataset: { id: assignment.datasetId, name: assignment.datasetName }, + resources, + }), + ); + modelIO.push({ + thread_model_id: threadModelId, + model_io_id: input.id, + dataslice_id: datasliceId, + }); + } + } + await updateThreadData({ variables: { threadId: thread.id, @@ -184,11 +313,12 @@ export function DatasetsStep({ userid: user?.username ?? 'anonymous', notes: null, }, - data: [], - modelIO: [], + data, + modelIO, }, }); - onUpdated(); + setOverrides({}); + await onUpdated(); onContinue(); } catch (err) { toast({ title: 'Save failed', description: String(err), variant: 'destructive' }); @@ -238,7 +368,7 @@ export function DatasetsStep({ {modelIds.map((modelId) => { const model = models[modelId]!; const reqInputs = model.input_files.filter((i) => !i.isOptional); - const doneForModel = reqInputs.filter((i) => assignments[modelId]?.[i.id]).length; + const doneForModel = reqInputs.filter((i) => assignmentFor(modelId, i.id)).length; return (
@@ -252,8 +382,8 @@ export function DatasetsStep({
    {model.input_files.map((input) => { - const current = assignments[modelId]?.[input.id]; - const cov = dateCoverage(requested, toPeriod(current?.dataset?.time_period)); + const current = assignmentFor(modelId, input.id); + const cov = dateCoverage(requested, toPeriod(current?.timePeriod)); return (
  • @@ -279,7 +409,7 @@ export function DatasetsStep({ )} { vi.stubGlobal( @@ -45,9 +45,13 @@ const models: Record = { input_files: [ { id: 'inA', name: 'precipitation', variables: ['sv-precip'], isOptional: false }, ], + output_files: [], + input_parameters: [], }, }; +const ensembles: ModelEnsembleMap = { cfgA: { id: 'tm-1', bindings: {} } }; + describe('dateCoverage', () => { const req = { start: new Date('2000-01-01'), end: new Date('2026-01-01') }; it('returns "none" when no requested range is set', () => { @@ -67,12 +71,67 @@ describe('dateCoverage', () => { }); }); +describe('assignmentsFromBindings', () => { + it('reads the dataset behind each bound dataslice', () => { + const out = assignmentsFromBindings( + { cfgA: { id: 'tm-1', bindings: { inA: ['slice-1'] } } }, + { + 'slice-1': { + id: 'slice-1', + name: 'Rainfall for thread', + dataset: { id: 'ckan-precip', name: 'Rainfall' }, + selected_resources: 2, + resources: [{ id: 'r1', name: 'a.tif', url: 'http://x/a.tif', selected: true }], + }, + }, + ); + expect(out['cfgA']?.['inA']).toMatchObject({ + datasetId: 'ckan-precip', + datasetName: 'Rainfall', + }); + expect(out['cfgA']?.['inA']?.resources).toHaveLength(1); + }); + + it('ignores a parameter binding, which has no dataslice behind it', () => { + const out = assignmentsFromBindings( + { cfgA: { id: 'tm-1', bindings: { 'param-x': ['0.5'] } } }, + {}, + ); + expect(out['cfgA']).toBeUndefined(); + }); +}); + describe('DatasetsStep', () => { + it('counts a binding already written to the database', async () => { + renderWithProviders( + , + ); + expect(await screen.findByText(/1 \/ 1 inputs/i)).toBeInTheDocument(); + }); + it('renders one card per selected model with an inputs counter', async () => { renderWithProviders( { { { { - it('maps selected configuration ids to ThreadModel with input variable names', () => { - const models = buildThreadModels(thread(), tree); - expect(Object.keys(models)).toEqual(['cfgA']); - expect(models.cfgA?.name).toBe('PIHM Flood A'); - expect(models.cfgA?.input_files).toEqual([ - { id: 'inA', name: 'precipitation', variables: ['precip'], isOptional: false }, - ]); - }); - - it('uses standard-variable labels (names) for the data-catalog query, not URI ids', () => { - const models = buildThreadModels(thread(), tree); - // 'precip' is the standard_variable.label; 'sv-precip' is its URI id and must NOT be used. - expect(models.cfgA?.input_files[0]?.variables).toEqual(['precip']); - expect(models.cfgA?.input_files[0]?.variables).not.toContain('sv-precip'); - }); - - it('returns an empty map when no models are selected', () => { - const t = thread(); - t.thread_models = []; - expect(buildThreadModels(t, tree)).toEqual({}); - }); -}); diff --git a/ui-react/src/pages/modeling/thread/wizard/buildThreadModels.ts b/ui-react/src/pages/modeling/thread/wizard/buildThreadModels.ts deleted file mode 100644 index 7eeea12..0000000 --- a/ui-react/src/pages/modeling/thread/wizard/buildThreadModels.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - extractModelIO, - type GetModelTreeWithRegionsQuery, - type ModelConfigInfo, - type ModelSetupInfo, - type Thread, -} from '@/graphql/generated/modeling'; -import type { ThreadModel } from '../MintDatasets'; - -/** Flatten the tree to a map of configuration-id -> config/setup node. */ -function indexConfigs( - data: GetModelTreeWithRegionsQuery, -): Record { - const index: Record = {}; - for (const sw of data.modelcatalog_software) { - for (const ver of sw.versions) { - for (const cfg of ver.configurations) { - index[cfg.id] = cfg; - for (const setup of cfg.child_configurations) index[setup.id] = setup; - } - } - } - return index; -} - -/** - * Build the per-model input map DatasetsStep consumes, keyed by configuration id, - * from the thread's selected models and the extended model-tree query. - */ -export function buildThreadModels( - thread: Thread, - data: GetModelTreeWithRegionsQuery | undefined, -): Record { - if (!data) return {}; - const index = indexConfigs(data); - const result: Record = {}; - - for (const tm of thread.thread_models ?? []) { - const cfgId = tm.modelcatalog_configuration_id; - if (!cfgId) continue; - const node = index[cfgId]; - if (!node) continue; - const io = extractModelIO(node); - result[cfgId] = { - id: cfgId, - name: node.label ?? cfgId, - input_files: io.inputs.map((i) => ({ - id: i.id, - name: i.name, - // The data catalog filters by standard-variable NAME (standard_variable_names__in), - // so pass the variable labels, not the URI ids. - variables: i.variableLabels, - isOptional: i.optional, - })), - }; - } - return result; -}