diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3c0b9b7e5a..fd2e07ccdd 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9ea1259fa6a95e6b24d1523b2f6ec6ae0042ecfe \ No newline at end of file +9ea1259fa6a95e6b24d1523b2f6ec6ae0042ecfe diff --git a/backend/migrations/20260408140410_move_alert_to_global_settings.down.sql b/backend/migrations/20260408140410_move_alert_to_global_settings.down.sql new file mode 100644 index 0000000000..ece61a3ec9 --- /dev/null +++ b/backend/migrations/20260408140410_move_alert_to_global_settings.down.sql @@ -0,0 +1,6 @@ +-- Move alert config back from global_settings to the config table. +INSERT INTO config (name, config) +SELECT 'alert__job_queue_waiting', value FROM global_settings WHERE name = 'alert_job_queue_waiting' +ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config; + +DELETE FROM global_settings WHERE name = 'alert_job_queue_waiting'; diff --git a/backend/migrations/20260408140410_move_alert_to_global_settings.up.sql b/backend/migrations/20260408140410_move_alert_to_global_settings.up.sql new file mode 100644 index 0000000000..62e860f811 --- /dev/null +++ b/backend/migrations/20260408140410_move_alert_to_global_settings.up.sql @@ -0,0 +1,7 @@ +-- Move alert config from the generic config table to global_settings +-- where it belongs alongside other instance-level settings. +INSERT INTO global_settings (name, value) +SELECT 'alert_job_queue_waiting', config FROM config WHERE name = 'alert__job_queue_waiting' +ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value; + +DELETE FROM config WHERE name = 'alert__job_queue_waiting'; diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index afd414b0c3..055b633d28 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1151,3 +1151,125 @@ async fn test_replace_mode_protects_jwt_secret_and_rsa_keys(db: Pool) ); assert!(get_global_setting(&db, "normal_setting").await.is_none()); } + +// ======================================================================== +// Alert config migration tests +// ======================================================================== + +#[sqlx::test(fixtures("base"))] +async fn test_alert_config_in_global_settings_roundtrip(db: Pool) { + clear_settings_and_configs(&db).await; + + let alert_value = serde_json::json!({ + "alerts": [ + { + "name": "Test Alert", + "tags_to_monitor": ["default", "gpu"], + "jobs_num_threshold": 5, + "alert_cooldown_seconds": 300, + "alert_time_threshold_seconds": 60 + } + ] + }); + + // Insert alert_config into global_settings + insert_global_setting(&db, "alert_job_queue_waiting", alert_value.clone()).await; + + // Verify it appears in InstanceConfig global_settings (via extra) + let config = InstanceConfig::from_db(&db).await.unwrap(); + assert_eq!( + config.global_settings.extra["alert_job_queue_waiting"], alert_value, + "alert_config should appear in global_settings extra" + ); + + // Verify it does NOT appear in worker_configs + assert!( + !config + .worker_configs + .contains_key("alert_job_queue_waiting"), + "alert_config should not appear in worker_configs" + ); + + // Modify the alert_config + let updated_value = serde_json::json!({ + "alerts": [ + { + "name": "Updated Alert", + "tags_to_monitor": ["batch"], + "jobs_num_threshold": 10, + "alert_cooldown_seconds": 600, + "alert_time_threshold_seconds": 120 + } + ] + }); + + let current = config.global_settings.to_settings_map(); + let mut desired = current.clone(); + desired.insert("alert_job_queue_waiting".to_string(), updated_value.clone()); + + let diff = diff_global_settings(¤t, &desired, ApplyMode::Merge); + assert!( + diff.upserts.contains_key("alert_job_queue_waiting"), + "alert_config change should be detected in diff" + ); + + apply_settings_diff(&db, &diff).await.unwrap(); + + // Re-read and verify the update + let config2 = InstanceConfig::from_db(&db).await.unwrap(); + assert_eq!( + config2.global_settings.extra["alert_job_queue_waiting"], + updated_value + ); +} + +#[sqlx::test(fixtures("base"))] +async fn test_alert_config_not_in_worker_configs(db: Pool) { + clear_settings_and_configs(&db).await; + + // Insert alert_config in global_settings (the correct location) + insert_global_setting( + &db, + "alert_job_queue_waiting", + serde_json::json!({"alerts": []}), + ) + .await; + + // Also insert a real worker config + insert_config( + &db, + "worker__default", + serde_json::json!({"worker_tags": ["default"]}), + ) + .await; + + let config = InstanceConfig::from_db(&db).await.unwrap(); + + // alert_config should be in global_settings, not worker_configs + assert!( + config + .global_settings + .extra + .contains_key("alert_job_queue_waiting"), + "alert_config should be in global_settings.extra" + ); + assert_eq!(config.worker_configs.len(), 1); + assert!( + config.worker_configs.contains_key("default"), + "only the real worker config should be in worker_configs" + ); +} + +#[sqlx::test(fixtures("base"))] +async fn test_no_alert_in_config_table_after_migration(db: Pool) { + // After the migration runs, no alert__* entries should remain in the config table + let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM config WHERE name LIKE 'alert__%'") + .fetch_all(&db) + .await + .unwrap(); + assert!( + rows.is_empty(), + "No alert entries should remain in config table after migration, found: {:?}", + rows.iter().map(|(n,)| n.as_str()).collect::>() + ); +} diff --git a/backend/windmill-api-integration-tests/tests/configs.rs b/backend/windmill-api-integration-tests/tests/configs.rs new file mode 100644 index 0000000000..a9559ab456 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/configs.rs @@ -0,0 +1,139 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn assert_2xx(status: u16, body: &str, endpoint: &str) { + assert!( + (200..300).contains(&status), + "{endpoint} returned {status}: {body}", + ); +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_worker_group_crud(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/configs"); + + // Create a worker group + let resp = authed(client().post(format!("{base}/update/worker__test_group"))) + .json(&json!({"worker_tags": ["test_tag"]})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /configs/update/worker__test_group", + ); + + // Verify it exists via get + let resp = authed(client().get(format!("{base}/get/worker__test_group"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx(status, &body, "GET /configs/get/worker__test_group"); + let config: serde_json::Value = serde_json::from_str(&body)?; + assert_eq!(config["worker_tags"], json!(["test_tag"])); + + // Verify it appears in list_worker_groups with prefix stripped + let resp = authed(client().get(format!("{base}/list_worker_groups"))) + .send() + .await?; + let body = resp.text().await?; + let groups: Vec = serde_json::from_str(&body)?; + let test_group = groups.iter().find(|g| g["name"] == "test_group"); + assert!( + test_group.is_some(), + "test_group should appear in list_worker_groups with prefix stripped" + ); + + // Delete the worker group + let resp = authed(client().delete(format!("{base}/update/worker__test_group"))) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "DELETE /configs/update/worker__test_group", + ); + + // Verify it's gone from list_worker_groups + let resp = authed(client().get(format!("{base}/list_worker_groups"))) + .send() + .await?; + let body = resp.text().await?; + let groups: Vec = serde_json::from_str(&body)?; + let test_group = groups.iter().find(|g| g["name"] == "test_group"); + assert!( + test_group.is_none(), + "test_group should be gone after deletion" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_list_worker_groups_excludes_non_worker_entries( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/configs"); + + // Insert a non-worker entry directly into the config table + sqlx::query("INSERT INTO config (name, config) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config") + .bind("test__other_entry") + .bind(json!({"some_key": "some_value"})) + .execute(&db) + .await?; + + // list_worker_groups should NOT return it + let resp = authed(client().get(format!("{base}/list_worker_groups"))) + .send() + .await?; + let body = resp.text().await?; + let groups: Vec = serde_json::from_str(&body)?; + let other_entry = groups + .iter() + .find(|g| g["name"] == "test__other_entry" || g["name"] == "other_entry"); + assert!( + other_entry.is_none(), + "non-worker entries should not appear in list_worker_groups" + ); + + // Clean up + sqlx::query("DELETE FROM config WHERE name = $1") + .bind("test__other_entry") + .execute(&db) + .await?; + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_no_alert_entries_in_config_table_after_migration( + db: Pool, +) -> anyhow::Result<()> { + // After migration, no alert__* entries should remain in the config table + let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM config WHERE name LIKE 'alert__%'") + .fetch_all(&db) + .await?; + assert!( + rows.is_empty(), + "No alert entries should remain in config table after migration, found: {:?}", + rows.iter().map(|(n,)| n.as_str()).collect::>() + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/settings.rs b/backend/windmill-api-integration-tests/tests/settings.rs index 8f21ffb483..348d945a86 100644 --- a/backend/windmill-api-integration-tests/tests/settings.rs +++ b/backend/windmill-api-integration-tests/tests/settings.rs @@ -114,3 +114,89 @@ async fn test_settings_2xx(db: Pool) -> anyhow::Result<()> { Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_alert_job_queue_waiting_in_global_settings(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let settings_base = format!("http://localhost:{port}/api/settings"); + let configs_base = format!("http://localhost:{port}/api/configs"); + + let alert_payload = json!({ + "alerts": [ + { + "name": "Test Alert", + "tags_to_monitor": ["default", "gpu"], + "jobs_num_threshold": 5, + "alert_cooldown_seconds": 300, + "alert_time_threshold_seconds": 60 + } + ] + }); + + // Set alert_job_queue_waiting in global_settings + let resp = authed(client().post(format!("{settings_base}/global/alert_job_queue_waiting"))) + .json(&json!({"value": alert_payload})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /settings/global/alert_job_queue_waiting", + ); + + // Read it back + let resp = authed(client().get(format!("{settings_base}/global/alert_job_queue_waiting"))) + .send() + .await?; + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert_2xx( + status, + &body, + "GET /settings/global/alert_job_queue_waiting", + ); + let value: serde_json::Value = serde_json::from_str(&body)?; + assert_eq!(value["alerts"][0]["name"], "Test Alert"); + assert_eq!(value["alerts"][0]["jobs_num_threshold"], 5); + + // Verify alert_job_queue_waiting does NOT appear in list_worker_groups + let resp = authed(client().get(format!("{configs_base}/list_worker_groups"))) + .send() + .await?; + let body = resp.text().await?; + let groups: Vec = serde_json::from_str(&body)?; + let alert_in_groups = groups.iter().find(|g| { + let name = g["name"].as_str().unwrap_or(""); + name.contains("alert") + }); + assert!( + alert_in_groups.is_none(), + "alert_job_queue_waiting should not appear in worker groups" + ); + + // Delete by setting to null + let resp = authed(client().post(format!("{settings_base}/global/alert_job_queue_waiting"))) + .json(&json!({"value": null})) + .send() + .await?; + assert_2xx( + resp.status().as_u16(), + &resp.text().await?, + "POST /settings/global/alert_job_queue_waiting (null)", + ); + + // Verify it reads back as null + let resp = authed(client().get(format!("{settings_base}/global/alert_job_queue_waiting"))) + .send() + .await?; + let body = resp.text().await?; + let value: serde_json::Value = serde_json::from_str(&body)?; + assert!( + value.is_null(), + "alert_job_queue_waiting should be null after deletion" + ); + + Ok(()) +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 95e41c3bcd..1abdd2ed7f 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -66,6 +66,7 @@ pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const INSTANCE_EVENTS_WEBHOOK_SETTING: &str = "instance_events_webhook"; pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination"; +pub const ALERT_CONFIG_SETTING: &str = "alert_job_queue_waiting"; use std::sync::Arc; use tokio::sync::RwLock; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 21ba5463ad..27414e7adc 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -10,7 +10,6 @@ import { AIConfig, Config, GlobalSetting } from "../../gen/types.gen.ts"; import { compareInstanceObjects, InstanceSyncOptions } from "../commands/instance/instance.ts"; import { isSuperset } from "../types.ts"; import { deepEqual } from "../utils/utils.ts"; -import { removeWorkerPrefix } from "../commands/worker-groups/worker-groups.ts"; import { decrypt, encrypt } from "../utils/local_encryption.ts"; // New grouped config interfaces @@ -625,12 +624,7 @@ export async function pullInstanceConfigs( opts: InstanceSyncOptions, preview = false ) { - const remoteConfigs = (await wmill.listConfigs()).map((x) => { - return { - ...x, - name: removeWorkerPrefix(x.name), - }; - }); + const remoteConfigs = await wmill.listWorkerGroups(); if (preview) { const localConfigs: Config[] = await readLocalConfigs(opts); @@ -658,12 +652,7 @@ export async function pushInstanceConfigs( opts: InstanceSyncOptions, preview: boolean = false ) { - const remoteConfigs = (await wmill.listConfigs()).map((x) => { - return { - ...x, - name: removeWorkerPrefix(x.name), - }; - }); + const remoteConfigs = await wmill.listWorkerGroups(); const localConfigs = await readLocalConfigs(opts); if (preview) { @@ -682,9 +671,7 @@ export async function pushInstanceConfigs( } try { await wmill.updateConfig({ - name: config.name.startsWith("worker__") - ? config.name - : `worker__${config.name}`, + name: `worker__${config.name}`, requestBody: config.config, }); } catch (err) { @@ -698,7 +685,7 @@ export async function pushInstanceConfigs( if (!localMatch) { try { await wmill.deleteConfig({ - name: removeConfig.name, + name: `worker__${removeConfig.name}`, }); } catch (err) { log.error(`Failed to delete config ${removeConfig.name}: ${err}`); diff --git a/cli/test/instance_configs_unit.test.ts b/cli/test/instance_configs_unit.test.ts new file mode 100644 index 0000000000..c8ba15c5dc --- /dev/null +++ b/cli/test/instance_configs_unit.test.ts @@ -0,0 +1,297 @@ +/** + * Unit tests for pullInstanceConfigs / pushInstanceConfigs in settings.ts. + * + * Verifies that: + * - pullInstanceConfigs writes only worker group configs (no alerts) + * - pushInstanceConfigs calls updateConfig with worker__ prefix + * - pushInstanceConfigs calls deleteConfig with worker__ prefix for removed configs + * - pushInstanceConfigs skips unchanged configs + */ + +import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test"; +import { writeFile, readFile, mkdir, rm } from "node:fs/promises"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify as yamlStringify } from "yaml"; + +// Track calls to mocked wmill functions +let listWorkerGroupsResult: any[] = []; +let updateConfigCalls: { name: string; requestBody: any }[] = []; +let deleteConfigCalls: { name: string }[] = []; + +// Mock the wmill module before importing settings.ts +mock.module("../gen/services.gen.ts", () => ({ + listWorkerGroups: async () => listWorkerGroupsResult, + updateConfig: async (args: { name: string; requestBody: any }) => { + updateConfigCalls.push(args); + }, + deleteConfig: async (args: { name: string }) => { + deleteConfigCalls.push(args); + }, + listConfigs: async () => { + throw new Error("listConfigs should not be called"); + }, +})); + +import { + pullInstanceConfigs, + pushInstanceConfigs, + readLocalConfigs, +} from "../src/core/settings.ts"; + +describe("instance configs", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "wm-config-test-")); + listWorkerGroupsResult = []; + updateConfigCalls = []; + deleteConfigCalls = []; + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // ========================================================================= + // readLocalConfigs + // ========================================================================= + + describe("readLocalConfigs", () => { + test("reads configs from instance_configs.yaml", async () => { + const configs = [ + { name: "default", config: { worker_tags: ["deno", "bun"] } }, + { name: "gpu", config: { dedicated_worker: "ws:f/gpu_script" } }, + ]; + await writeFile( + join(tempDir, "instance_configs.yaml"), + yamlStringify(configs), + "utf-8" + ); + + const result = await readLocalConfigs({ + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }); + expect(result).toEqual(configs); + }); + + test("returns empty array when file does not exist", async () => { + const result = await readLocalConfigs({ + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }); + expect(result).toEqual([]); + }); + }); + + // ========================================================================= + // pullInstanceConfigs + // ========================================================================= + + describe("pullInstanceConfigs", () => { + test("writes worker group configs to instance_configs.yaml", async () => { + listWorkerGroupsResult = [ + { name: "default", config: { worker_tags: ["deno", "bun"] } }, + { name: "native", config: { worker_tags: ["nativets"] } }, + ]; + + // readLocalConfigs sets instanceConfigsPath when prefix is used + const opts = { + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }; + await readLocalConfigs(opts); + await pullInstanceConfigs(opts); + + const content = await readFile( + join(tempDir, "instance_configs.yaml"), + "utf-8" + ); + expect(content).toContain("default"); + expect(content).toContain("native"); + expect(content).toContain("deno"); + // Should not contain alert entries (listWorkerGroups filters them) + expect(content).not.toContain("alert"); + }); + + test("preview mode returns change count without writing file", async () => { + listWorkerGroupsResult = [ + { name: "default", config: { worker_tags: ["deno"] } }, + ]; + + const changes = await pullInstanceConfigs( + { + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }, + true + ); + + // One remote config not in local = 1 change + expect(changes).toBe(1); + }); + + test("preview mode returns 0 when remote matches local", async () => { + const configs = [ + { name: "default", config: { worker_tags: ["deno"] } }, + ]; + listWorkerGroupsResult = configs; + + await writeFile( + join(tempDir, "instance_configs.yaml"), + yamlStringify(configs), + "utf-8" + ); + + const changes = await pullInstanceConfigs( + { + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }, + true + ); + + expect(changes).toBe(0); + }); + }); + + // ========================================================================= + // pushInstanceConfigs + // ========================================================================= + + describe("pushInstanceConfigs", () => { + test("calls updateConfig with worker__ prefix for new configs", async () => { + listWorkerGroupsResult = []; + const localConfigs = [ + { name: "mygroup", config: { worker_tags: ["python3"] } }, + ]; + await writeFile( + join(tempDir, "instance_configs.yaml"), + yamlStringify(localConfigs), + "utf-8" + ); + + await pushInstanceConfigs({ + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }); + + expect(updateConfigCalls).toHaveLength(1); + expect(updateConfigCalls[0].name).toBe("worker__mygroup"); + expect(updateConfigCalls[0].requestBody).toEqual({ + worker_tags: ["python3"], + }); + }); + + test("calls deleteConfig with worker__ prefix for removed configs", async () => { + listWorkerGroupsResult = [ + { name: "old_group", config: { worker_tags: ["bash"] } }, + ]; + // Empty local configs = old_group should be deleted + await writeFile( + join(tempDir, "instance_configs.yaml"), + yamlStringify([]), + "utf-8" + ); + + await pushInstanceConfigs({ + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }); + + expect(deleteConfigCalls).toHaveLength(1); + expect(deleteConfigCalls[0].name).toBe("worker__old_group"); + }); + + test("skips unchanged configs", async () => { + const configs = [ + { name: "default", config: { worker_tags: ["deno"] } }, + ]; + listWorkerGroupsResult = configs; + + await writeFile( + join(tempDir, "instance_configs.yaml"), + yamlStringify(configs), + "utf-8" + ); + + await pushInstanceConfigs({ + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }); + + expect(updateConfigCalls).toHaveLength(0); + expect(deleteConfigCalls).toHaveLength(0); + }); + + test("updates changed configs and deletes removed ones", async () => { + listWorkerGroupsResult = [ + { name: "keep", config: { worker_tags: ["old_tag"] } }, + { name: "remove_me", config: { worker_tags: ["bash"] } }, + ]; + const localConfigs = [ + { name: "keep", config: { worker_tags: ["new_tag"] } }, + { name: "add_me", config: { worker_tags: ["python3"] } }, + ]; + await writeFile( + join(tempDir, "instance_configs.yaml"), + yamlStringify(localConfigs), + "utf-8" + ); + + await pushInstanceConfigs({ + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }); + + // "keep" was changed, "add_me" is new + expect(updateConfigCalls).toHaveLength(2); + const updateNames = updateConfigCalls.map((c) => c.name).sort(); + expect(updateNames).toEqual(["worker__add_me", "worker__keep"]); + + // "remove_me" was deleted + expect(deleteConfigCalls).toHaveLength(1); + expect(deleteConfigCalls[0].name).toBe("worker__remove_me"); + }); + + test("preview mode returns change count without calling API", async () => { + listWorkerGroupsResult = [ + { name: "default", config: { worker_tags: ["deno"] } }, + ]; + const localConfigs = [ + { name: "default", config: { worker_tags: ["bun"] } }, + { name: "new_group", config: { worker_tags: ["go"] } }, + ]; + await writeFile( + join(tempDir, "instance_configs.yaml"), + yamlStringify(localConfigs), + "utf-8" + ); + + const changes = await pushInstanceConfigs( + { + prefix: tempDir, + folderPerInstance: true, + prefixSettings: true, + }, + true + ); + + // "default" changed + "new_group" added = 2 changes + expect(changes).toBe(2); + expect(updateConfigCalls).toHaveLength(0); + expect(deleteConfigCalls).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/lib/components/QueueAlerts.svelte b/frontend/src/lib/components/QueueAlerts.svelte index 1f147f52dd..5855db1e87 100644 --- a/frontend/src/lib/components/QueueAlerts.svelte +++ b/frontend/src/lib/components/QueueAlerts.svelte @@ -8,15 +8,13 @@ import { Plus, Edit3, Save, X, Trash, ExternalLink } from 'lucide-svelte' import { sendUserToast } from '$lib/toast' import { twMerge } from 'tailwind-merge' - import { ConfigService, type Alert } from '$lib/gen' + import { ConfigService, SettingService, type Alert } from '$lib/gen' import Tooltip from './Tooltip.svelte' import Badge from './common/badge/Badge.svelte' import { enterpriseLicense } from '$lib/stores' let queueAlertConfig = $state([]) let availableTags = $state([]) - let configName = 'alert__job_queue_waiting' - let editingRowIndex = $state(-1) let editForm = $state<{ tags_to_monitor: string[] @@ -50,8 +48,8 @@ async function fetchConfig() { try { - const response = await ConfigService.getConfig({ name: configName }) - queueAlertConfig = response?.alerts || [] + const response = await SettingService.getGlobal({ key: 'alert_job_queue_waiting' }) + queueAlertConfig = (response as any)?.alerts || [] expandedTagRows = [] } catch (error) { console.error('Failed to fetch config:', error) @@ -60,12 +58,13 @@ async function fetchWorkerTags(): Promise { try { - const response = await ConfigService.listConfigs() + const response = await ConfigService.listWorkerGroups() const workerTagsSet = new Set() - response.forEach((config) => { - if (config.name.startsWith('worker__') && Array.isArray(config.config?.worker_tags)) { - config?.config?.worker_tags.forEach((tag) => workerTagsSet.add(tag)) + response.forEach((wg) => { + const config = wg.config as any + if (Array.isArray(config?.worker_tags)) { + config.worker_tags.forEach((tag: string) => workerTagsSet.add(tag)) } }) @@ -190,9 +189,9 @@ } async function saveQueueAlertConfig() { - await ConfigService.updateConfig({ - name: configName, - requestBody: { alerts: queueAlertConfig } + await SettingService.setGlobal({ + key: 'alert_job_queue_waiting', + requestBody: { value: { alerts: queueAlertConfig } } }) }