diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index cd32587648..70e676b5b4 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -283,6 +283,8 @@ struct SimplifiedSettings { #[serde(skip_serializing_if = "Option::is_none")] operator_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] + datatable: Option, + #[serde(skip_serializing_if = "Option::is_none")] slack_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] slack_name: Option, @@ -323,6 +325,8 @@ struct SimplifiedSettingsLegacy { #[serde(skip_serializing_if = "Option::is_none")] operator_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] + datatable: Option, + #[serde(skip_serializing_if = "Option::is_none")] slack_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] slack_name: Option, @@ -347,6 +351,7 @@ struct SettingsRow { mute_critical_alerts: Option, color: Option, operator_settings: Option, + datatable: Option, slack_team_id: Option, slack_name: Option, slack_command_script: Option, @@ -955,6 +960,7 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts, color, operator_settings, + datatable, slack_team_id, slack_name, slack_command_script @@ -983,6 +989,7 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color.clone(), operator_settings: row.operator_settings.clone(), + datatable: row.datatable.clone(), slack_team_id: row.slack_team_id.clone(), slack_name: row.slack_name.clone(), slack_command_script: row.slack_command_script.clone(), @@ -1045,6 +1052,7 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color, operator_settings: row.operator_settings, + datatable: row.datatable, slack_team_id: row.slack_team_id, slack_name: row.slack_name, slack_command_script: row.slack_command_script, diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 0f7acce4a9..21ba5463ad 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -53,6 +53,7 @@ export interface SimplifiedSettings { mute_critical_alerts?: boolean; color?: string; operator_settings?: any; + datatable?: any; slack_team_id?: string; slack_name?: string; slack_command_script?: string; @@ -100,6 +101,7 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings { if (settings.mute_critical_alerts !== undefined) result.mute_critical_alerts = settings.mute_critical_alerts; if (settings.color !== undefined) result.color = settings.color; if (settings.operator_settings !== undefined) result.operator_settings = settings.operator_settings; + if (settings.datatable !== undefined) result.datatable = settings.datatable; if (settings.slack_team_id !== undefined) result.slack_team_id = settings.slack_team_id; if (settings.slack_name !== undefined) result.slack_name = settings.slack_name; if (settings.slack_command_script !== undefined) result.slack_command_script = settings.slack_command_script; @@ -192,6 +194,7 @@ export async function pushWorkspaceSettings( mute_critical_alerts: remoteSettings.mute_critical_alerts, color: remoteSettings.color, operator_settings: remoteSettings.operator_settings, + datatable: remoteSettings.datatable, slack_team_id: remoteSettings.slack_team_id, slack_name: remoteSettings.slack_name, slack_command_script: remoteSettings.slack_command_script, @@ -382,6 +385,14 @@ export async function pushWorkspaceSettings( }); } + if (!deepEqual(localSettings.datatable, settings.datatable)) { + log.debug(`Updating datatable config...`); + await wmill.editDataTableConfig({ + workspace, + requestBody: { settings: localSettings.datatable ?? { datatables: {} } }, + }); + } + if (localSettings.slack_command_script != settings.slack_command_script) { log.debug(`Updating slack command script...`); await wmill.editSlackCommand({ diff --git a/cli/test/datatable_settings_sync.test.ts b/cli/test/datatable_settings_sync.test.ts new file mode 100644 index 0000000000..387dd3eaf2 --- /dev/null +++ b/cli/test/datatable_settings_sync.test.ts @@ -0,0 +1,226 @@ +/** + * Datatable settings sync tests + * + * Tests that datatable config is correctly synced via settings.yaml during pull/push operations. + */ + +import { expect, test } from "bun:test"; +import { writeFile, readFile } from "node:fs/promises"; +import { parse, stringify } from "yaml"; +import { withTestBackend } from "./test_backend.ts"; +import { shouldSkipOnCI } from "./cargo_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +test.skipIf(shouldSkipOnCI())("Datatable config: included in sync pull settings.yaml", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_pull_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Configure datatable on backend + const datatableConfig = { + datatables: { + main: { + database: { + resource_path: "u/test/test_db", + resource_type: "postgresql" + } + } + } + }; + + const configResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/edit_datatable_config`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: datatableConfig }) + } + ); + expect(configResp.ok).toBe(true); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // Pull settings + const result = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(result.code).toEqual(0); + + // Verify settings.yaml was created and contains datatable + const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); + expect(settingsContent).toContain("datatable:"); + expect(settingsContent).toContain("u/test/test_db"); + expect(settingsContent).toContain("postgresql"); + }); +}); + +test.skipIf(shouldSkipOnCI())("Datatable config: pushed correctly from settings.yaml", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_push_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // First pull to get baseline settings + const pullResult = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(pullResult.code).toEqual(0); + + // Read existing settings and add datatable config + const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); + const existingSettings = parse(settingsContent) as Record; + + existingSettings.datatable = { + datatables: { + analytics: { + database: { + resource_path: "u/admin/analytics_db", + resource_type: "postgresql" + } + } + } + }; + + await writeFile(`${tempDir}/settings.yaml`, stringify(existingSettings), "utf-8"); + + // Push the modified settings + const pushResult = await backend.runCLICommand(['sync', 'push', '--yes'], tempDir); + expect(pushResult.code).toEqual(0); + + // Verify the backend has the updated datatable config + const settingsResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/get_settings` + ); + expect(settingsResp.ok).toBe(true); + const settings = await settingsResp.json(); + + expect(settings.datatable).toBeDefined(); + expect(settings.datatable.datatables).toBeDefined(); + expect(settings.datatable.datatables.analytics).toBeDefined(); + expect(settings.datatable.datatables.analytics.database.resource_path).toEqual("u/admin/analytics_db"); + expect(settings.datatable.datatables.analytics.database.resource_type).toEqual("postgresql"); + }); +}); + +test.skipIf(shouldSkipOnCI())("Datatable config: empty/undefined doesn't cause errors", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_empty_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Ensure datatable config is empty/cleared on backend + const clearResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/edit_datatable_config`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: { datatables: {} } }) + } + ); + expect(clearResp.ok).toBe(true); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // Pull should succeed even with empty datatable config + const pullResult = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(pullResult.code).toEqual(0); + + // Settings.yaml should exist + const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); + expect(settingsContent.length).toBeGreaterThan(0); + + // Push should also succeed + const pushResult = await backend.runCLICommand(['sync', 'push', '--yes'], tempDir); + expect(pushResult.code).toEqual(0); + }); +}); + +test.skipIf(shouldSkipOnCI())("Datatable config: round-trip preserves structure", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_roundtrip_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Set up a complex datatable config on backend with multiple datatables + const originalConfig = { + datatables: { + users: { + database: { + resource_path: "f/shared/users_db", + resource_type: "postgresql" + } + }, + logs: { + database: { + resource_path: "f/shared/logs_db", + resource_type: "instance" + } + } + } + }; + + const configResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/edit_datatable_config`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: originalConfig }) + } + ); + expect(configResp.ok).toBe(true); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // Pull + const pullResult = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(pullResult.code).toEqual(0); + + // Push back without modification + const pushResult = await backend.runCLICommand(['sync', 'push', '--yes'], tempDir); + expect(pushResult.code).toEqual(0); + + // Verify the config is preserved + const settingsResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/get_settings` + ); + expect(settingsResp.ok).toBe(true); + const settings = await settingsResp.json(); + + expect(settings.datatable).toBeDefined(); + expect(settings.datatable.datatables.users).toBeDefined(); + expect(settings.datatable.datatables.logs).toBeDefined(); + expect(settings.datatable.datatables.users.database.resource_path).toEqual("f/shared/users_db"); + expect(settings.datatable.datatables.logs.database.resource_type).toEqual("instance"); + }); +});