diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 23afdb58..d15b6e35 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -3,7 +3,7 @@ name: ui on: push: branches: - - '*' + - '**' tags: - v* pull_request: @@ -60,9 +60,12 @@ jobs: name: nextjs-build path: build/ - - name: Create environment variable with the commit id + - name: Create environment variables (commit id + sanitized branch) run: | echo "DOCKER_TAG=${GITHUB_SHA}" >> $GITHUB_ENV + SAFE_BRANCH="${{ steps.branch-name.outputs.current_branch }}" + # Docker tags forbid '/' — replace with '-' so slash-containing branches build + echo "SAFE_BRANCH=${SAFE_BRANCH//\//-}" >> $GITHUB_ENV - name: Expose the commit id id: exposeValue @@ -86,7 +89,7 @@ jobs: with: push: true context: . - tags: ${{ env.DOCKER_IMAGE_REPOSITORY }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.branch-name.outputs.current_branch }}, ${{ env.DOCKER_IMAGE_REPOSITORY }}/${{ env.DOCKER_IMAGE_NAME }}:${{ env.DOCKER_TAG }} + tags: ${{ env.DOCKER_IMAGE_REPOSITORY }}/${{ env.DOCKER_IMAGE_NAME }}:${{ env.SAFE_BRANCH }}, ${{ env.DOCKER_IMAGE_REPOSITORY }}/${{ env.DOCKER_IMAGE_NAME }}:${{ env.DOCKER_TAG }} file: ${{ env.DOCKER_FILE}} platforms: linux/amd64,linux/arm64 @@ -108,6 +111,7 @@ jobs: name: 'Scan vulnerabilities in the image' needs: [push] runs-on: ubuntu-latest + continue-on-error: true steps: - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master diff --git a/src/model-catalog-api/custom-apis/model-configuration-setup.ts b/src/model-catalog-api/custom-apis/model-configuration-setup.ts index 1ccb9817..e99fc0dd 100644 --- a/src/model-catalog-api/custom-apis/model-configuration-setup.ts +++ b/src/model-catalog-api/custom-apis/model-configuration-setup.ts @@ -29,6 +29,40 @@ export class CustomModelConfigurationSetupApi extends DefaultReduxApi< super(ModelConfigurationSetupApi, user, config); } + /** Override get to restore isOptional on hasInput items from the raw API response. + * The generated ModelConfigurationSetupFromJSON strips isOptional because it is not + * in the v1.8.0 OpenAPI schema used to generate the client. + */ + public get: ActionThunk, MCActionAdd> = + (uri: string) => (dispatch) => { + const id: string = this._getIdFromUri(uri); + const rawReq = this._api.modelconfigurationsetupsIdGetRaw({ + username: this._username, + id, + }); + return rawReq.then(async (apiResponse) => { + const [rawJson, typed] = await Promise.all([ + apiResponse.raw.clone().json() as Promise, + apiResponse.value(), + ]); + // Restore isOptional on hasInput items from the raw JSON + if (typed.hasInput && rawJson.hasInput) { + typed.hasInput = typed.hasInput.map((item: any, i: number) => ({ + ...item, + isOptional: !!(rawJson.hasInput[i]?.isOptional), + })); + } + if (this._redux) { + dispatch({ + type: MODEL_CATALOG_ADD, + kind: this.getName(), + payload: this._idReducer({}, typed), + }); + } + return typed; + }); + }; + private simplePost: ActionThunk< Promise, MCActionAdd diff --git a/src/model-catalog-api/custom-apis/model-configuration.ts b/src/model-catalog-api/custom-apis/model-configuration.ts index 6da0a880..6a80a393 100644 --- a/src/model-catalog-api/custom-apis/model-configuration.ts +++ b/src/model-catalog-api/custom-apis/model-configuration.ts @@ -1,6 +1,7 @@ import { IdMap } from "app/reducers"; import { MCActionAdd, + MODEL_CATALOG_ADD, ActionThunk, } from "../actions"; import { Configuration, BaseAPI, TapisApp } from "@mintproject/modelcatalog_client"; @@ -25,6 +26,41 @@ export class CustomModelConfigurationApi extends DefaultReduxApi< super(ModelConfigurationApi, user, config); } + /** Override get to restore isOptional on hasInput items from the raw API response. + * The generated ModelConfigurationFromJSON strips isOptional because it is not in + * the v1.8.0 OpenAPI schema used to generate the client. We fetch the raw JSON + * alongside the typed object and merge the flag back before dispatching to Redux. + */ + public get: ActionThunk, MCActionAdd> = + (uri: string) => (dispatch) => { + const id: string = this._getIdFromUri(uri); + const rawReq = this._api.modelconfigurationsIdGetRaw({ + username: this._username, + id, + }); + return rawReq.then(async (apiResponse) => { + const [rawJson, typed] = await Promise.all([ + apiResponse.raw.clone().json() as Promise, + apiResponse.value(), + ]); + // Restore isOptional on hasInput items from the raw JSON + if (typed.hasInput && rawJson.hasInput) { + typed.hasInput = typed.hasInput.map((item: any, i: number) => ({ + ...item, + isOptional: !!(rawJson.hasInput[i]?.isOptional), + })); + } + if (this._redux) { + dispatch({ + type: MODEL_CATALOG_ADD, + kind: this.getName(), + payload: this._idReducer({}, typed), + }); + } + return typed; + }); + }; + private simplePost: ActionThunk, MCActionAdd> = this.post; diff --git a/src/model-catalog-api/model-catalog-api.ts b/src/model-catalog-api/model-catalog-api.ts index 1f284887..22f899fe 100644 --- a/src/model-catalog-api/model-catalog-api.ts +++ b/src/model-catalog-api/model-catalog-api.ts @@ -2,6 +2,7 @@ import { Configuration, ConfigurationParameters, } from "@mintproject/modelcatalog_client"; +import "./sdk-patches"; import { UserCatalog } from "./user-catalog"; import { MINT_PREFERENCES } from "config"; diff --git a/src/model-catalog-api/sdk-patches.ts b/src/model-catalog-api/sdk-patches.ts new file mode 100644 index 00000000..867c94a0 --- /dev/null +++ b/src/model-catalog-api/sdk-patches.ts @@ -0,0 +1,34 @@ +// Patch the SDK's models index so internal API call sites use the patched +// serializer/deserializer. The internal .js modules access functions via +// `require("../models").DatasetSpecificationToJSON`, so we mutate that exact +// object. Patching the top-level package index does NOT propagate, because the +// top-level uses __export copies. +const models = require("@mintproject/modelcatalog_client/dist/models"); + +const originalToJSON = models.DatasetSpecificationToJSON; +const originalFromJSONTyped = models.DatasetSpecificationFromJSONTyped; +const originalFromJSON = models.DatasetSpecificationFromJSON; + +function patchedToJSON(value: any): any { + const out = originalToJSON(value); + if (out && value && value.isOptional !== undefined && value.isOptional !== null) { + out.isOptional = value.isOptional; + } + return out; +} + +function patchedFromJSONTyped(json: any, ignoreDiscriminator: boolean): any { + const ds = originalFromJSONTyped(json, ignoreDiscriminator); + if (ds && json && json.isOptional !== undefined && json.isOptional !== null) { + ds.isOptional = json.isOptional; + } + return ds; +} + +function patchedFromJSON(json: any): any { + return patchedFromJSONTyped(json, false); +} + +models.DatasetSpecificationToJSON = patchedToJSON; +models.DatasetSpecificationFromJSONTyped = patchedFromJSONTyped; +models.DatasetSpecificationFromJSON = patchedFromJSON; diff --git a/src/screens/models/configure/resources/dataset-specification.ts b/src/screens/models/configure/resources/dataset-specification.ts index 7bb3c2d2..5ae2f7bc 100644 --- a/src/screens/models/configure/resources/dataset-specification.ts +++ b/src/screens/models/configure/resources/dataset-specification.ts @@ -1,4 +1,4 @@ -import { ModelCatalogResource } from "./resource"; +import { ModelCatalogResource, Action } from "./resource"; import { property, html, customElement, css } from "lit-element"; import { connect } from "pwa-helpers/connect-mixin"; import { store } from "app/store"; @@ -84,6 +84,11 @@ export class ModelCatalogDatasetSpecification extends connect(store)( private sampleResources: IdMap = {}; private sampleCollections: IdMap = {}; private _vpDisplayer: IdMap = {}; + // isOptional is a junction column on modelcatalog_configuration_input, not on the + // DatasetSpecification entity. The base setResources() refetches each row by id, + // and that entity GET has no isOptional. Stash the inline value from the parent + // ModelConfiguration payload and re-apply it onto _loadedResources before render. + private _junctionOverlay: { [id: string]: { isOptional?: boolean } } = {}; @property({ type: String }) private _fileType: "resource" | "collection" = "resource"; @@ -92,6 +97,26 @@ export class ModelCatalogDatasetSpecification extends connect(store)( this.colspan = 4; } + public setResources(r: DatasetSpecification[]) { + this._junctionOverlay = {}; + (r || []).forEach((it: any) => { + if (it && it.id) { + this._junctionOverlay[it.id] = { isOptional: !!it.isOptional }; + } + }); + super.setResources(r); + } + + public requestUpdate(name?: PropertyKey, oldValue?: unknown) { + Object.keys(this._junctionOverlay || {}).forEach((id: string) => { + const lr = (this as any)._loadedResources[id]; + if (lr && (lr as any).isOptional === undefined) { + (lr as any).isOptional = this._junctionOverlay[id].isOptional; + } + }); + return super.requestUpdate(name as any, oldValue); + } + constructor() { super(); this._inputVariablePresentation = new ModelCatalogVariablePresentation(); @@ -198,6 +223,23 @@ export class ModelCatalogDatasetSpecification extends connect(store)( >(.${r.hasFormat})` : ""} + ${this._action === Action.EDIT_OR_ADD + ? html`` + : (r as any).isOptional + ? html`optional` + : ""} ${r.description ? r.description[0] : ""} @@ -403,7 +445,8 @@ export class ModelCatalogDatasetSpecification extends connect(store)( "If no variables are associated with an input, we will not be able to search dataset candidates in the MINT data catalog when using this model" ) ) { - return DatasetSpecificationFromJSON(jsonRes); + const result = DatasetSpecificationFromJSON(jsonRes); + return result; } } else { // Show errors diff --git a/src/screens/models/configure/resources/model-configuration-setup.ts b/src/screens/models/configure/resources/model-configuration-setup.ts index bb3b4839..38ae41a0 100644 --- a/src/screens/models/configure/resources/model-configuration-setup.ts +++ b/src/screens/models/configure/resources/model-configuration-setup.ts @@ -207,6 +207,7 @@ export class ModelCatalogModelConfigurationSetup extends connect(store)( } this._inputParameter.setResources(r.hasParameter); + // hasInput items carry isOptional from the raw API response (see CustomModelConfigurationSetupApi.get) this._inputDSInput.setResources(r.hasInput); this._inputDSOutput.setResources(r.hasOutput); this._inputSourceCode.setResources(r.hasSourceCode); @@ -458,6 +459,7 @@ export class ModelCatalogModelConfigurationSetup extends connect(store)( ${this._inputParameter} Files: +
Inputs marked as optional can be skipped when no dataset is bound for execution.
${this._inputDSInput} Output files: @@ -686,6 +688,7 @@ ${edResource && edResource.hasUsageNotes ${this._inputParameter} Files: +
Use the "optional" checkbox on each input to mark it skippable. Save the configuration to persist.
${this._inputDSInput} Output files: @@ -830,7 +833,8 @@ ${edResource && edResource.hasUsageNotes hasOutputTimeInterval: this._inputTimeInterval.getResources(), hasGrid: this._inputGrid.getResources(), hasParameter: this._inputParameter.getResources(), - hasInput: this._inputDSInput.getResources(), + // getResources() returns hasInput items with isOptional preserved from API or form edits + hasInput: this._inputDSInput.getResources().map((ds: any) => ({ ...ds, isOptional: ds.isOptional ?? false })), hasOutput: this._inputDSOutput.getResources(), hasSourceCode: this._inputSourceCode.getResources(), hasConstraint: this._inputConstraint.getResources(), diff --git a/src/screens/models/configure/resources/model-configuration.ts b/src/screens/models/configure/resources/model-configuration.ts index 28068926..3d9aeaac 100644 --- a/src/screens/models/configure/resources/model-configuration.ts +++ b/src/screens/models/configure/resources/model-configuration.ts @@ -194,6 +194,7 @@ export class ModelCatalogModelConfiguration extends connect(store)( } this._inputParameter.setResources(r.hasParameter); + // hasInput items carry isOptional from the raw API response (see CustomModelConfigurationApi.get) this._inputDSInput.setResources(r.hasInput); this._inputDSOutput.setResources(r.hasOutput); this._inputSourceCode.setResources(r.hasSourceCode); @@ -435,6 +436,7 @@ export class ModelCatalogModelConfiguration extends connect(store)( ${this._inputParameter} Files: +
Inputs marked as optional can be skipped when no dataset is bound for execution.
${this._inputDSInput} Output files: @@ -689,6 +691,7 @@ ${edResource && edResource.hasUsageNotes ${this._inputParameter} Files: +
Use the "optional" checkbox on each input to mark it skippable. Save the configuration to persist.
${this._inputDSInput} Output files: @@ -825,7 +828,8 @@ ${edResource && edResource.hasUsageNotes hasGrid: this._inputGrid.getResources(), // this ones are temporal resources hasParameter: this._inputParameter.getResources(), - hasInput: this._inputDSInput.getResources(), + // getResources() returns hasInput items with isOptional preserved from API or form edits + hasInput: this._inputDSInput.getResources().map((ds: any) => ({ ...ds, isOptional: ds.isOptional ?? false })), hasOutput: this._inputDSOutput.getResources(), hasSourceCode: this._inputSourceCode.getResources(), hasConstraint: this._inputConstraint.getResources(),