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; -}