fix: move alert config from config table to global_settings (#8762)
* feat: move alert config from config table to global_settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rename alert setting to alert_job_queue_waiting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add CLI unit tests for pullInstanceConfigs/pushInstanceConfigs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref.txt to merged main Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
@@ -1 +1 @@
|
||||
9ea1259fa6a95e6b24d1523b2f6ec6ae0042ecfe
|
||||
9ea1259fa6a95e6b24d1523b2f6ec6ae0042ecfe
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -1151,3 +1151,125 @@ async fn test_replace_mode_protects_jwt_secret_and_rsa_keys(db: Pool<Postgres>)
|
||||
);
|
||||
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<Postgres>) {
|
||||
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<Postgres>) {
|
||||
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<Postgres>) {
|
||||
// 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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
139
backend/windmill-api-integration-tests/tests/configs.rs
Normal file
139
backend/windmill-api-integration-tests/tests/configs.rs
Normal file
@@ -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<Postgres>) -> 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::Value> = 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::Value> = 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<Postgres>,
|
||||
) -> 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::Value> = 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<Postgres>,
|
||||
) -> 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::<Vec<_>>()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,3 +114,89 @@ async fn test_settings_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_alert_job_queue_waiting_in_global_settings(db: Pool<Postgres>) -> 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::Value> = 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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
297
cli/test/instance_configs_unit.test.ts
Normal file
297
cli/test/instance_configs_unit.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Alert[]>([])
|
||||
let availableTags = $state<string[]>([])
|
||||
let configName = 'alert__job_queue_waiting'
|
||||
|
||||
let editingRowIndex = $state<number>(-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<string[]> {
|
||||
try {
|
||||
const response = await ConfigService.listConfigs()
|
||||
const response = await ConfigService.listWorkerGroups()
|
||||
const workerTagsSet = new Set<string>()
|
||||
|
||||
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 } }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user