diff --git a/app/assets/css/main.css b/app/assets/css/main.css index f150e382..e591ed35 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -16,8 +16,8 @@ html { --page-padding-y: 1.5rem; /* PrimeVue runtime tokens — overridden by PrimeVue at hydration; declared here for IDE resolution */ - --p-primary-color: #ea580c; --p-text-muted-color: #64748b; + --p-primary-color: #ea580c; } .description-box { @@ -27,3 +27,11 @@ html { gap: 1rem; margin-bottom: 2rem; } + +[class='flame-light'] { + --icon-warning: #eaa001; +} + +[class='flame-dark'] { + --icon-warning: #d8cc00; +} \ No newline at end of file diff --git a/app/assets/css/preferences.css b/app/assets/css/preferences.css index 2df677e0..9d0f877e 100644 --- a/app/assets/css/preferences.css +++ b/app/assets/css/preferences.css @@ -5,6 +5,17 @@ gap: 1rem; } +.settings-subsection { + margin-left: 2rem; + margin-top: 1.5rem; +} + +.disabled { + opacity: 0.4; + pointer-events: none; + cursor: not-allowed; +} + .settings-description-title { font-weight: 600; margin-bottom: 0.75rem; diff --git a/app/components/preferences/AutostartField.vue b/app/components/preferences/AutostartField.vue index a87b0152..895e02f0 100644 --- a/app/components/preferences/AutostartField.vue +++ b/app/components/preferences/AutostartField.vue @@ -8,11 +8,13 @@ const autostartModel = defineModel({ required: true });

Autostart Analyses

- Immediately start an analysis as soon as it is detected in the hub for - the current node. If a data store is required for the analysis to run - and is not yet configured, it will not be started. + + Toggle the autostart feature. When enabled, individual projects can be + configured to allow associated analyses to immediately start as soon as + their image arrives at your node. If a data store is required for the + analysis to run and is not yet configured for the project, the analysis + will not start. +
({ required: true }); />
- -
-
-

Autostart Interval

- How often (in seconds) the Hub should be queried to check for new - analyses. +
+ +
+
+

Default Autostart Policy

+ + The default autostart policy assigned to new projects registered in + the Hub for this node. When enabled, new projects will have autostart + enabled for their analyses by default. Autostart settings for + individual projects can still be configured in the Projects table. + +
+
+ +
-
- + +
+
+

Autostart Interval

+ + How often (in seconds) the Hub should be queried to check for new + analyses. + +
+
+ +
diff --git a/app/components/preferences/PreferencesDialog.vue b/app/components/preferences/PreferencesDialog.vue index 789043ec..89814d7a 100644 --- a/app/components/preferences/PreferencesDialog.vue +++ b/app/components/preferences/PreferencesDialog.vue @@ -16,6 +16,7 @@ const requireDataStoreDisabled = computed( const draftAutostart = ref({ enabled: store.autostartEnabled, interval: store.autostartInterval, + default_project_autostart_policy: store.autostartDefaultPolicy, }); const settingsLoaded = computed(() => store.settings !== null); @@ -24,6 +25,8 @@ function resetDrafts() { draftRequireDataStore.value = store.requireDataStoreSetting; draftAutostart.value.enabled = store.autostartEnabled; draftAutostart.value.interval = store.autostartInterval; + draftAutostart.value.default_project_autostart_policy = + store.autostartDefaultPolicy; } watch(preferencesVisible, (visible) => { @@ -43,6 +46,8 @@ async function onSubmitPreferences() { autostart: { enabled: draftAutostart.value.enabled, interval: draftAutostart.value.interval, + default_project_autostart_policy: + draftAutostart.value.default_project_autostart_policy, }, }); @@ -53,6 +58,7 @@ async function onSubmitPreferences() { detail: "Settings were successfully updated", life: 5000, }); + preferencesVisible.value = false; } else { toast.add({ severity: "error", diff --git a/app/components/projects/AutostartToggle.vue b/app/components/projects/AutostartToggle.vue new file mode 100644 index 00000000..e6e901c3 --- /dev/null +++ b/app/components/projects/AutostartToggle.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/app/components/projects/ProjectsTable.vue b/app/components/projects/ProjectsTable.vue index fdc51119..b007f362 100644 --- a/app/components/projects/ProjectsTable.vue +++ b/app/components/projects/ProjectsTable.vue @@ -7,8 +7,10 @@ import { meterSegments, meterTooltip, } from "~/utils/project-status-meter"; +import { useNodeSettingsStore } from "~/stores/nodeSettingsStore.ts"; const filters = ref(); +const store = useNodeSettingsStore(); const { nodeType, requireDataStore } = useDataStoreRequirement(); const { projects, status, summariesLoading, truncated, refreshTable } = @@ -212,6 +214,29 @@ const updateFilters = (filterText: string) => {

+ + + + diff --git a/app/services/Api.ts b/app/services/Api.ts index f1f78e81..677a34b2 100644 --- a/app/services/Api.ts +++ b/app/services/Api.ts @@ -404,6 +404,16 @@ export interface AutostartSettings { * @default 60 */ interval?: number | null; + /** + * Default Project Autostart Policy + * @default false + */ + default_project_autostart_policy?: boolean; + /** + * Projects + * @default {} + */ + projects?: Record; } /** Body_get_token_token_post */ @@ -1854,7 +1864,7 @@ export interface UserSettings { * @default true */ require_data_store?: boolean | null; - /** @default {"enabled":false,"interval":60} */ + /** @default {"enabled":false,"interval":60,"default_project_autostart_policy":false,"projects":{}} */ autostart?: AutostartSettings | null; /** @default {"interval":600} */ kong_cleanup?: KongCleanupSettings | null; diff --git a/app/services/hub_adapter_swagger.json b/app/services/hub_adapter_swagger.json index 565a309a..329f7fcb 100644 --- a/app/services/hub_adapter_swagger.json +++ b/app/services/hub_adapter_swagger.json @@ -3164,7 +3164,7 @@ "anyOf": [ { "type": "integer", - "exclusiveMinimum": 0 + "exclusiveMinimum": 0.0 }, { "type": "null" @@ -3172,6 +3172,19 @@ ], "title": "Interval", "default": 60 + }, + "default_project_autostart_policy": { + "type": "boolean", + "title": "Default Project Autostart Policy", + "default": false + }, + "projects": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object", + "title": "Projects", + "default": {} } }, "additionalProperties": false, @@ -3414,7 +3427,7 @@ }, "type": "object", "title": "CreateServiceRequestClientCertificate", - "description": "Certificate to be used as client certificate while TLS handshaking to the upstream server. With form-encoded, the notation is `client_certificate.id=\u003Cclient_certificate id\u003E`. With JSON, use `\"client_certificate\":{\"id\":\"\u003Cclient_certificate id\u003E\"}`." + "description": "Certificate to be used as client certificate while TLS handshaking to the upstream server. With form-encoded, the notation is `client_certificate.id=`. With JSON, use `\"client_certificate\":{\"id\":\"\"}`." }, "DataStoreType": { "type": "string", @@ -4315,7 +4328,7 @@ "anyOf": [ { "type": "integer", - "exclusiveMinimum": 0 + "exclusiveMinimum": 0.0 }, { "type": "null" @@ -6282,7 +6295,7 @@ "anyOf": [ { "type": "integer", - "exclusiveMinimum": 0 + "exclusiveMinimum": 0.0 }, { "type": "null" @@ -6295,7 +6308,7 @@ "anyOf": [ { "type": "integer", - "exclusiveMinimum": 0 + "exclusiveMinimum": 0.0 }, { "type": "null" @@ -6775,7 +6788,9 @@ ], "default": { "enabled": false, - "interval": 60 + "interval": 60, + "default_project_autostart_policy": false, + "projects": {} } }, "kong_cleanup": { diff --git a/app/stores/nodeSettingsStore.ts b/app/stores/nodeSettingsStore.ts index 8ecf832e..669b6e34 100644 --- a/app/stores/nodeSettingsStore.ts +++ b/app/stores/nodeSettingsStore.ts @@ -21,6 +21,14 @@ export const useNodeSettingsStore = defineStore("nodeSettings", { autostartInterval(state): number { return state.settings?.autostart?.interval ?? 60; }, + autostartDefaultPolicy(state): boolean { + return Boolean( + state.settings?.autostart?.default_project_autostart_policy ?? false, + ); + }, + projectAutostartSettings(state): Record { + return state.settings?.autostart?.projects ?? {}; + }, }, actions: { async fetchSettings() { diff --git a/test/components/preferences/AutostartField.spec.ts b/test/components/preferences/AutostartField.spec.ts index 225dfc0f..e2b41adb 100644 --- a/test/components/preferences/AutostartField.spec.ts +++ b/test/components/preferences/AutostartField.spec.ts @@ -34,20 +34,22 @@ function mountField(modelValue: AutostartSettings) { describe("AutostartField.vue", () => { it("renders the autostart enabled section", () => { const wrapper = mountField({ enabled: false, interval: 60 }); - expect( - wrapper.find(".autostart-enabled-field .settings-description-title").text(), - ).toBe("Autostart Analyses"); expect( wrapper - .find(".autostart-enabled-field .setting-description-text") + .find(".autostart-enabled-field .settings-description-title") .text(), - ).toContain("Immediately start an analysis"); + ).toBe("Autostart Analyses"); + expect( + wrapper.find(".autostart-enabled-field .setting-description-text").text(), + ).toContain("Toggle the autostart feature"); }); it("renders the autostart interval section", () => { const wrapper = mountField({ enabled: false, interval: 60 }); expect( - wrapper.find(".autostart-interval-field .settings-description-title").text(), + wrapper + .find(".autostart-interval-field .settings-description-title") + .text(), ).toBe("Autostart Interval"); expect( wrapper @@ -56,6 +58,62 @@ describe("AutostartField.vue", () => { ).toContain("How often"); }); + it("renders the default autostart policy section", () => { + const wrapper = mountField({ enabled: false, interval: 60 }); + expect( + wrapper + .find(".autostart-default-policy-field .settings-description-title") + .text(), + ).toBe("Default Autostart Policy"); + expect( + wrapper + .find(".autostart-default-policy-field .setting-description-text") + .text(), + ).toContain("default autostart policy assigned to new projects"); + }); + + it("dims the subsettings when autostart is off", () => { + const wrapper = mountField({ enabled: false, interval: 60 }); + const subsettings = wrapper.findAll(".settings-subsection .settings-field"); + expect(subsettings.length).toBe(2); + subsettings.forEach((field) => + expect(field.classes()).toContain("disabled"), + ); + }); + + it("does not dim the subsettings when autostart is on", () => { + const wrapper = mountField({ enabled: true, interval: 60 }); + wrapper + .findAll(".settings-subsection .settings-field") + .forEach((field) => expect(field.classes()).not.toContain("disabled")); + }); + + it("default policy toggle reflects the model", () => { + const wrapper = mountField({ + enabled: true, + interval: 60, + default_project_autostart_policy: true, + }); + expect(wrapper.findAll(".mock-toggle")[1].attributes("data-checked")).toBe( + "true", + ); + }); + + it("default policy toggle updates the model", async () => { + const model: AutostartSettings = { + enabled: true, + interval: 60, + default_project_autostart_policy: false, + }; + const wrapper = mountField(model); + + await wrapper.findAll(".mock-toggle")[1].trigger("click"); + await nextTick(); + + expect(model.default_project_autostart_policy).toBe(true); + expect(model.enabled).toBe(true); + }); + it("toggle is off when enabled is false", () => { const wrapper = mountField({ enabled: false, interval: 60 }); expect(wrapper.find(".mock-toggle").attributes("data-checked")).toBe( @@ -65,7 +123,9 @@ describe("AutostartField.vue", () => { it("toggle is on when enabled is true", () => { const wrapper = mountField({ enabled: true, interval: 60 }); - expect(wrapper.find(".mock-toggle").attributes("data-checked")).toBe("true"); + expect(wrapper.find(".mock-toggle").attributes("data-checked")).toBe( + "true", + ); }); it("InputNumber is disabled when autostart is off", () => { @@ -84,9 +144,9 @@ describe("AutostartField.vue", () => { it("InputNumber shows the initial interval value", () => { const wrapper = mountField({ enabled: true, interval: 120 }); - expect(wrapper.find(".mock-input-number").element.getAttribute("value")).toBe( - "120", - ); + expect( + wrapper.find(".mock-input-number").element.getAttribute("value"), + ).toBe("120"); }); it("InputNumber becomes enabled after toggling autostart on", async () => { diff --git a/test/components/preferences/PreferencesDialog.spec.ts b/test/components/preferences/PreferencesDialog.spec.ts index 68bdb146..40edb7fa 100644 --- a/test/components/preferences/PreferencesDialog.spec.ts +++ b/test/components/preferences/PreferencesDialog.spec.ts @@ -59,6 +59,7 @@ describe("PreferencesDialog.vue", () => { requireDataStoreSetting: true, autostartEnabled: false, autostartInterval: 60, + autostartDefaultPolicy: false, updateSettings: mockUpdateSettings, } as unknown as ReturnType); }); @@ -95,6 +96,7 @@ describe("PreferencesDialog.vue", () => { requireDataStoreSetting: false, autostartEnabled: true, autostartInterval: 30, + autostartDefaultPolicy: true, updateSettings: mockUpdateSettings, } as unknown as ReturnType); @@ -118,10 +120,67 @@ describe("PreferencesDialog.vue", () => { expect(mockUpdateSettings).toHaveBeenCalledWith({ require_data_store: true, - autostart: { enabled: false, interval: 60 }, + autostart: { + enabled: false, + interval: 60, + default_project_autostart_policy: false, + }, }); }); + it("submits the default autostart policy from the store", async () => { + vi.mocked(useNodeSettingsStore).mockReturnValue({ + requireDataStore: true, + requireDataStoreSetting: true, + autostartEnabled: true, + autostartInterval: 60, + autostartDefaultPolicy: true, + updateSettings: mockUpdateSettings, + } as unknown as ReturnType); + + const wrapper = mount(PreferencesDialog, { + props: { preferencesVisible: true }, + global: { stubs: globalStubs }, + }); + + await wrapper.find(".preferences-update-btn button").trigger("click"); + await flushPromises(); + + expect(mockUpdateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + autostart: expect.objectContaining({ + default_project_autostart_policy: true, + }), + }), + ); + }); + + it("closes the dialog after a successful update", async () => { + const wrapper = mount(PreferencesDialog, { + props: { preferencesVisible: true }, + global: { stubs: globalStubs }, + }); + + await wrapper.find(".preferences-update-btn button").trigger("click"); + await flushPromises(); + + expect(wrapper.emitted("update:preferencesVisible")).toEqual([[false]]); + }); + + it("keeps the dialog open when the update fails", async () => { + mockUpdateSettings.mockResolvedValue(null); + + const wrapper = mount(PreferencesDialog, { + props: { preferencesVisible: true }, + global: { stubs: globalStubs }, + }); + + await wrapper.find(".preferences-update-btn button").trigger("click"); + await flushPromises(); + + expect(wrapper.emitted("update:preferencesVisible")).toBeUndefined(); + }); + it("shows success toast after a successful update", async () => { const wrapper = mount(PreferencesDialog, { props: { preferencesVisible: true }, @@ -164,6 +223,7 @@ describe("PreferencesDialog.vue", () => { requireDataStoreSetting: false, autostartEnabled: true, autostartInterval: 30, + autostartDefaultPolicy: true, updateSettings: mockUpdateSettings, } as unknown as ReturnType); diff --git a/test/components/preferences/PreferencesDialogStore.spec.ts b/test/components/preferences/PreferencesDialogStore.spec.ts index 28d80c40..bedbad65 100644 --- a/test/components/preferences/PreferencesDialogStore.spec.ts +++ b/test/components/preferences/PreferencesDialogStore.spec.ts @@ -23,7 +23,7 @@ const AutostartFieldStub = { props: ["modelValue"], emits: ["update:modelValue"], template: - '
', + '
', }; const globalStubs = { @@ -184,6 +184,31 @@ describe("PreferencesDialog.vue + nodeSettingsStore", () => { expect(autostart.attributes("data-interval")).toBe("30"); }); + it("re-seeds the default autostart policy when settings arrive", async () => { + const store = seedUnloaded(); + + const wrapper = mount(PreferencesDialog, { + props: { preferencesVisible: false }, + global: { stubs: globalStubs }, + }); + await wrapper.setProps({ preferencesVisible: true }); + await flushPromises(); + + store.settings = { + require_data_store: true, + autostart: { + enabled: true, + interval: 60, + default_project_autostart_policy: true, + }, + }; + await flushPromises(); + + expect( + wrapper.find(".autostart-stub").attributes("data-default-policy"), + ).toBe("true"); + }); + it("saves the loaded values, not the pre-load defaults", async () => { const store = seedUnloaded(); @@ -203,7 +228,11 @@ describe("PreferencesDialog.vue + nodeSettingsStore", () => { expect(store.updateSettings).toHaveBeenCalledWith({ require_data_store: false, - autostart: { enabled: true, interval: 30 }, + autostart: { + enabled: true, + interval: 30, + default_project_autostart_policy: false, + }, }); }); }); @@ -232,4 +261,29 @@ describe("PreferencesDialog.vue + nodeSettingsStore", () => { wrapper.find(".require-datastore-stub").attributes("data-value"), ).toBe("false"); }); + + it("persists the default autostart policy across a save and reopen", async () => { + const store = seedStore("default", { + require_data_store: true, + autostart: { + enabled: true, + interval: 60, + default_project_autostart_policy: true, + }, + }); + + const wrapper = mount(PreferencesDialog, { + props: { preferencesVisible: true }, + global: { stubs: globalStubs }, + }); + + await toggleSaveAndReopen(wrapper); + + expect(store.settings?.autostart?.default_project_autostart_policy).toBe( + true, + ); + expect( + wrapper.find(".autostart-stub").attributes("data-default-policy"), + ).toBe("true"); + }); }); diff --git a/test/components/projects/AutostartToggle.spec.ts b/test/components/projects/AutostartToggle.spec.ts new file mode 100644 index 00000000..2b0ed54b --- /dev/null +++ b/test/components/projects/AutostartToggle.spec.ts @@ -0,0 +1,352 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ConfirmationEventBus from "primevue/confirmationeventbus"; +import { useConfirm } from "primevue/useconfirm"; +import { useToast } from "primevue/usetoast"; +import AutostartToggle from "~/components/projects/AutostartToggle.vue"; +import { useNodeSettingsStore } from "~/stores/nodeSettingsStore"; +import type { UserSettings } from "~/services/Api"; + +const PROJECT_ID = "7f2f3b59-3b6d-4fb6-a900-2a4d5c2ea483"; +const PROJECT_NAME = "fake-project"; + +interface ConfirmOptions { + group?: string; + message?: string; + header?: string; + icon?: string; + accept?: () => void; + reject?: () => void; + onHide?: () => void; +} + +// Fires the parent's click listener before the model update, matching the real +// ToggleSwitch where the root click precedes the input's change event. +const ToggleSwitchStub = { + props: ["modelValue", "disabled"], + emits: ["click", "update:modelValue"], + template: + '