@@ -50,15 +53,12 @@ along with this program. If not, see
@@ -143,10 +143,10 @@ along with this program. If not, see
+
{{ t("settings.customLogoText") }}
@@ -192,10 +192,10 @@ along with this program. If not, see .
/>
@@ -217,11 +217,11 @@ along with this program. If not, see .
@@ -286,10 +286,10 @@ along with this program. If not, see
.
/>
@@ -357,10 +357,10 @@ along with this program. If not, see
.
/>
@@ -405,11 +405,10 @@ along with this program. If not, see
.
diff --git a/web/src/components/settings/OrgStorageEditor.schema.ts b/web/src/components/settings/OrgStorageEditor.schema.ts
new file mode 100644
index 0000000000..df9a7fa338
--- /dev/null
+++ b/web/src/components/settings/OrgStorageEditor.schema.ts
@@ -0,0 +1,73 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// Provider-discriminated validation schema for OrgStorageEditor.vue. The storage
+// provider is chosen from a custom card grid (not an
), so it is bridged
+// into the form as `selectedProvider` (the documented sanctioned discriminator
+// bridge, as CreateDestinationForm does) and `superRefine` branches on it.
+//
+// Restores the Quasar BEFORE rules (truthy→Zod inversion) — every per-provider
+// credential was `(v)=>!!v?.trim() || t(
)`:
+// • AwsCredentials → bucket_name, access_key, secret_key required (trim)
+// • AzureCredentials→ storage_account, bucket_name, secret_key required
+// • GcpCredentials → bucket_name, access_key required
+// • AwsRoleArn → bucket_name, region, role_arn, external_id required
+//
+// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
+// this file only describes WHAT is valid.
+
+import { z } from "zod";
+
+export const makeOrgStorageEditorSchema = (t: (_key: string) => string) =>
+ z
+ .object({
+ // Bridged discriminator (the provider card grid).
+ selectedProvider: z.string().optional().default(""),
+ // All credential fields are loose at the object level; the per-provider
+ // requireds are enforced in superRefine below.
+ server_url: z.string().optional().default(""),
+ region: z.string().optional().default(""),
+ bucket_name: z.string().optional().default(""),
+ access_key: z.string().optional().default(""),
+ storage_account: z.string().optional().default(""),
+ secret_key: z.string().optional().default(""),
+ role_arn: z.string().optional().default(""),
+ external_id: z.string().optional().default(""),
+ })
+ .superRefine((val, ctx) => {
+ const require = (field: keyof typeof val, msgKey: string) => {
+ if (!String(val[field] ?? "").trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [field],
+ message: t(msgKey),
+ });
+ }
+ };
+
+ switch (val.selectedProvider) {
+ case "AwsCredentials":
+ require("bucket_name", "storage_settings.bucketNameRequired");
+ require("access_key", "storage_settings.accessKeyRequired");
+ require("secret_key", "storage_settings.secretKeyRequired");
+ break;
+ case "AzureCredentials":
+ require("storage_account", "storage_settings.storageAccountRequired");
+ require("bucket_name", "storage_settings.bucketNameRequired");
+ require("secret_key", "storage_settings.secretKeyRequired");
+ break;
+ case "GcpCredentials":
+ require("bucket_name", "storage_settings.bucketNameRequired");
+ require("access_key", "storage_settings.accessKeyRequired");
+ break;
+ case "AwsRoleArn":
+ require("bucket_name", "storage_settings.bucketNameRequired");
+ require("region", "storage_settings.regionRequired");
+ require("role_arn", "storage_settings.roleARNRequired");
+ require("external_id", "storage_settings.externalIdRequired");
+ break;
+ }
+ });
+
+export type OrgStorageEditorForm = z.infer<
+ ReturnType
+>;
diff --git a/web/src/components/settings/OrgStorageEditor.spec.ts b/web/src/components/settings/OrgStorageEditor.spec.ts
new file mode 100644
index 0000000000..8011fe3b21
--- /dev/null
+++ b/web/src/components/settings/OrgStorageEditor.spec.ts
@@ -0,0 +1,364 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
+import OrgStorageEditor from "./OrgStorageEditor.vue";
+import i18n from "@/locales";
+import { nextTick } from "vue";
+import store from "@/test/unit/helpers/store";
+import { makeOrgStorageEditorSchema } from "./OrgStorageEditor.schema";
+
+const { mockCreate, mockUpdate, mockGet, mockToast } = vi.hoisted(() => ({
+ mockCreate: vi.fn(),
+ mockUpdate: vi.fn(),
+ mockGet: vi.fn(),
+ mockToast: vi.fn(() => vi.fn()),
+}));
+
+vi.mock("@/lib/feedback/Toast/useToast", () => ({
+ toast: mockToast,
+}));
+
+vi.mock("@/services/org_storage", () => ({
+ default: {
+ get: mockGet,
+ create: mockCreate,
+ update: mockUpdate,
+ },
+}));
+
+vi.mock("@/aws-exports", () => ({
+ default: { isCloud: "false", isEnterprise: "true" },
+}));
+
+vi.mock("@/utils/zincutils", async () => {
+ const actual = await vi.importActual("@/utils/zincutils");
+ return {
+ ...actual,
+ getImageURL: (path: string) => `/mock/${path}`,
+ };
+});
+
+const mockStore = {
+ ...store,
+ dispatch: vi.fn(),
+ state: {
+ ...store.state,
+ theme: "light",
+ selectedOrganization: { identifier: "test-org-123" },
+ zoConfig: { ...store.state.zoConfig },
+ },
+};
+
+vi.mock("vuex", () => ({
+ useStore: () => mockStore,
+}));
+
+// OStepper / OStep render their slots so both steps' content is available; the
+// per-provider fields are still gated by selectedProvider (v-if). OForm /
+// OFormInput are real so the provider-discriminated schema actually runs.
+const createWrapper = (action: "add" | "edit" = "add") =>
+ mount(OrgStorageEditor, {
+ props: { action },
+ global: {
+ plugins: [i18n],
+ provide: { store: mockStore },
+ stubs: {
+ OStepper: { template: "
" },
+ OStep: { template: "
" },
+ AppPageHeader: { template: "
" },
+ OIcon: { template: "" },
+ OButton: {
+ template: ``,
+ props: ["type", "variant", "disabled"],
+ emits: ["click"],
+ },
+ },
+ },
+ });
+
+const getForm = (wrapper: any) => wrapper.vm.storageForm.form;
+
+const selectProvider = async (wrapper: any, provider: string) => {
+ await wrapper
+ .find(`[data-test="storage-settings-provider-card-${provider}"]`)
+ .trigger("click");
+ await nextTick();
+};
+
+describe("OrgStorageEditor", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockCreate.mockResolvedValue({ data: {} });
+ mockUpdate.mockResolvedValue({ data: {} });
+ mockGet.mockResolvedValue({ data: { provider: null, data: {} } });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("mounts and shows the provider cards in add mode", () => {
+ const wrapper = createWrapper("add");
+ expect(wrapper.exists()).toBe(true);
+ expect(
+ wrapper
+ .find('[data-test="storage-settings-provider-card-AwsCredentials"]')
+ .exists(),
+ ).toBe(true);
+ expect(
+ wrapper
+ .find('[data-test="storage-settings-provider-card-AwsRoleArn"]')
+ .exists(),
+ ).toBe(true);
+ });
+
+ it("renders the AWS credential fields after selecting the provider", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AwsCredentials");
+
+ expect(
+ wrapper.find('[data-test="storage-settings-bucket-name-input"]').exists(),
+ ).toBe(true);
+ expect(
+ wrapper.find('[data-test="storage-settings-access-key-input"]').exists(),
+ ).toBe(true);
+ expect(
+ wrapper.find('[data-test="storage-settings-secret-key-input"]').exists(),
+ ).toBe(true);
+ });
+
+ it("bridges the selected provider into the form", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AwsCredentials");
+ expect(getForm(wrapper).state.values.selectedProvider).toBe(
+ "AwsCredentials",
+ );
+ });
+
+ describe("Provider-discriminated validation (real OForm)", () => {
+ it("blocks submit and does NOT call create when AWS required fields are empty", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AwsCredentials");
+
+ await getForm(wrapper).handleSubmit();
+ await flushPromises();
+
+ expect(getForm(wrapper).state.isValid).toBe(false);
+ expect(mockCreate).not.toHaveBeenCalled();
+ });
+
+ it("creates the AWS config with the correct payload when valid", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AwsCredentials");
+
+ const form = getForm(wrapper);
+ form.setFieldValue("bucket_name", "my-bucket");
+ form.setFieldValue("region", "us-east-1");
+ form.setFieldValue("access_key", "AKIA123");
+ form.setFieldValue("secret_key", "secret123");
+ await nextTick();
+
+ await form.handleSubmit();
+ await flushPromises();
+
+ expect(mockCreate).toHaveBeenCalledWith("test-org-123", {
+ provider: "AwsCredentials",
+ data: {
+ bucket_name: "my-bucket",
+ server_url: "",
+ region: "us-east-1",
+ access_key: "AKIA123",
+ secret_key: "secret123",
+ },
+ });
+ });
+
+ it("blocks submit when AwsRoleArn is missing role_arn / external_id (restored per-provider rules)", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AwsRoleArn");
+
+ const form = getForm(wrapper);
+ form.setFieldValue("bucket_name", "role-bucket");
+ form.setFieldValue("region", "us-east-1");
+ // role_arn + external_id intentionally left empty
+ await nextTick();
+
+ await form.handleSubmit();
+ await flushPromises();
+
+ expect(form.state.isValid).toBe(false);
+ expect(mockCreate).not.toHaveBeenCalled();
+ });
+
+ it("creates the AwsRoleArn config when all required fields are present", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AwsRoleArn");
+
+ const form = getForm(wrapper);
+ form.setFieldValue("bucket_name", "role-bucket");
+ form.setFieldValue("region", "us-east-1");
+ form.setFieldValue("role_arn", "arn:aws:iam::123:role/x");
+ form.setFieldValue("external_id", "ext-123");
+ await nextTick();
+
+ await form.handleSubmit();
+ await flushPromises();
+
+ expect(mockCreate).toHaveBeenCalledWith("test-org-123", {
+ provider: "AwsRoleArn",
+ data: {
+ bucket_name: "role-bucket",
+ region: "us-east-1",
+ role_arn: "arn:aws:iam::123:role/x",
+ external_id: "ext-123",
+ },
+ });
+ });
+ });
+
+ describe("Azure provider validation (real OForm)", () => {
+ it("blocks submit and does NOT call create when Azure required fields are empty", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AzureCredentials");
+
+ await getForm(wrapper).handleSubmit();
+ await flushPromises();
+
+ expect(getForm(wrapper).state.isValid).toBe(false);
+ expect(mockCreate).not.toHaveBeenCalled();
+ });
+
+ it("creates the Azure config with the correct payload when valid", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AzureCredentials");
+
+ const form = getForm(wrapper);
+ form.setFieldValue("storage_account", "myaccount");
+ form.setFieldValue("bucket_name", "az-bucket");
+ form.setFieldValue("secret_key", "az-secret");
+ await nextTick();
+
+ await form.handleSubmit();
+ await flushPromises();
+
+ expect(mockCreate).toHaveBeenCalledWith("test-org-123", {
+ provider: "AzureCredentials",
+ data: {
+ bucket_name: "az-bucket",
+ server_url: "",
+ storage_account: "myaccount",
+ secret_key: "az-secret",
+ },
+ });
+ });
+ });
+
+ describe("Provider switch reset (④c dependent-field side-effect)", () => {
+ it("clears the previous provider's credentials when the provider changes", async () => {
+ const wrapper = createWrapper("add");
+ await selectProvider(wrapper, "AwsCredentials");
+
+ const form = getForm(wrapper);
+ form.setFieldValue("bucket_name", "aws-bucket");
+ form.setFieldValue("access_key", "AKIA-should-clear");
+ form.setFieldValue("secret_key", "secret-should-clear");
+ await nextTick();
+ // sanity: the credentials are set before the provider switch
+ expect(form.state.values.access_key).toBe("AKIA-should-clear");
+
+ // Switching the provider fires the create-mode watch → form.reset(blank),
+ // wiping the previous provider's credentials (the rulebook's ④c side-effect).
+ await selectProvider(wrapper, "AwsRoleArn");
+ await flushPromises();
+
+ const values = getForm(wrapper).state.values;
+ expect(values.selectedProvider).toBe("AwsRoleArn");
+ expect(values.access_key).toBe("");
+ expect(values.secret_key).toBe("");
+ expect(values.bucket_name).toBe("");
+ });
+ });
+
+ describe("GCP provider validation (schema-level — card not in the grid)", () => {
+ // GCP is intentionally not offered as a provider card (providerDefinitions
+ // comments it out), so it can't be exercised through the UI — but the
+ // superRefine branch still exists, so it is covered directly here.
+ const schema = makeOrgStorageEditorSchema((k: string) => k);
+
+ it("requires bucket_name and access_key for GcpCredentials", () => {
+ const res = schema.safeParse({ selectedProvider: "GcpCredentials" });
+ expect(res.success).toBe(false);
+ const paths = res.success
+ ? []
+ : res.error.issues.map((iss: any) => iss.path.join("."));
+ expect(paths).toContain("bucket_name");
+ expect(paths).toContain("access_key");
+ });
+
+ it("passes for GcpCredentials when bucket_name and access_key are present", () => {
+ const res = schema.safeParse({
+ selectedProvider: "GcpCredentials",
+ bucket_name: "gcp-bucket",
+ access_key: "gcp-key",
+ });
+ expect(res.success).toBe(true);
+ });
+ });
+
+ describe("Edit mode", () => {
+ it("prefills non-credential fields and calls update on submit", async () => {
+ mockGet.mockResolvedValue({
+ data: {
+ provider: "AwsCredentials",
+ data: {
+ bucket_name: "existing-bucket",
+ region: "eu-west-1",
+ server_url: "",
+ },
+ },
+ });
+
+ const wrapper = createWrapper("edit");
+ await flushPromises();
+ await nextTick();
+
+ const form = getForm(wrapper);
+ // Non-credential fields prefilled from the loaded config
+ expect(form.state.values.bucket_name).toBe("existing-bucket");
+ expect(form.state.values.region).toBe("eu-west-1");
+
+ // Credentials must be entered fresh
+ form.setFieldValue("access_key", "new-key");
+ form.setFieldValue("secret_key", "new-secret");
+ await nextTick();
+
+ await form.handleSubmit();
+ await flushPromises();
+
+ expect(mockUpdate).toHaveBeenCalledWith("test-org-123", {
+ provider: "AwsCredentials",
+ data: {
+ bucket_name: "existing-bucket",
+ server_url: "",
+ region: "eu-west-1",
+ access_key: "new-key",
+ secret_key: "new-secret",
+ },
+ });
+ });
+ });
+});
diff --git a/web/src/components/settings/OrgStorageEditor.vue b/web/src/components/settings/OrgStorageEditor.vue
index 024b055dd8..feda697126 100644
--- a/web/src/components/settings/OrgStorageEditor.vue
+++ b/web/src/components/settings/OrgStorageEditor.vue
@@ -34,7 +34,12 @@ along with this program. If not, see .
-
+
.
-
-
-
-
-
@@ -187,40 +189,37 @@ along with this program. If not, see .
-
-
-
-
@@ -407,7 +400,7 @@ along with this program. If not, see .
diff --git a/web/src/components/settings/OrganizationManagement.schema.ts b/web/src/components/settings/OrganizationManagement.schema.ts
new file mode 100644
index 0000000000..e3dba893d8
--- /dev/null
+++ b/web/src/components/settings/OrganizationManagement.schema.ts
@@ -0,0 +1,48 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// Validation schemas for the two self-owned dialogs in OrganizationManagement.vue
+// (meta-org admin): the External Contract dialog and the Extend-Trial dialog.
+//
+// Both dialogs previously gated their POST on imperative JS guards inside the
+// @click:primary handlers (a toast + early-return for a missing contract end
+// date). Those guards are replaced here by Zod rules wired to , so the
+// footer Save + Enter submit through `form-id` and validation is schema-driven.
+//
+// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
+// these schemas only describe WHAT is valid.
+
+import { z } from "zod";
+
+// ── External Contract dialog ─────────────────────────────────────────────────
+// The single date field is required. The message is mode-aware so it reads
+// "End date is required." when creating a new contract and "New end date is
+// required." when extending an existing one (mirrors the old toast guards).
+export const makeContractSchema = (mode: "create" | "extend") =>
+ z.object({
+ contractEndDate: z
+ .string()
+ .min(
+ 1,
+ mode === "create"
+ ? "End date is required."
+ : "New end date is required.",
+ ),
+ });
+
+export type ContractForm = z.infer>;
+
+export const contractDefaults = (): ContractForm => ({ contractEndDate: "" });
+
+// ── Extend-Trial dialog ──────────────────────────────────────────────────────
+// The week count comes from a custom pill grid (1–4), bridged into the form via
+// setFieldValue. It must be at least one week. `z.coerce.number()` because the
+// bridged value can arrive as a string.
+export const extendTrialSchema = z.object({
+ extendedTrial: z.coerce
+ .number()
+ .min(1, "Trial extension must be at least 1 week."),
+});
+
+export type ExtendTrialForm = z.infer;
+
+export const extendTrialDefaults = (): ExtendTrialForm => ({ extendedTrial: 1 });
diff --git a/web/src/components/settings/OrganizationManagement.spec.ts b/web/src/components/settings/OrganizationManagement.spec.ts
index 34325876c4..b7c2a2e4c6 100644
--- a/web/src/components/settings/OrganizationManagement.spec.ts
+++ b/web/src/components/settings/OrganizationManagement.spec.ts
@@ -26,6 +26,7 @@ import {
import { mount, flushPromises } from "@vue/test-utils";
import { createRouter, createWebHistory } from "vue-router";
import OrganizationManagement from "./OrganizationManagement.vue";
+import { extendTrialSchema } from "./OrganizationManagement.schema";
import store from "../../test/unit/helpers/store";
import { createI18n } from "vue-i18n";
import { nextTick } from "vue";
@@ -342,7 +343,8 @@ describe("OrganizationManagement.vue", () => {
wrapper = createWrapper();
expect(wrapper.vm.contractPrompt).toBe(false);
expect(wrapper.vm.contractMode).toBe("create");
- expect(wrapper.vm.contractEndDate).toBe("");
+ // contractEndDate is now form-owned (OFormInput) — the schema default is "".
+ expect(wrapper.vm.contractDefaults()).toEqual({ contractEndDate: "" });
});
});
@@ -1180,22 +1182,16 @@ describe("OrganizationManagement.vue", () => {
expect(wrapper.vm.extendTrialPrompt).toBe(false);
});
- it("should call updateTrialPeriod when primary is clicked on the extend-trial dialog", async () => {
+ it("should call updateTrialPeriod when the extend-trial form is submitted (real OForm)", async () => {
mockExtendTrialPeriod.mockResolvedValue({ data: true });
wrapper = createWrapper();
- wrapper.vm.extendTrialPrompt = true;
- wrapper.vm.extendTrialDataRow = { name: "Acme", identifier: "acme-id" };
+ wrapper.vm.toggleExtendTrialDialog({ name: "Acme", identifier: "acme-id" });
wrapper.vm.extendedTrial = 3;
await nextTick();
- const dialog = wrapper
- .findAll('[data-test="o-dialog-stub"]')
- .find((d: any) =>
- (d.attributes("data-title") || "").startsWith("Extend Trial"),
- );
- expect(dialog?.exists()).toBe(true);
-
- await dialog!.find('[data-test="o-dialog-stub-primary"]').trigger("click");
+ const form = wrapper.findComponent({ name: "OForm" });
+ expect(form.exists()).toBe(true);
+ await (form.vm as any).form.handleSubmit();
await flushPromises();
expect(mockExtendTrialPeriod).toHaveBeenCalledWith(
@@ -1266,24 +1262,23 @@ describe("OrganizationManagement.vue", () => {
expect(wrapper.vm.contractPrompt).toBe(false);
});
- it("should invoke submitContract when primary is clicked", async () => {
+ it("should call create_external_contract when the contract form is submitted with a valid date (real OForm)", async () => {
mockCreateExternalContract?.mockResolvedValue?.({ data: true });
wrapper = createWrapper();
wrapper.vm.toggleContractDialog(
{ name: "Acme", identifier: "acme" },
"create",
);
- wrapper.vm.contractEndDate = "2030-12-31";
await nextTick();
- const dialog = wrapper
- .findAll('[data-test="o-dialog-stub"]')
- .find((d: any) =>
- (d.attributes("data-title") || "").includes("External Contract"),
- );
- expect(dialog?.exists()).toBe(true);
+ // Fill the form-owned date field, then submit through the real OForm.
+ await wrapper
+ .find('[data-test="contract-end-date-input"]')
+ .setValue("2030-12-31");
- await dialog!.find('[data-test="o-dialog-stub-primary"]').trigger("click");
+ const form = wrapper.findComponent({ name: "OForm" });
+ expect(form.exists()).toBe(true);
+ await (form.vm as any).form.handleSubmit();
await flushPromises();
expect(mockCreateExternalContract).toHaveBeenCalledWith(
@@ -1294,9 +1289,8 @@ describe("OrganizationManagement.vue", () => {
});
describe("toggleContractDialog Function", () => {
- it("should open the contract dialog in create mode and reset the end date", () => {
+ it("should open the contract dialog in create mode", () => {
wrapper = createWrapper();
- wrapper.vm.contractEndDate = "2025-01-01";
wrapper.vm.toggleContractDialog(
{ name: "Acme", identifier: "acme" },
"create",
@@ -1308,7 +1302,8 @@ describe("OrganizationManagement.vue", () => {
name: "Acme",
identifier: "acme",
});
- expect(wrapper.vm.contractEndDate).toBe("");
+ // The end date is no longer a component ref — the form re-seeds it to ""
+ // on each open (dialog body remounts), proven by the real-OForm gate tests.
});
it("should open the contract dialog in extend mode", () => {
@@ -1333,43 +1328,40 @@ describe("OrganizationManagement.vue", () => {
store.state.zoConfig.meta_org = "different_org";
});
- it("should warn and return when create mode has no end date", async () => {
+ // Validation is now schema-driven: an empty end date blocks the submit
+ // (the old toast required-guards are gone). Mount the REAL and prove
+ // the schema gates the submit and the service is NOT called.
+ it("blocks the create submit and does not call the service when the end date is empty (real OForm)", async () => {
wrapper = createWrapper();
wrapper.vm.toggleContractDialog(
{ name: "Acme", identifier: "acme" },
"create",
);
- wrapper.vm.contractEndDate = "";
+ await nextTick();
- await wrapper.vm.submitContract();
+ const form = wrapper.findComponent({ name: "OForm" });
+ expect(form.exists()).toBe(true);
+ await (form.vm as any).form.handleSubmit();
await flushPromises();
- expect(mockToastFn).toHaveBeenCalledWith(
- expect.objectContaining({
- variant: "error",
- message: "End date is required.",
- }),
- );
+ expect((form.vm as any).form.state.isValid).toBe(false);
expect(mockCreateExternalContract).not.toHaveBeenCalled();
});
- it("should warn and return when extend mode has no end date", async () => {
+ it("blocks the extend submit and does not call the service when the end date is empty (real OForm)", async () => {
wrapper = createWrapper();
wrapper.vm.toggleContractDialog(
{ name: "Acme", identifier: "acme" },
"extend",
);
- wrapper.vm.contractEndDate = "";
+ await nextTick();
- await wrapper.vm.submitContract();
+ const form = wrapper.findComponent({ name: "OForm" });
+ expect(form.exists()).toBe(true);
+ await (form.vm as any).form.handleSubmit();
await flushPromises();
- expect(mockToastFn).toHaveBeenCalledWith(
- expect.objectContaining({
- variant: "error",
- message: "New end date is required.",
- }),
- );
+ expect((form.vm as any).form.state.isValid).toBe(false);
expect(mockExtendExternalContract).not.toHaveBeenCalled();
});
@@ -1380,9 +1372,8 @@ describe("OrganizationManagement.vue", () => {
{ name: "Acme", identifier: "acme" },
"create",
);
- wrapper.vm.contractEndDate = "2030-12-31";
- await wrapper.vm.submitContract();
+ await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
await flushPromises();
expect(mockCreateExternalContract).toHaveBeenCalledTimes(1);
@@ -1404,8 +1395,7 @@ describe("OrganizationManagement.vue", () => {
{ name: "Acme", identifier: "acme" },
"create",
);
- wrapper.vm.contractEndDate = "2030-12-31";
- await wrapper.vm.submitContract();
+ await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
await flushPromises();
expect(wrapper.vm.contractPrompt).toBe(false);
@@ -1421,9 +1411,8 @@ describe("OrganizationManagement.vue", () => {
{ name: "Acme", identifier: "acme" },
"create",
);
- wrapper.vm.contractEndDate = "2030-12-31";
- await wrapper.vm.submitContract();
+ await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
await flushPromises();
expect(mockToastFn).toHaveBeenCalledWith(
@@ -1443,9 +1432,8 @@ describe("OrganizationManagement.vue", () => {
{ name: "Acme", identifier: "acme" },
"create",
);
- wrapper.vm.contractEndDate = "2030-12-31";
- await wrapper.vm.submitContract();
+ await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
await flushPromises();
expect(mockToastFn).toHaveBeenCalledWith(
@@ -1463,9 +1451,8 @@ describe("OrganizationManagement.vue", () => {
{ name: "Beta", identifier: "beta" },
"extend",
);
- wrapper.vm.contractEndDate = "2030-12-31";
- await wrapper.vm.submitContract();
+ await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
await flushPromises();
expect(mockExtendExternalContract).toHaveBeenCalledTimes(1);
@@ -1484,9 +1471,8 @@ describe("OrganizationManagement.vue", () => {
{ name: "Beta", identifier: "beta" },
"extend",
);
- wrapper.vm.contractEndDate = "2030-12-31";
- await wrapper.vm.submitContract();
+ await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
await flushPromises();
expect(mockToastFn).toHaveBeenCalledWith(
@@ -1917,3 +1903,29 @@ describe("OrganizationManagement.vue", () => {
});
});
});
+
+describe("extendTrialSchema", () => {
+ it("rejects a week count below 1 (the .min(1) rule)", () => {
+ expect(extendTrialSchema.safeParse({ extendedTrial: 0 }).success).toBe(
+ false,
+ );
+ });
+
+ it("coerces a numeric string and still enforces the minimum", () => {
+ // The pill value is bridged in and can arrive as a string.
+ expect(extendTrialSchema.safeParse({ extendedTrial: "0" }).success).toBe(
+ false,
+ );
+ const res = extendTrialSchema.safeParse({ extendedTrial: "2" });
+ expect(res.success).toBe(true);
+ if (res.success) expect(res.data.extendedTrial).toBe(2);
+ });
+
+ it("accepts the in-range pill values 1..4", () => {
+ for (const v of [1, 2, 3, 4]) {
+ expect(extendTrialSchema.safeParse({ extendedTrial: v }).success).toBe(
+ true,
+ );
+ }
+ });
+});
diff --git a/web/src/components/settings/OrganizationManagement.vue b/web/src/components/settings/OrganizationManagement.vue
index 2f8aa5245e..2d17912c44 100644
--- a/web/src/components/settings/OrganizationManagement.vue
+++ b/web/src/components/settings/OrganizationManagement.vue
@@ -149,27 +149,35 @@ along with this program. If not, see .
sub-title="Set the new trial extension period."
:secondary-button-label="t('common.cancel')"
:primary-button-label="`Extend trial by ${extendedTrial} week(s)`"
+ form-id="org-extend-trial-form"
@click:secondary="extendTrialPrompt = false"
- @click:primary="updateTrialPeriod(extendTrialDataRow.identifier, extendedTrial)"
>
-
-
Week(s)
-
-
- {{ page }}
-
+
+
+
Week(s)
+
+
+ {{ page }}
+
+
-
+
@@ -180,25 +188,31 @@ along with this program. If not, see
.
:title="`${contractMode === 'create' ? 'Create' : 'Extend'} External Contract for ${contractDataRow?.name}`"
:secondary-button-label="t('common.cancel')"
:primary-button-label="contractMode === 'create' ? 'Create Contract' : 'Extend Contract'"
+ form-id="org-contract-form"
@click:secondary="contractPrompt = false"
- @click:primary="submitContract"
>
-
-
- {{ contractMode === 'create' ? 'End Date' : 'New End Date' }}
-
-
-
-
- Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }}
-
+
+
+
+
+ Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }}
+
+
@@ -217,7 +231,8 @@ import { useStore } from "vuex";
import { useRouter } from "vue-router";
import OrganizationServices from "@/services/organizations";
import OButton from "@/lib/core/Button/OButton.vue";
-import OInput from "@/lib/forms/Input/OInput.vue";
+import OForm from "@/lib/forms/Form/OForm.vue";
+import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
@@ -230,6 +245,13 @@ import { useConfirmDialog } from "@/composables/useConfirmDialog";
import AppPageHeader from "@/components/common/AppPageHeader.vue";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
+import {
+ makeContractSchema,
+ contractDefaults,
+ extendTrialSchema,
+ type ContractForm,
+ type ExtendTrialForm,
+} from "./OrganizationManagement.schema";
export default defineComponent({
name: "PageAlerts",
@@ -239,7 +261,8 @@ export default defineComponent({
OButton,
ODialog,
OTooltip,
- OInput,
+ OForm,
+ OFormInput,
OSearchInput,
OTable,
},
@@ -261,7 +284,26 @@ export default defineComponent({
const contractPrompt = ref(false);
const contractDataRow = ref({});
const contractMode = ref<"create" | "extend">("create");
- const contractEndDate = ref("");
+
+ // ── Form schemas (Options-API: MUST be returned from setup() or :schema
+ // resolves to undefined and validation silently no-ops). ───────────────
+ // The contract message is mode-aware; the dialog body remounts on open
+ // (reka-ui), so a freshly-mounted always reads the current schema.
+ const contractSchema = computed(() => makeContractSchema(contractMode.value));
+
+ // Extend-trial week count is bridged from the pill grid into the form below.
+ // Dynamic defaults (project the current pill value) → a typed computed.
+ const extendTrialFormRef = ref(null);
+ const extendTrialDefaults = computed(
+ (): ExtendTrialForm => ({ extendedTrial: extendedTrial.value }),
+ );
+
+ // Keep the form's copy of the bridged pill value in sync (the pill grid is a
+ // custom control, not an , so it is bridged via setFieldValue — the
+ // documented sanctioned exception, as CreateDestinationForm does).
+ watch(extendedTrial, (v) => {
+ extendTrialFormRef.value?.form?.setFieldValue("extendedTrial", Number(v));
+ });
onMounted(() => {
if (
@@ -457,21 +499,21 @@ export default defineComponent({
const toggleContractDialog = (row: any, mode: "create" | "extend") => {
contractDataRow.value = row;
contractMode.value = mode;
- contractEndDate.value = "";
+ // No contractEndDate reset needed: the dialog body remounts on open and
+ // re-seeds the field to blank.
contractPrompt.value = true;
};
- const submitContract = () => {
+ // @submit handler — fires only once the schema passes (contractEndDate
+ // required), so the old toast required-guards are gone. Awaited by OForm, so
+ // the footer Save spinner spans the POST automatically.
+ const submitContract = async (value: ContractForm) => {
const metaOrg = store.state.selectedOrganization.identifier;
if (contractMode.value === "create") {
- if (!contractEndDate.value) {
- toast({ variant: "error", message: "End date is required." });
- return;
- }
const payload = {
org_id: contractDataRow.value.identifier,
- end_date: dateToMicros(contractEndDate.value),
+ end_date: dateToMicros(value.contractEndDate),
};
loading.value = true;
@@ -480,7 +522,7 @@ export default defineComponent({
message: "Creating external contract...",
timeout: 0,
});
- OrganizationServices.create_external_contract(metaOrg, payload)
+ return OrganizationServices.create_external_contract(metaOrg, payload)
.then(() => {
toast({
variant: "success",
@@ -503,13 +545,9 @@ export default defineComponent({
});
});
} else {
- if (!contractEndDate.value) {
- toast({ variant: "error", message: "New end date is required." });
- return;
- }
const payload = {
org_id: contractDataRow.value.identifier,
- new_end_date: dateToMicros(contractEndDate.value),
+ new_end_date: dateToMicros(value.contractEndDate),
};
loading.value = true;
@@ -518,7 +556,7 @@ export default defineComponent({
message: "Extending external contract...",
timeout: 0,
});
- OrganizationServices.extend_external_contract(metaOrg, payload)
+ return OrganizationServices.extend_external_contract(metaOrg, payload)
.then(() => {
toast({
variant: "success",
@@ -630,7 +668,7 @@ export default defineComponent({
"Please wait while processing trial period extension request...",
timeout: 0,
});
- OrganizationServices.extend_trial_period(
+ return OrganizationServices.extend_trial_period(
store.state.selectedOrganization.identifier,
payload,
)
@@ -663,6 +701,16 @@ export default defineComponent({
});
};
+ // @submit handler for the extend-trial dialog — awaited by OForm so the
+ // footer Save spinner spans the POST. The week count comes from the
+ // schema-validated form value (bridged from the pill grid).
+ const onExtendTrialSubmit = async (value: ExtendTrialForm) => {
+ return updateTrialPeriod(
+ extendTrialDataRow.value?.identifier,
+ Number(value.extendedTrial),
+ );
+ };
+
const filterData = (rows: string | any[], terms: string) => {
var filtered = [];
terms = terms.toLowerCase();
@@ -704,7 +752,6 @@ export default defineComponent({
contractPrompt,
contractDataRow,
contractMode,
- contractEndDate,
toggleContractDialog,
submitContract,
confirmRevokeContract,
@@ -714,7 +761,15 @@ export default defineComponent({
filterData,
visibleRows,
store,
+ // Form wiring (Options-API: schemas/defaults MUST be returned so :schema
+ // resolves and validation runs).
+ contractSchema,
+ contractDefaults,
+ extendTrialSchema,
+ extendTrialDefaults,
+ extendTrialFormRef,
+ onExtendTrialSubmit,
};
},
});
-
\ No newline at end of file
+
diff --git a/web/src/components/settings/OrganizationSettings.schema.ts b/web/src/components/settings/OrganizationSettings.schema.ts
new file mode 100644
index 0000000000..ef0a1e606c
--- /dev/null
+++ b/web/src/components/settings/OrganizationSettings.schema.ts
@@ -0,0 +1,46 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// Validation schema for OrganizationSettings.vue (Settings > Organization > Log
+// Details). The trace-id / span-id field names are required and must match the
+// allowed character set; the two switches are non-validated form state; the
+// crossLinks composite (CrossLinkManager) has no OForm* equivalent so it stays
+// bare and is merged at submit (declared optional here for documentation).
+//
+// Restores the Quasar BEFORE rule (truthy→Zod inversion):
+// `!!v ? isValidTraceField || 'Use alphanumeric…' : nameRequired`
+// → required (min 1) + regex(/^[a-zA-Z0-9+=,.@_-]+$/).
+//
+// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
+// this file only describes WHAT is valid.
+
+import { z } from "zod";
+
+export const orgSettingsFieldNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/;
+
+const FIELD_FORMAT_MESSAGE =
+ "Use alphanumeric and '+=,.@-_' characters only, without spaces.";
+
+export const makeOrganizationSettingsSchema = (t: (_key: string) => string) =>
+ z.object({
+ // NO .trim(): OForm/TanStack validates with the schema but SAVES the raw value,
+ // so .trim() would let "trace_id " pass yet persist the space (breaks field lookup).
+ // Validate the raw value; the regex already rejects spaces.
+ traceIdFieldName: z
+ .string()
+ .min(1, t("common.nameRequired"))
+ .regex(orgSettingsFieldNameRegex, FIELD_FORMAT_MESSAGE),
+ spanIdFieldName: z
+ .string()
+ .min(1, t("common.nameRequired"))
+ .regex(orgSettingsFieldNameRegex, FIELD_FORMAT_MESSAGE),
+ // Non-validated form state (R1-strict: still form-owned via OFormSwitch).
+ toggleIngestionLogs: z.boolean().optional().default(false),
+ usageStreamEnabled: z.boolean().optional().default(false),
+ // CrossLinkManager is a composite custom control (documented no-OForm*
+ // exception) — kept bare and merged at submit; optional so it never blocks.
+ crossLinks: z.array(z.any()).optional(),
+ });
+
+export type OrganizationSettingsForm = z.infer<
+ ReturnType
+>;
diff --git a/web/src/components/settings/OrganizationSettings.spec.ts b/web/src/components/settings/OrganizationSettings.spec.ts
index 6962ede4c5..0f5f382905 100644
--- a/web/src/components/settings/OrganizationSettings.spec.ts
+++ b/web/src/components/settings/OrganizationSettings.spec.ts
@@ -13,16 +13,14 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { mount } from "@vue/test-utils";
+import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import OrganizationSettings from "./OrganizationSettings.vue";
import i18n from "@/locales";
import { nextTick, reactive } from "vue";
import store from "@/test/unit/helpers/store";
-
// Mock toast from @/lib/feedback/Toast/useToast (replaces Quasar $q.notify)
-// Must use vi.hoisted so the mock factory can access mockToast before it's initialized
const { mockToast } = vi.hoisted(() => ({
mockToast: vi.fn(),
}));
@@ -42,14 +40,14 @@ vi.mock("@/services/organizations", () => ({
}));
import organizations from "@/services/organizations";
-const mockPostOrganizationSettings = organizations.post_organization_settings as any;
+const mockPostOrganizationSettings =
+ organizations.post_organization_settings as any;
// Mock useStore to return our test store
vi.mock("vuex", () => ({
useStore: () => mockStore,
}));
-// Create enhanced mock store with proper dispatch mock
// Use reactive() so watchers in the component can observe state changes
const mockStore = reactive({
...store,
@@ -74,50 +72,24 @@ const mockStore = reactive({
const createWrapper = (props = {}, options = {}) => {
return mount(OrganizationSettings, {
- props: {
- ...props,
- },
+ props: { ...props },
global: {
plugins: [i18n],
- provide: {
- store: mockStore,
- },
+ provide: { store: mockStore },
+ // NOTE: OForm / OFormInput / OFormSwitch are intentionally NOT stubbed so
+ // the real schema validation runs (per the migration playbook: at least
+ // one path must mount the real ).
stubs: {
- OInput: {
- template: ``,
- props: ["modelValue", "label", "error", "errorMessage", "helpText"],
- emits: ["update:modelValue"],
- },
- OSwitch: {
- template: ``,
- props: ["modelValue", "label"],
- emits: ["update:modelValue"],
- },
OButton: {
template: ``,
+ >`,
props: ["variant", "size", "type", "loading"],
emits: ["click"],
},
- OSeparator: {
- template: '
',
- },
+ OSeparator: { template: '
' },
CrossLinkManager: {
template: 'CrossLinkManager
',
props: ["modelValue", "title", "subtitle"],
@@ -130,18 +102,19 @@ const createWrapper = (props = {}, options = {}) => {
});
};
+const getForm = (wrapper: any) => wrapper.findComponent({ name: "OForm" });
+
describe("OrganizationSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
- // Reset store state
mockStore.state.selectedOrganization = {
identifier: "test-org-123",
- label: "Test Organization",
+ label: "Test Organization",
id: 123,
};
mockStore.state.organizationData.organizationSettings = {
trace_id_field_name: "trace_id",
- span_id_field_name: "span_id",
+ span_id_field_name: "span_id",
toggle_ingestion_logs: false,
};
mockPostOrganizationSettings.mockResolvedValue({ status: 200 });
@@ -152,7 +125,7 @@ describe("OrganizationSettings", () => {
vi.clearAllMocks();
});
- describe("Component mounting and initialization", () => {
+ describe("Component mounting and rendering", () => {
it("should mount successfully", () => {
const wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
@@ -161,299 +134,175 @@ describe("OrganizationSettings", () => {
it("should display the settings title", () => {
const wrapper = createWrapper();
const title = wrapper.get("div.text-base.font-bold");
- expect(title.exists()).toBe(true);
expect(title.text()).toContain("Log Details");
});
- it("should initialize traceIdFieldName from store", () => {
+ it("should render trace and span ID field inputs (data-tests preserved)", () => {
const wrapper = createWrapper();
- expect(wrapper.vm.traceIdFieldName).toBe("trace_id");
+ expect(
+ wrapper.find('[data-test="settings-org-trace-id-input"]').exists(),
+ ).toBe(true);
+ expect(
+ wrapper.find('[data-test="settings-org-span-id-input"]').exists(),
+ ).toBe(true);
});
- it("should initialize spanIdFieldName from store", () => {
+ it("should render the ingestion + usage stream toggles (data-tests preserved)", () => {
const wrapper = createWrapper();
- expect(wrapper.vm.spanIdFieldName).toBe("span_id");
+ expect(wrapper.find('[data-test="add-toggle-ingestion"]').exists()).toBe(
+ true,
+ );
+ expect(
+ wrapper.find('[data-test="add-toggle-ingestion-btn"]').exists(),
+ ).toBe(true);
+ expect(
+ wrapper.find('[data-test="add-toggle-usage-stream"]').exists(),
+ ).toBe(true);
+ expect(
+ wrapper.find('[data-test="add-toggle-usage-stream-btn"]').exists(),
+ ).toBe(true);
});
- it("should initialize toggleIngestionLogs from store", () => {
+ it("should render the save button and keep it enabled (R3)", () => {
const wrapper = createWrapper();
- expect(wrapper.vm.toggleIngestionLogs).toBe(false);
+ const saveBtn = wrapper.find('[data-test="add-alert-submit-btn"]');
+ expect(saveBtn.exists()).toBe(true);
+ expect(saveBtn.attributes("disabled")).toBeFalsy();
});
- it("should initialize isValidSpanField as true", () => {
+ it("should seed the form defaults from the store", () => {
const wrapper = createWrapper();
- expect(wrapper.vm.isValidSpanField).toBe(true);
+ const form = getForm(wrapper);
+ expect(form.vm.form.state.values).toMatchObject({
+ traceIdFieldName: "trace_id",
+ spanIdFieldName: "span_id",
+ toggleIngestionLogs: false,
+ usageStreamEnabled: false,
+ });
});
+ });
- it("should initialize isValidTraceField as true", () => {
- const wrapper = createWrapper();
- expect(wrapper.vm.isValidTraceField).toBe(true);
- });
-
- it("should handle missing organizationSettings gracefully", () => {
- mockStore.state.organizationData.organizationSettings = null;
- const wrapper = createWrapper();
- expect(wrapper.vm.traceIdFieldName).toBeUndefined();
- expect(wrapper.vm.spanIdFieldName).toBeUndefined();
- expect(wrapper.vm.toggleIngestionLogs).toBe(false);
- });
-
- it("should use default value for toggleIngestionLogs when not in store", () => {
+ describe("Schema validation (real OForm)", () => {
+ it("blocks submit and does NOT call the service when field names are empty", async () => {
mockStore.state.organizationData.organizationSettings = {
- trace_id_field_name: "trace_id",
+ trace_id_field_name: "",
+ span_id_field_name: "",
+ };
+ const wrapper = createWrapper();
+ const form = getForm(wrapper);
+
+ await form.vm.form.handleSubmit();
+ await flushPromises();
+
+ expect(form.vm.form.state.isValid).toBe(false);
+ expect(mockPostOrganizationSettings).not.toHaveBeenCalled();
+ });
+
+ it("blocks submit when a field name has invalid characters (restored regex rule)", async () => {
+ mockStore.state.organizationData.organizationSettings = {
+ trace_id_field_name: "invalid trace",
+ span_id_field_name: "valid_span",
+ };
+ const wrapper = createWrapper();
+ const form = getForm(wrapper);
+
+ await form.vm.form.handleSubmit();
+ await flushPromises();
+
+ expect(form.vm.form.state.isValid).toBe(false);
+ expect(mockPostOrganizationSettings).not.toHaveBeenCalled();
+ });
+
+ it("blocks submit when a field name has trailing whitespace (regression: no schema .trim to mask it)", async () => {
+ // Before the fix, .trim() validated a trimmed copy so "trace_id " passed
+ // and was saved raw with the space. The regex must now reject it directly.
+ mockStore.state.organizationData.organizationSettings = {
+ trace_id_field_name: "trace_id ",
span_id_field_name: "span_id",
};
const wrapper = createWrapper();
- expect(wrapper.vm.toggleIngestionLogs).toBe(false);
+ const form = getForm(wrapper);
+
+ await form.vm.form.handleSubmit();
+ await flushPromises();
+
+ expect(form.vm.form.state.isValid).toBe(false);
+ expect(mockPostOrganizationSettings).not.toHaveBeenCalled();
});
- it("should initialize toggleIngestionLogs as true when set in store", () => {
- mockStore.state.organizationData.organizationSettings.toggle_ingestion_logs = true;
+ it("submits when both field names are valid", async () => {
const wrapper = createWrapper();
- expect(wrapper.vm.toggleIngestionLogs).toBe(true);
- });
- });
+ const form = getForm(wrapper);
- describe("Form inputs", () => {
- it("should render trace ID field name input", () => {
- const wrapper = createWrapper();
- const traceInput = wrapper.find('.trace-id-field-name input[data-test-stub="o-input"]');
- expect(traceInput.exists()).toBe(true);
- });
+ await form.vm.form.handleSubmit();
+ await flushPromises();
- it("should render span ID field name input", () => {
- const wrapper = createWrapper();
- const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]');
- expect(spanInput.exists()).toBe(true);
- });
-
- it("should render toggle ingestion logs", () => {
- const wrapper = createWrapper();
- const toggleInput = wrapper.find('input[data-test-stub="o-switch"]');
- expect(toggleInput.exists()).toBe(true);
- });
-
- it("should update traceIdFieldName when input changes", async () => {
- const wrapper = createWrapper();
- const traceInput = wrapper.find('.trace-id-field-name input[data-test-stub="o-input"]');
-
- await traceInput.setValue("new_trace_id");
- expect(wrapper.vm.traceIdFieldName).toBe("new_trace_id");
- });
-
- it("should update spanIdFieldName when input changes", async () => {
- const wrapper = createWrapper();
- const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]');
-
- await spanInput.setValue("new_span_id");
- expect(wrapper.vm.spanIdFieldName).toBe("new_span_id");
- });
-
- it("should update toggleIngestionLogs when toggle changes", async () => {
- const wrapper = createWrapper();
- const toggle = wrapper.find('input[data-test-stub="o-switch"]');
-
- await toggle.setChecked(true);
- expect(wrapper.vm.toggleIngestionLogs).toBe(true);
- });
-
- it("should trim whitespace from trace ID input", async () => {
- const wrapper = createWrapper();
- const traceInput = wrapper.find('.trace-id-field-name input[data-test-stub="o-input"]');
-
- await traceInput.setValue(" trace_with_spaces ");
- expect(wrapper.vm.traceIdFieldName).toBe("trace_with_spaces");
- });
-
- it("should trim whitespace from span ID input", async () => {
- const wrapper = createWrapper();
- const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]');
-
- await spanInput.setValue(" span_with_spaces ");
- expect(wrapper.vm.spanIdFieldName).toBe("span_with_spaces");
- });
- });
-
- describe("Validation functions", () => {
- it("should validate field names with valid characters", () => {
- const wrapper = createWrapper();
- expect(wrapper.vm.validateFieldName("valid_field-name123")).toBe(true);
- expect(wrapper.vm.validateFieldName("test+field@company.com")).toBe(true);
- expect(wrapper.vm.validateFieldName("field.name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field,name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field=value")).toBe(true);
- });
-
- it("should reject field names with invalid characters", () => {
- const wrapper = createWrapper();
- expect(wrapper.vm.validateFieldName("field name")).toBe(false); // space
- expect(wrapper.vm.validateFieldName("field#name")).toBe(false); // hash
- expect(wrapper.vm.validateFieldName("field$name")).toBe(false); // dollar
- expect(wrapper.vm.validateFieldName("field%name")).toBe(false); // percent
- expect(wrapper.vm.validateFieldName("field&name")).toBe(false); // ampersand
- });
-
- it("should validate empty field names as invalid", () => {
- const wrapper = createWrapper();
- expect(wrapper.vm.validateFieldName("")).toBe(false);
- });
-
- it("should validate field names with special allowed characters", () => {
- const wrapper = createWrapper();
- expect(wrapper.vm.validateFieldName("field+name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field=name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field,name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field.name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field@name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field_name")).toBe(true);
- expect(wrapper.vm.validateFieldName("field-name")).toBe(true);
- });
-
- it("should handle null or undefined in validateFieldName", () => {
- const wrapper = createWrapper();
- // The regex.test() method converts null/undefined to string, so they return true
- // This tests the actual behavior of the original component
- expect(wrapper.vm.validateFieldName(null)).toBe(true);
- expect(wrapper.vm.validateFieldName(undefined)).toBe(true);
- });
- });
-
- describe("isValidRoleName computed property", () => {
- it("should return true for valid trace ID field name", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "valid_trace_id";
- await nextTick();
- expect(wrapper.vm.isValidRoleName).toBe(true);
- });
-
- it("should return false for invalid trace ID field name", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "invalid trace id";
- await nextTick();
- expect(wrapper.vm.isValidRoleName).toBe(false);
- });
-
- it("should return false for empty trace ID field name", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "";
- await nextTick();
- expect(wrapper.vm.isValidRoleName).toBe(false);
- });
-
- it("should react to changes in traceIdFieldName", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "valid_trace";
- await nextTick();
- expect(wrapper.vm.isValidRoleName).toBe(true);
-
- wrapper.vm.traceIdFieldName = "invalid trace";
- await nextTick();
- expect(wrapper.vm.isValidRoleName).toBe(false);
- });
- });
-
- describe("updateFieldName function", () => {
- it("should update isValidSpanField for span field", async () => {
- const wrapper = createWrapper();
- wrapper.vm.spanIdFieldName = "valid_span_id";
-
- wrapper.vm.updateFieldName("span");
- expect(wrapper.vm.isValidSpanField).toBe(true);
- });
-
- it("should update isValidSpanField to false for invalid span field", async () => {
- const wrapper = createWrapper();
- wrapper.vm.spanIdFieldName = "invalid span id";
-
- wrapper.vm.updateFieldName("span");
- expect(wrapper.vm.isValidSpanField).toBe(false);
- });
-
- it("should update isValidTraceField for trace field", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "valid_trace_id";
-
- wrapper.vm.updateFieldName("trace");
- expect(wrapper.vm.isValidTraceField).toBe(true);
- });
-
- it("should update isValidTraceField to false for invalid trace field", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "invalid trace id";
-
- wrapper.vm.updateFieldName("trace");
- expect(wrapper.vm.isValidTraceField).toBe(false);
- });
-
- it("should not update validation flags for unknown field type", () => {
- const wrapper = createWrapper();
- const initialSpanValid = wrapper.vm.isValidSpanField;
- const initialTraceValid = wrapper.vm.isValidTraceField;
-
- wrapper.vm.updateFieldName("unknown");
- expect(wrapper.vm.isValidSpanField).toBe(initialSpanValid);
- expect(wrapper.vm.isValidTraceField).toBe(initialTraceValid);
- });
-
- it("should be called when span input changes", async () => {
- const wrapper = createWrapper();
- const spanInput = wrapper.find('.span-id-field-name input[data-test-stub="o-input"]');
-
- // Set spy on updateFieldName
- const updateFieldNameSpy = vi.spyOn(wrapper.vm, 'updateFieldName');
-
- await spanInput.setValue('new_span');
-
- // The template calls updateFieldName('span') on @update:model-value
- // Since we're testing the function exists and works, let's call it directly
- wrapper.vm.updateFieldName('span');
- expect(updateFieldNameSpy).toHaveBeenCalledWith('span');
- });
- });
-
- describe("saveOrgSettings function", () => {
- it("should save organization settings successfully", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "custom_trace_id";
- wrapper.vm.spanIdFieldName = "custom_span_id";
- wrapper.vm.toggleIngestionLogs = true;
-
- await wrapper.vm.saveOrgSettings();
-
- expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
- "test-org-123",
- {
- trace_id_field_name: "custom_trace_id",
- span_id_field_name: "custom_span_id",
- toggle_ingestion_logs: true,
- cross_links: [],
- usage_stream_enabled: false,
- }
- );
- });
-
- it("should dispatch setOrganizationSettings after successful save", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "new_trace";
- wrapper.vm.spanIdFieldName = "new_span";
- wrapper.vm.toggleIngestionLogs = false;
-
- await wrapper.vm.saveOrgSettings();
-
- expect(mockStore.dispatch).toHaveBeenCalledWith("setOrganizationSettings", {
- ...mockStore.state.organizationData.organizationSettings,
- trace_id_field_name: "new_trace",
- span_id_field_name: "new_span",
+ expect(mockPostOrganizationSettings).toHaveBeenCalledWith("test-org-123", {
+ trace_id_field_name: "trace_id",
+ span_id_field_name: "span_id",
toggle_ingestion_logs: false,
cross_links: [],
usage_stream_enabled: false,
});
});
- it("should show success notification after successful save", async () => {
+ it("submits the toggled switch values", async () => {
const wrapper = createWrapper();
+ const form = getForm(wrapper);
+ form.vm.form.setFieldValue("toggleIngestionLogs", true);
+ form.vm.form.setFieldValue("usageStreamEnabled", true);
- await wrapper.vm.saveOrgSettings();
+ await form.vm.form.handleSubmit();
+ await flushPromises();
+
+ expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
+ "test-org-123",
+ expect.objectContaining({
+ toggle_ingestion_logs: true,
+ usage_stream_enabled: true,
+ }),
+ );
+ });
+ });
+
+ describe("saveOrgSettings handler", () => {
+ const validValue = {
+ traceIdFieldName: "custom_trace_id",
+ spanIdFieldName: "custom_span_id",
+ toggleIngestionLogs: true,
+ usageStreamEnabled: false,
+ };
+
+ it("should call post_organization_settings with the correct payload", async () => {
+ const wrapper = createWrapper();
+ await wrapper.vm.saveOrgSettings(validValue);
+
+ expect(mockPostOrganizationSettings).toHaveBeenCalledWith("test-org-123", {
+ trace_id_field_name: "custom_trace_id",
+ span_id_field_name: "custom_span_id",
+ toggle_ingestion_logs: true,
+ cross_links: [],
+ usage_stream_enabled: false,
+ });
+ });
+
+ it("should dispatch setOrganizationSettings after a successful save", async () => {
+ const wrapper = createWrapper();
+ await wrapper.vm.saveOrgSettings(validValue);
+
+ expect(mockStore.dispatch).toHaveBeenCalledWith("setOrganizationSettings", {
+ ...mockStore.state.organizationData.organizationSettings,
+ trace_id_field_name: "custom_trace_id",
+ span_id_field_name: "custom_span_id",
+ toggle_ingestion_logs: true,
+ cross_links: [],
+ usage_stream_enabled: false,
+ });
+ });
+
+ it("should show a success notification", async () => {
+ const wrapper = createWrapper();
+ await wrapper.vm.saveOrgSettings(validValue);
expect(mockToast).toHaveBeenCalledWith({
message: "Organization settings updated successfully",
@@ -461,38 +310,23 @@ describe("OrganizationSettings", () => {
});
});
- it("should handle API error with message", async () => {
+ it("should handle an API error with a message", async () => {
const wrapper = createWrapper();
- const errorMessage = "Invalid field names";
- mockPostOrganizationSettings.mockRejectedValue({
- message: errorMessage,
- });
+ mockPostOrganizationSettings.mockRejectedValue({ message: "Invalid field names" });
- await wrapper.vm.saveOrgSettings();
+ await wrapper.vm.saveOrgSettings(validValue);
expect(mockToast).toHaveBeenCalledWith({
- message: errorMessage,
+ message: "Invalid field names",
variant: "error",
});
});
- it("should handle API error without message", async () => {
+ it("should fall back to a default message when the API error has none", async () => {
const wrapper = createWrapper();
mockPostOrganizationSettings.mockRejectedValue({});
- await wrapper.vm.saveOrgSettings();
-
- expect(mockToast).toHaveBeenCalledWith({
- message: "Error saving organization settings",
- variant: "error",
- });
- });
-
- it("should handle API error with non-object error", async () => {
- const wrapper = createWrapper();
- mockPostOrganizationSettings.mockRejectedValue("Network error");
-
- await wrapper.vm.saveOrgSettings();
+ await wrapper.vm.saveOrgSettings(validValue);
expect(mockToast).toHaveBeenCalledWith({
message: "Error saving organization settings",
@@ -504,289 +338,18 @@ describe("OrganizationSettings", () => {
const wrapper = createWrapper();
mockPostOrganizationSettings.mockRejectedValue({ message: "Error" });
- await wrapper.vm.saveOrgSettings();
+ await wrapper.vm.saveOrgSettings(validValue);
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
});
- describe("Button interactions", () => {
- it.skip("should render cancel button", () => {
- // Cancel button is currently commented out in the component
- const wrapper = createWrapper();
- const cancelBtn = wrapper.find('[data-test="add-alert-cancel-btn"]');
- expect(cancelBtn.exists()).toBe(true);
- });
-
- it("should render save button", () => {
- const wrapper = createWrapper();
- const saveBtn = wrapper.find('[data-test="add-alert-submit-btn"]');
- expect(saveBtn.exists()).toBe(true);
- });
-
- it("should call saveOrgSettings when save button is clicked", async () => {
- const wrapper = createWrapper();
-
- // Mock the saveOrgSettings method
- const saveOrgSettingsSpy = vi.spyOn(wrapper.vm, 'saveOrgSettings').mockImplementation(() => Promise.resolve());
-
- // Since we're using stubs, we need to manually call the method that would be called by the template
- // In the real component, clicking the button calls saveOrgSettings
- await wrapper.vm.saveOrgSettings();
-
- expect(saveOrgSettingsSpy).toHaveBeenCalled();
- });
-
- it.skip("should emit cancel event when cancel button is clicked", async () => {
- const wrapper = createWrapper();
- const cancelBtn = wrapper.find('[data-test="add-alert-cancel-btn"]');
-
- await cancelBtn.trigger("click");
-
- expect(wrapper.emitted('cancel:hideform')).toBeTruthy();
- });
- });
-
- describe("Form validation rules", () => {
- it("should have validation rules for trace ID field", () => {
- const wrapper = createWrapper();
- const traceInput = wrapper.findComponent({ name: 'OInput' });
-
- if (traceInput.exists()) {
- expect(traceInput.props('error')).toBeDefined();
- expect(traceInput.props('errorMessage')).toBeDefined();
- }
- });
-
- it("should validate required trace ID field", () => {
- const wrapper = createWrapper();
- const traceInput = wrapper.find('.trace-id-field-name');
-
- // Simulate the validation rule logic
- wrapper.vm.traceIdFieldName = "";
- wrapper.vm.isValidTraceField = false;
-
- // The rule should return the required message for empty value
- expect(wrapper.vm.traceIdFieldName).toBe("");
- });
-
- it("should validate trace ID field format", () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "invalid trace";
- wrapper.vm.isValidTraceField = false;
-
- // Should indicate invalid format
- expect(wrapper.vm.isValidTraceField).toBe(false);
- });
-
- it("should validate span ID field format", () => {
- const wrapper = createWrapper();
- wrapper.vm.spanIdFieldName = "invalid span";
- wrapper.vm.isValidSpanField = false;
-
- // Should indicate invalid format
- expect(wrapper.vm.isValidSpanField).toBe(false);
- });
-
- it("should pass validation for valid field names", () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "valid_trace_id";
- wrapper.vm.spanIdFieldName = "valid_span_id";
- wrapper.vm.isValidTraceField = true;
- wrapper.vm.isValidSpanField = true;
-
- expect(wrapper.vm.isValidTraceField).toBe(true);
- expect(wrapper.vm.isValidSpanField).toBe(true);
- });
- });
-
- describe("Component layout and styling", () => {
- it("should have correct CSS classes for trace ID input", () => {
- const wrapper = createWrapper();
- const traceDiv = wrapper.find('.trace-id-field-name');
- expect(traceDiv.exists()).toBe(true);
- expect(traceDiv.classes()).toContain('o2-input');
- });
-
- it("should have correct CSS classes for span ID input", () => {
- const wrapper = createWrapper();
- const spanDiv = wrapper.find('.span-id-field-name');
- expect(spanDiv.exists()).toBe(true);
- expect(spanDiv.classes()).toContain('o2-input');
- });
-
- it("should apply scoped styling for field widths", () => {
- const wrapper = createWrapper();
- // The component should have scoped styles for 400px width
- // This is tested by verifying the style section exists in the component
- expect(wrapper.find('.trace-id-field-name').exists()).toBe(true);
- expect(wrapper.find('.span-id-field-name').exists()).toBe(true);
- });
- });
-
- describe("Edge cases and error scenarios", () => {
- it("should handle store with undefined selectedOrganization", async () => {
- const wrapper = createWrapper();
- mockStore.state.selectedOrganization = undefined;
-
- // Should handle gracefully and use fallback or throw appropriate error
- try {
- await wrapper.vm.saveOrgSettings();
-
- // If it doesn't throw, verify it handles undefined gracefully
- expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
- undefined,
- expect.any(Object)
- );
- } catch (error) {
- // If it throws an error, that's also valid behavior
- expect(error).toBeDefined();
- }
- });
-
- it("should handle store with null organizationSettings", () => {
- mockStore.state.organizationData.organizationSettings = null;
- const wrapper = createWrapper();
-
- expect(wrapper.vm.traceIdFieldName).toBeUndefined();
- expect(wrapper.vm.spanIdFieldName).toBeUndefined();
- expect(wrapper.vm.toggleIngestionLogs).toBe(false);
- });
-
- it("should handle very long field names", async () => {
- const wrapper = createWrapper();
- const longFieldName = "a".repeat(1000);
-
- wrapper.vm.traceIdFieldName = longFieldName;
- wrapper.vm.spanIdFieldName = longFieldName;
-
- await wrapper.vm.saveOrgSettings();
-
- expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
- "test-org-123",
- expect.objectContaining({
- trace_id_field_name: longFieldName,
- span_id_field_name: longFieldName,
- })
- );
- });
-
- it("should handle special characters in organization identifier", async () => {
- mockStore.state.selectedOrganization.identifier = "org-with-special@chars+123";
- const wrapper = createWrapper();
-
- await wrapper.vm.saveOrgSettings();
-
- expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
- "org-with-special@chars+123",
- expect.any(Object)
- );
- });
-
- it("should handle network timeout error", async () => {
- const wrapper = createWrapper();
- mockPostOrganizationSettings.mockRejectedValue({
- message: "Network timeout",
- code: "TIMEOUT"
- });
-
- await wrapper.vm.saveOrgSettings();
-
- expect(mockToast).toHaveBeenCalledWith({
- message: "Network timeout",
- variant: "error",
- });
- });
- });
-
- describe("Integration tests", () => {
- it("should perform complete form submission flow", async () => {
- const wrapper = createWrapper();
-
- // Update form fields
- wrapper.vm.traceIdFieldName = "integration_trace_id";
- wrapper.vm.spanIdFieldName = "integration_span_id";
- wrapper.vm.toggleIngestionLogs = true;
-
- await nextTick();
-
- // Validate fields
- wrapper.vm.updateFieldName("span");
- wrapper.vm.updateFieldName("trace");
-
- expect(wrapper.vm.isValidSpanField).toBe(true);
- expect(wrapper.vm.isValidTraceField).toBe(true);
-
- // Submit form
- await wrapper.vm.saveOrgSettings();
-
- // Verify all expected calls
- expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
- "test-org-123",
- {
- trace_id_field_name: "integration_trace_id",
- span_id_field_name: "integration_span_id",
- toggle_ingestion_logs: true,
- cross_links: [],
- usage_stream_enabled: false,
- }
- );
-
- expect(mockStore.dispatch).toHaveBeenCalledWith("setOrganizationSettings", {
- ...mockStore.state.organizationData.organizationSettings,
- trace_id_field_name: "integration_trace_id",
- span_id_field_name: "integration_span_id",
- toggle_ingestion_logs: true,
- cross_links: [],
- usage_stream_enabled: false,
- });
-
- expect(mockToast).toHaveBeenCalledWith({
- message: "Organization settings updated successfully",
- variant: "success",
- });
- });
-
- it("should handle form submission with validation errors", async () => {
- const wrapper = createWrapper();
-
- // Set invalid field names
- wrapper.vm.traceIdFieldName = "invalid trace";
- wrapper.vm.spanIdFieldName = "invalid span";
-
- wrapper.vm.updateFieldName("trace");
- wrapper.vm.updateFieldName("span");
-
- expect(wrapper.vm.isValidTraceField).toBe(false);
- expect(wrapper.vm.isValidSpanField).toBe(false);
-
- // The component now validates before saving and returns early on invalid data
- await wrapper.vm.saveOrgSettings();
-
- expect(mockPostOrganizationSettings).not.toHaveBeenCalled();
- });
- });
-
describe("Usage stream settings", () => {
- it("should render usage stream toggle", () => {
- const wrapper = createWrapper();
- const usageToggle = wrapper.find('[data-test="add-toggle-usage-stream"] input[data-test-stub="o-switch"]');
- expect(usageToggle.exists()).toBe(true);
- });
-
- it("should update usageStreamEnabled when toggle changes", async () => {
- const wrapper = createWrapper();
-
- const usageToggle = wrapper.find('[data-test="add-toggle-usage-stream"] input[data-test-stub="o-switch"]');
- await usageToggle.setChecked(true);
-
- expect(wrapper.vm.usageStreamEnabled).toBe(true);
- });
-
- it("should initialize usageStreamEnabled from store", () => {
+ it("should seed usageStreamEnabled from the store", () => {
mockStore.state.organizationData.organizationSettings.usage_stream_enabled = true;
const wrapper = createWrapper();
- expect(wrapper.vm.usageStreamEnabled).toBe(true);
+ const form = getForm(wrapper);
+ expect(form.vm.form.state.values.usageStreamEnabled).toBe(true);
});
});
@@ -801,79 +364,56 @@ describe("OrganizationSettings", () => {
it("should render CrossLinkManager when cross-linking is enabled", () => {
const wrapper = createWrapper();
- const crossLinkManager = wrapper.find('[data-test="cross-link-manager"]');
- expect(crossLinkManager.exists()).toBe(true);
+ expect(wrapper.find('[data-test="cross-link-manager"]').exists()).toBe(
+ true,
+ );
});
- it("should update crossLinks when the watch triggers", async () => {
+ it("should update crossLinks when the store watch triggers", async () => {
const wrapper = createWrapper();
- // Mutate the store's cross_links array to a new reference to trigger the watcher
mockStore.state.organizationData.organizationSettings = {
...mockStore.state.organizationData.organizationSettings,
cross_links: [{ sourceField: "trace_id", targetOrg: "other-org" }],
};
-
await nextTick();
- // The watch should have updated crossLinks ref from the store
expect(wrapper.vm.crossLinks).toEqual([
{ sourceField: "trace_id", targetOrg: "other-org" },
]);
});
- });
- describe("Validation errors during save", () => {
- it("should set traceIdFieldNameError when trace name is empty", async () => {
+ it("should include crossLinks in the submit payload", async () => {
+ mockStore.state.organizationData.organizationSettings = {
+ trace_id_field_name: "trace_id",
+ span_id_field_name: "span_id",
+ toggle_ingestion_logs: false,
+ cross_links: [{ sourceField: "trace_id", targetOrg: "other-org" }],
+ };
const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "";
- wrapper.vm.spanIdFieldName = "valid_span";
+ const form = getForm(wrapper);
- await wrapper.vm.saveOrgSettings();
+ await form.vm.form.handleSubmit();
+ await flushPromises();
- expect(wrapper.vm.traceIdFieldNameError).not.toBe("");
- });
-
- it("should set spanIdFieldNameError when span name is empty", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "valid_trace";
- wrapper.vm.spanIdFieldName = "";
-
- await wrapper.vm.saveOrgSettings();
-
- expect(wrapper.vm.spanIdFieldNameError).not.toBe("");
- });
-
- it("should not call API when both fields are empty", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "";
- wrapper.vm.spanIdFieldName = "";
-
- await wrapper.vm.saveOrgSettings();
-
- expect(mockPostOrganizationSettings).not.toHaveBeenCalled();
- });
-
- it("should set format error when trace name is invalid", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "invalid trace";
- wrapper.vm.isValidTraceField = false;
- wrapper.vm.spanIdFieldName = "valid_span";
-
- await wrapper.vm.saveOrgSettings();
-
- expect(wrapper.vm.traceIdFieldNameError).not.toBe("");
- });
-
- it("should set format error when span name is invalid", async () => {
- const wrapper = createWrapper();
- wrapper.vm.traceIdFieldName = "valid_trace";
- wrapper.vm.spanIdFieldName = "invalid span";
- wrapper.vm.isValidSpanField = false;
-
- await wrapper.vm.saveOrgSettings();
-
- expect(wrapper.vm.spanIdFieldNameError).not.toBe("");
+ expect(mockPostOrganizationSettings).toHaveBeenCalledWith(
+ "test-org-123",
+ expect.objectContaining({
+ cross_links: [{ sourceField: "trace_id", targetOrg: "other-org" }],
+ }),
+ );
});
});
-});
\ No newline at end of file
+
+ describe("Layout and styling", () => {
+ it("should keep the trace/span field width wrappers", () => {
+ const wrapper = createWrapper();
+ const traceDiv = wrapper.find(".trace-id-field-name");
+ const spanDiv = wrapper.find(".span-id-field-name");
+ expect(traceDiv.exists()).toBe(true);
+ expect(traceDiv.classes()).toContain("o2-input");
+ expect(spanDiv.exists()).toBe(true);
+ expect(spanDiv.classes()).toContain("o2-input");
+ });
+ });
+});
diff --git a/web/src/components/settings/OrganizationSettings.vue b/web/src/components/settings/OrganizationSettings.vue
index e232c677df..26473e983e 100644
--- a/web/src/components/settings/OrganizationSettings.vue
+++ b/web/src/components/settings/OrganizationSettings.vue
@@ -8,20 +8,25 @@
-
+
-
@@ -29,31 +34,29 @@
data-test="add-role-rolename-input-btn"
class="span-id-field-name o2-input w-100"
>
-
-
-
@@ -82,10 +85,10 @@
variant="primary"
size="sm-action"
type="submit"
- @click="saveOrgSettings"
+ :loading="isSubmitting"
>{{ t('alerts.save') }}
-
+
@@ -96,40 +99,42 @@ import organizations from "@/services/organizations";
import { useStore } from "vuex";
import CrossLinkManager from "@/components/cross-linking/CrossLinkManager.vue";
import OButton from "@/lib/core/Button/OButton.vue";
-import OInput from "@/lib/forms/Input/OInput.vue";
-import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
+import OForm from "@/lib/forms/Form/OForm.vue";
+import OFormInput from "@/lib/forms/Input/OFormInput.vue";
+import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import config from "@/aws-exports";
import { toast } from "@/lib/feedback/Toast/useToast";
+import {
+ makeOrganizationSettingsSchema,
+ type OrganizationSettingsForm,
+} from "./OrganizationSettings.schema";
const { t } = useI18n();
const store = useStore();
-const traceIdFieldName = ref(
- store.state?.organizationData?.organizationSettings?.trace_id_field_name,
-);
+// Schema-driven validation replaces the hand-rolled validate()/error refs.
+const organizationSettingsSchema = makeOrganizationSettingsSchema(t);
-const spanIdFieldName = ref(
- store.state?.organizationData?.organizationSettings?.span_id_field_name,
-);
-
-
-const isValidSpanField = ref(true);
-const isValidTraceField = ref(true);
-const traceIdFieldNameError = ref('');
-const spanIdFieldNameError = ref('');
-const toggleIngestionLogs = ref(
- store.state?.organizationData?.organizationSettings?.toggle_ingestion_logs ||
- false,
-);
-const usageStreamEnabled = ref(
- store.state?.organizationData?.organizationSettings?.usage_stream_enabled ||
- false,
-);
+// CrossLinkManager has no OForm* equivalent (composite) — kept as local state
+// and merged at submit (the documented exception).
const crossLinks = ref(
store.state?.organizationData?.organizationSettings?.cross_links || [],
);
+const formDirty = ref(false);
+
+// Dynamic defaults (edit-prefill from the store) → a typed computed. The trace/
+// span/toggle values are form-owned (OFormInput / OFormSwitch).
+const organizationSettingsDefaults = computed((): OrganizationSettingsForm => {
+ const s = store.state?.organizationData?.organizationSettings;
+ return {
+ traceIdFieldName: s?.trace_id_field_name ?? "",
+ spanIdFieldName: s?.span_id_field_name ?? "",
+ toggleIngestionLogs: s?.toggle_ingestion_logs ?? false,
+ usageStreamEnabled: s?.usage_stream_enabled ?? false,
+ };
+});
watch(
() => store.state?.organizationData?.organizationSettings?.cross_links,
@@ -139,56 +144,18 @@ watch(
}
},
);
-const formDirty = ref(false);
-const isValidRoleName = computed(() => {
- const roleNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/;
- // Check if the role name is valid
- return roleNameRegex.test(traceIdFieldName.value);
-});
-
-const validateFieldName = (value: string) => {
- const roleNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/;
- // Check if the role name is valid
- return roleNameRegex.test(value);
-};
-
-const updateFieldName = (fieldName: string) => {
- if (fieldName === "span")
- isValidSpanField.value = validateFieldName(spanIdFieldName.value);
-
- if (fieldName === "trace")
- isValidTraceField.value = validateFieldName(traceIdFieldName.value);
-};
-
-const validateOrgSettings = () => {
- let valid = true;
- if (!traceIdFieldName.value) {
- traceIdFieldNameError.value = t('common.nameRequired');
- valid = false;
- } else if (!isValidTraceField.value) {
- traceIdFieldNameError.value = `Use alphanumeric and '+=,.@-_' characters only, without spaces.`;
- valid = false;
- }
- if (!spanIdFieldName.value) {
- spanIdFieldNameError.value = t('common.nameRequired');
- valid = false;
- } else if (!isValidSpanField.value) {
- spanIdFieldNameError.value = `Use alphanumeric and '+=,.@-_' characters only, without spaces.`;
- valid = false;
- }
- return valid;
-};
-
-const saveOrgSettings = async () => {
- if (!validateOrgSettings()) return;
+// @submit fires only once the schema passes (both field names required + regex),
+// so the old validateOrgSettings()/error refs are gone. Awaited by OForm so the
+// inline Save button's spinner spans the POST.
+const saveOrgSettings = async (value: OrganizationSettingsForm) => {
try {
const payload: any = {
- trace_id_field_name: traceIdFieldName.value,
- span_id_field_name: spanIdFieldName.value,
- toggle_ingestion_logs: toggleIngestionLogs.value,
+ trace_id_field_name: value.traceIdFieldName,
+ span_id_field_name: value.spanIdFieldName,
+ toggle_ingestion_logs: value.toggleIngestionLogs,
cross_links: crossLinks.value,
- usage_stream_enabled: usageStreamEnabled.value,
+ usage_stream_enabled: value.usageStreamEnabled,
};
await organizations.post_organization_settings(
@@ -198,11 +165,11 @@ const saveOrgSettings = async () => {
const updatedSettings: any = {
...store.state?.organizationData?.organizationSettings,
- trace_id_field_name: traceIdFieldName.value,
- span_id_field_name: spanIdFieldName.value,
- toggle_ingestion_logs: toggleIngestionLogs.value,
+ trace_id_field_name: value.traceIdFieldName,
+ span_id_field_name: value.spanIdFieldName,
+ toggle_ingestion_logs: value.toggleIngestionLogs,
cross_links: crossLinks.value,
- usage_stream_enabled: usageStreamEnabled.value,
+ usage_stream_enabled: value.usageStreamEnabled,
};
store.dispatch("setOrganizationSettings", updatedSettings);
@@ -220,4 +187,13 @@ const saveOrgSettings = async () => {
});
}
};
+
+// Exposed for unit tests that exercise the submit handler directly.
+defineExpose({
+ crossLinks,
+ formDirty,
+ saveOrgSettings,
+ organizationSettingsSchema,
+ organizationSettingsDefaults,
+});
diff --git a/web/src/locales/languages/de.json b/web/src/locales/languages/de.json
index 2dadba086e..19cd097940 100644
--- a/web/src/locales/languages/de.json
+++ b/web/src/locales/languages/de.json
@@ -3951,6 +3951,8 @@
"saveChanges": "Speichern",
"invalidDomain": "Bitte geben Sie einen gültigen Domainnamen ein",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse für diese Domain ein",
+ "domainRequired": "Die Domain ist erforderlich",
+ "emailRequired": "Die E-Mail-Adresse ist erforderlich",
"domainAlreadyExists": "Diese Domain wurde bereits hinzugefügt",
"domainAdded": "Domain wurde erfolgreich hinzugefügt",
"confirmRemoveDomain": "Möchten Sie die Domain '{domain}' wirklich entfernen?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "Passen Sie die Einstellungen für Ihre Anwendung an",
"platformSettings": "Plattform-Einstellungen",
"scrapeIntervalRequired": "Das Scrape-Intervall ist erforderlich",
+ "scrapeIntervalPositive": "Das Scrape-Intervall muss eine positive Zahl sein",
"scrapeIntervalDescription": "Das Scrape-Intervall ist die Häufigkeit in Sekunden, mit der das Überwachungssystem Metriken erfasst.",
"maxSeriesPerQueryLabel": "Max. Serien pro Abfrage",
"maxSeriesPerQueryDescription": "Maximale Anzahl von Serien, die von einer einzelnen PromQL-Abfrage zurückgegeben werden können. Hilft, das Einfrieren der Benutzeroberfläche bei großen Ergebnismengen zu verhindern. Lassen Sie das Feld leer, um den Systemstandard (40.000) zu verwenden.",
diff --git a/web/src/locales/languages/en.json b/web/src/locales/languages/en.json
index fe634dd43f..c164edcf71 100644
--- a/web/src/locales/languages/en.json
+++ b/web/src/locales/languages/en.json
@@ -3955,6 +3955,8 @@
"saveChanges": "Save",
"invalidDomain": "Please enter a valid domain name",
"invalidEmail": "Please enter a valid email address for this domain",
+ "domainRequired": "Domain is required",
+ "emailRequired": "Email is required",
"domainAlreadyExists": "This domain has already been added",
"domainAdded": "Domain added successfully",
"confirmRemoveDomain": "Are you sure you want to remove the domain '{domain}'?",
@@ -3971,6 +3973,7 @@
"pageSubtitle": "Adjust settings for your Application",
"platformSettings": "Platform Settings",
"scrapeIntervalRequired": "Scrape interval is required",
+ "scrapeIntervalPositive": "Scrape interval must be a positive number",
"scrapeIntervalDescription": "The scrape interval is the frequency, in seconds, at which the monitoring system collects metrics.",
"maxSeriesPerQueryLabel": "Max Series Per Query",
"maxSeriesPerQueryDescription": "Maximum number of series that can be returned from a single PromQL query. Helps prevent UI freezing with large result sets. Leave empty to use system default (40,000).",
diff --git a/web/src/locales/languages/es.json b/web/src/locales/languages/es.json
index 1b902e1d96..8db1ecc1d1 100644
--- a/web/src/locales/languages/es.json
+++ b/web/src/locales/languages/es.json
@@ -3951,6 +3951,8 @@
"saveChanges": "Guardar",
"invalidDomain": "Introduce un nombre de dominio válido",
"invalidEmail": "Introduce una dirección de correo electrónico válida para este dominio",
+ "domainRequired": "Se requiere el dominio",
+ "emailRequired": "Se requiere el correo electrónico",
"domainAlreadyExists": "Este dominio ya se ha agregado",
"domainAdded": "El dominio se agregó correctamente",
"confirmRemoveDomain": "¿Estás seguro de que quieres eliminar el dominio '{domain}'?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "Ajusta la configuración de tu aplicación",
"platformSettings": "Configuración de plataforma",
"scrapeIntervalRequired": "Se requiere un intervalo de raspado",
+ "scrapeIntervalPositive": "El intervalo de raspado debe ser un número positivo",
"scrapeIntervalDescription": "El intervalo de raspado es la frecuencia, en segundos, con la que el sistema de monitoreo recopila las métricas.",
"maxSeriesPerQueryLabel": "Número máximo de series por consulta",
"maxSeriesPerQueryDescription": "Número máximo de series que se pueden devolver desde una sola consulta de PromQL. Ayuda a evitar que la interfaz de usuario se bloquee con conjuntos de resultados grandes. Déjelo vacío para usar los valores predeterminados del sistema (40 000).",
diff --git a/web/src/locales/languages/fr.json b/web/src/locales/languages/fr.json
index 4200a05d82..71c555edf3 100644
--- a/web/src/locales/languages/fr.json
+++ b/web/src/locales/languages/fr.json
@@ -3951,6 +3951,8 @@
"saveChanges": "Enregistrer",
"invalidDomain": "Veuillez saisir un nom de domaine valide",
"invalidEmail": "Veuillez saisir une adresse e-mail valide pour ce domaine",
+ "domainRequired": "Le domaine est requis",
+ "emailRequired": "L'adresse e-mail est requise",
"domainAlreadyExists": "Ce domaine a déjà été ajouté",
"domainAdded": "Domaine ajouté avec succès",
"confirmRemoveDomain": "Êtes-vous sûr de vouloir supprimer le domaine « {domain} » ?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "Ajustez les paramètres de votre application",
"platformSettings": "Paramètres de la plateforme",
"scrapeIntervalRequired": "Un intervalle de raclage est requis",
+ "scrapeIntervalPositive": "L'intervalle de raclage doit être un nombre positif",
"scrapeIntervalDescription": "L'intervalle de scrape est la fréquence, en secondes, à laquelle le système de surveillance collecte les métriques.",
"maxSeriesPerQueryLabel": "Nombre maximum de séries par requête",
"maxSeriesPerQueryDescription": "Nombre maximum de séries pouvant être renvoyées à partir d'une seule requête ProMQL. Aide à prévenir le gel de l'interface utilisateur en cas de grands ensembles de résultats. Laissez ce champ vide pour utiliser la valeur par défaut du système (40 000).",
diff --git a/web/src/locales/languages/hi.json b/web/src/locales/languages/hi.json
index d7c1e01dc3..112d19995d 100644
--- a/web/src/locales/languages/hi.json
+++ b/web/src/locales/languages/hi.json
@@ -2981,6 +2981,8 @@
"saveChanges": "Save",
"invalidDomain": "Please enter a valid domain name",
"invalidEmail": "Please enter a valid email address for this domain",
+ "domainRequired": "Domain is required",
+ "emailRequired": "Email is required",
"domainAlreadyExists": "This domain has already been added",
"domainAdded": "Domain added successfully",
"confirmRemoveDomain": "Are you sure you want to remove the domain '{domain}'?",
@@ -2997,6 +2999,7 @@
"pageSubtitle": "Adjust settings for your Application",
"platformSettings": "Platform Settings",
"scrapeIntervalRequired": "Scrape interval is required",
+ "scrapeIntervalPositive": "Scrape interval must be a positive number",
"scrapeIntervalDescription": "The scrape interval is the frequency, in seconds, at which the monitoring system collects metrics.",
"maxSeriesPerQueryLabel": "Max Series Per Query",
"maxSeriesPerQueryDescription": "Maximum number of series that can be returned from a single PromQL query. Helps prevent UI freezing with large result sets. Leave empty to use system default (40,000).",
diff --git a/web/src/locales/languages/it.json b/web/src/locales/languages/it.json
index 0b651b6d22..720c9c0058 100644
--- a/web/src/locales/languages/it.json
+++ b/web/src/locales/languages/it.json
@@ -3951,6 +3951,8 @@
"saveChanges": "Salva",
"invalidDomain": "Inserisci un nome di dominio valido",
"invalidEmail": "Inserisci un indirizzo email valido per questo dominio",
+ "domainRequired": "È richiesto il dominio",
+ "emailRequired": "È richiesto l'indirizzo email",
"domainAlreadyExists": "Questo dominio è già stato aggiunto",
"domainAdded": "Dominio aggiunto con successo",
"confirmRemoveDomain": "Sei sicuro di voler rimuovere il dominio '{domain}'?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "Modifica le impostazioni per la tua applicazione",
"platformSettings": "Impostazioni della piattaforma",
"scrapeIntervalRequired": "È richiesto l'intervallo di raschiatura",
+ "scrapeIntervalPositive": "L'intervallo di raschiatura deve essere un numero positivo",
"scrapeIntervalDescription": "L'intervallo di scrape è la frequenza, in secondi, con cui il sistema di monitoraggio raccoglie le metriche.",
"maxSeriesPerQueryLabel": "Serie massima per query",
"maxSeriesPerQueryDescription": "Numero massimo di serie che possono essere restituite da una singola query ProMQL. Aiuta a prevenire il blocco dell'interfaccia utente con set di risultati di grandi dimensioni. Lascia vuoto per utilizzare l'impostazione predefinita del sistema (40.000).",
diff --git a/web/src/locales/languages/ja.json b/web/src/locales/languages/ja.json
index 8035ab0741..f70d26e040 100644
--- a/web/src/locales/languages/ja.json
+++ b/web/src/locales/languages/ja.json
@@ -3951,6 +3951,8 @@
"saveChanges": "[保存]",
"invalidDomain": "有効なドメイン名を入力してください",
"invalidEmail": "このドメインの有効なメールアドレスを入力してください",
+ "domainRequired": "ドメインが必要です",
+ "emailRequired": "メールアドレスが必要です",
"domainAlreadyExists": "このドメインは既に追加されています",
"domainAdded": "ドメインは正常に追加されました",
"confirmRemoveDomain": "ドメイン '{domain}' を削除してよろしいですか?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "アプリケーションの設定を調整する",
"platformSettings": "プラットフォーム設定",
"scrapeIntervalRequired": "スクレイプ間隔が必要です",
+ "scrapeIntervalPositive": "スクレイプ間隔は正の数でなければなりません",
"scrapeIntervalDescription": "スクレイプ間隔は、モニタリングシステムがメトリックを収集する頻度(秒単位)です。",
"maxSeriesPerQueryLabel": "クエリあたりの最大シリーズ数",
"maxSeriesPerQueryDescription": "1 つの PromQL クエリから返されるシリーズの最大数。結果セットが大きい場合に UI がフリーズするのを防ぐのに役立ちます。システムデフォルト (40,000) を使用するには空のままにします。",
@@ -5328,13 +5331,13 @@
"loading": "オンライン評価設定をロード中...",
"loadError": "オンライン評価を読み込めませんでした",
"deletePrompt": "{name} を削除しますか?",
- "deleteTitle": "{ラベル} を削除",
+ "deleteTitle": "{label} を削除",
"deleteConfirmMessage": "「{name}」を削除してよろしいですか?このアクションは元に戻せません。",
- "deleted": "{ラベル} は削除されました",
+ "deleted": "{label} は削除されました",
"deleteError": "{label} を削除できませんでした",
"saved": "{label} が保存されました",
- "newButton": "新規 {ラベル}",
- "search": "検索 {ラベル}",
+ "newButton": "新規 {label}",
+ "search": "検索 {label}",
"refresh": "リフレッシュ",
"allStatuses": "すべてのステータス",
"noRowsFound": "{label} が見つかりません",
diff --git a/web/src/locales/languages/ko.json b/web/src/locales/languages/ko.json
index dfe5c9b93f..d91bf80f59 100644
--- a/web/src/locales/languages/ko.json
+++ b/web/src/locales/languages/ko.json
@@ -3951,6 +3951,8 @@
"saveChanges": "저장",
"invalidDomain": "유효한 도메인 이름을 입력하세요",
"invalidEmail": "이 도메인의 유효한 이메일 주소를 입력하십시오.",
+ "domainRequired": "도메인이 필요합니다.",
+ "emailRequired": "이메일 주소가 필요합니다.",
"domainAlreadyExists": "이 도메인은 이미 추가되었습니다",
"domainAdded": "도메인이 성공적으로 추가되었습니다.",
"confirmRemoveDomain": "'{domain}' 도메인을 정말로 제거하시겠습니까?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "애플리케이션 설정 조정",
"platformSettings": "플랫폼 설정",
"scrapeIntervalRequired": "스크래핑 간격이 필요합니다.",
+ "scrapeIntervalPositive": "스크래핑 간격은 양수여야 합니다.",
"scrapeIntervalDescription": "스크랩 간격은 모니터링 시스템이 메트릭을 수집하는 빈도 (초) 입니다.",
"maxSeriesPerQueryLabel": "쿼리당 최대 시리즈 수",
"maxSeriesPerQueryDescription": "단일 PromQL 쿼리에서 반환할 수 있는 최대 시리즈 수입니다.결과 집합이 클 때 UI가 멈추는 것을 방지하는 데 도움이 됩니다.시스템 기본값 (40,000) 을 사용하려면 비워 두십시오.",
@@ -5328,16 +5331,16 @@
"loading": "온라인 평가 구성을 로드하는 중...",
"loadError": "온라인 평가를 로드하지 못했습니다.",
"deletePrompt": "{name} 을 (를) 삭제하시겠습니까?",
- "deleteTitle": "{라벨} 삭제",
+ "deleteTitle": "{label} 삭제",
"deleteConfirmMessage": "정말로 “{name}”을 삭제하시겠습니까?이 작업은 취소할 수 없습니다.",
- "deleted": "{라벨} 삭제됨",
+ "deleted": "{label} 삭제됨",
"deleteError": "{label} 을 (를) 삭제하지 못했습니다.",
- "saved": "{라벨} 저장됨",
- "newButton": "새 {라벨}",
- "search": "{라벨} 검색",
+ "saved": "{label} 저장됨",
+ "newButton": "새 {label}",
+ "search": "{label} 검색",
"refresh": "새로 고침",
"allStatuses": "모든 상태",
- "noRowsFound": "{라벨} 을 (를) 찾을 수 없습니다.",
+ "noRowsFound": "{label} 을 (를) 찾을 수 없습니다.",
"getStartedHint": "하나를 생성하여 온라인 배선 평가를 시작하십시오.",
"columns": {
"name": "이름",
diff --git a/web/src/locales/languages/nl.json b/web/src/locales/languages/nl.json
index f4a3ed6b39..4d0db8e455 100644
--- a/web/src/locales/languages/nl.json
+++ b/web/src/locales/languages/nl.json
@@ -3951,6 +3951,8 @@
"saveChanges": "Opslaan",
"invalidDomain": "Voer een geldige domeinnaam in",
"invalidEmail": "Voer een geldig e-mailadres in voor dit domein",
+ "domainRequired": "Domein is vereist",
+ "emailRequired": "E-mailadres is vereist",
"domainAlreadyExists": "Dit domein is al toegevoegd",
"domainAdded": "Domein succesvol toegevoegd",
"confirmRemoveDomain": "Weet je zeker dat je het domein '{domain}' wilt verwijderen?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "Instellingen voor uw toepassing aanpassen",
"platformSettings": "Platform-instellingen",
"scrapeIntervalRequired": "Schraapinterval is vereist",
+ "scrapeIntervalPositive": "Het schraapinterval moet een positief getal zijn",
"scrapeIntervalDescription": "Het schraapinterval is de frequentie, in seconden, waarmee het bewakingssysteem statistieken verzamelt.",
"maxSeriesPerQueryLabel": "Max. aantal reeksen per zoekopdracht",
"maxSeriesPerQueryDescription": "Maximaal aantal series dat kan worden geretourneerd uit een enkele ProMQL-query. Helpt bij grote resultatensets te voorkomen dat de gebruikersinterface vastloopt. Laat dit veld leeg om de systeemstandaard te gebruiken (40.000).",
diff --git a/web/src/locales/languages/pt.json b/web/src/locales/languages/pt.json
index 63355d3bab..412b616633 100644
--- a/web/src/locales/languages/pt.json
+++ b/web/src/locales/languages/pt.json
@@ -3951,6 +3951,8 @@
"saveChanges": "Salvar",
"invalidDomain": "Insira um nome de domínio válido",
"invalidEmail": "Insira um endereço de e-mail válido para este domínio",
+ "domainRequired": "O domínio é necessário",
+ "emailRequired": "O e-mail é necessário",
"domainAlreadyExists": "Este domínio já foi adicionado",
"domainAdded": "Domínio adicionado com sucesso",
"confirmRemoveDomain": "Tem certeza de que deseja remover o domínio '{domain}'?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "Ajuste as configurações do seu aplicativo",
"platformSettings": "Configurações da plataforma",
"scrapeIntervalRequired": "O intervalo de raspagem é necessário",
+ "scrapeIntervalPositive": "O intervalo de raspagem deve ser um número positivo",
"scrapeIntervalDescription": "O intervalo de raspagem é a frequência, em segundos, na qual o sistema de monitoramento coleta métricas.",
"maxSeriesPerQueryLabel": "Série máxima por consulta",
"maxSeriesPerQueryDescription": "Número máximo de séries que podem ser retornadas de uma única consulta ProMQL. Ajuda a evitar o congelamento da interface com grandes conjuntos de resultados. Deixe em branco para usar o padrão do sistema (40.000).",
diff --git a/web/src/locales/languages/tr.json b/web/src/locales/languages/tr.json
index 87ddd86451..85dd7fe13d 100644
--- a/web/src/locales/languages/tr.json
+++ b/web/src/locales/languages/tr.json
@@ -3951,6 +3951,8 @@
"saveChanges": "Kaydet",
"invalidDomain": "Lütfen geçerli bir alan adı girin",
"invalidEmail": "Lütfen bu alan adı için geçerli bir e-posta adresi girin",
+ "domainRequired": "Alan adı gereklidir",
+ "emailRequired": "E-posta adresi gereklidir",
"domainAlreadyExists": "Bu alan adı zaten eklendi",
"domainAdded": "Etki alanı başarıyla eklendi",
"confirmRemoveDomain": "'{ domain} 'alan adını kaldırmak istediğinizden emin misiniz?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "Uygulamanızın ayarlarını ayarlama",
"platformSettings": "Platform Ayarları",
"scrapeIntervalRequired": "Kazıma aralığı gereklidir",
+ "scrapeIntervalPositive": "Kazıma aralığı pozitif bir sayı olmalıdır",
"scrapeIntervalDescription": "Kazıma aralığı, izleme sisteminin metrikleri topladığı saniye cinsinden sıklıktır.",
"maxSeriesPerQueryLabel": "Sorgu Başına Maksimum Seri",
"maxSeriesPerQueryDescription": "Tek bir PromQL sorgusundan döndürülebilecek maksimum seri sayısı. Büyük sonuç kümeleriyle kullanıcı arayüzünün donmasını önlemeye yardımcı olur. Sistem varsayılanını kullanmak için boş bırakın (40.000).",
diff --git a/web/src/locales/languages/zh.json b/web/src/locales/languages/zh.json
index 91aa8ab376..69be764ee5 100644
--- a/web/src/locales/languages/zh.json
+++ b/web/src/locales/languages/zh.json
@@ -3951,6 +3951,8 @@
"saveChanges": "保存",
"invalidDomain": "请输入有效的域名",
"invalidEmail": "请输入该域的有效电子邮件地址",
+ "domainRequired": "需要域名",
+ "emailRequired": "需要电子邮件地址",
"domainAlreadyExists": "此域已被添加",
"domainAdded": "域名已成功添加",
"confirmRemoveDomain": "您确定要移除域名 '{domain}' 吗?",
@@ -3967,6 +3969,7 @@
"pageSubtitle": "调整应用程序的设置",
"platformSettings": "平台设置",
"scrapeIntervalRequired": "需要抓取间隔",
+ "scrapeIntervalPositive": "抓取间隔必须为正数",
"scrapeIntervalDescription": "抓取间隔是监控系统收集指标的频率,以秒为单位。",
"maxSeriesPerQueryLabel": "每个查询的最大序列数",
"maxSeriesPerQueryDescription": "单个 PromQL 查询可以返回的最大序列数。使用大型结果集有助于防止界面冻结。留空以使用系统默认值 (40,000)。",
@@ -5241,7 +5244,7 @@
"nameTooLong": "型号名称必须少于 256 个字符",
"patternRequired": "匹配模式为必填项",
"patternTooLong": "匹配模式必须少于 512 个字符",
- "invalidRegex": "无效的正则表达式:{错误}",
+ "invalidRegex": "无效的正则表达式:{error}",
"usageKeyPureInteger": "用法键不能是纯整数",
"usageKeyContainsSpaces": "使用密钥不得包含空格",
"tierPriceMissingKey": "等级 “{name}” 的价格没有使用密钥。添加使用密钥(例如 “输入”)或使用快速设置模板。",
@@ -5327,17 +5330,17 @@
"description": "配置 LLM 提供商、分数定义、评分器和实时评测任务。",
"loading": "正在加载在线评估配置...",
"loadError": "加载在线评估失败",
- "deletePrompt": "删除 {姓名}?",
- "deleteTitle": "删除 {标签}",
+ "deletePrompt": "删除 {name}?",
+ "deleteTitle": "删除 {label}",
"deleteConfirmMessage": "您确定要删除 “{name}” 吗?此操作无法撤消。",
"deleted": "{label} 已删除",
"deleteError": "删除 {label} 失败",
"saved": "{label} 已保存",
- "newButton": "新的 {标签}",
- "search": "搜索 {标签}",
+ "newButton": "新的 {label}",
+ "search": "搜索 {label}",
"refresh": "刷新",
"allStatuses": "所有状态",
- "noRowsFound": "未找到 {标签}",
+ "noRowsFound": "未找到 {label}",
"getStartedHint": "创建一个,开始接线在线评估。",
"columns": {
"name": "姓名",
diff --git a/web/src/locales/languages/zh_TW.json b/web/src/locales/languages/zh_TW.json
index c838186c22..baf289ecc5 100644
--- a/web/src/locales/languages/zh_TW.json
+++ b/web/src/locales/languages/zh_TW.json
@@ -3329,6 +3329,8 @@
"saveChanges": "儲存",
"invalidDomain": "請輸入有效的網域名稱",
"invalidEmail": "請輸入此網域的有效電子郵件地址",
+ "domainRequired": "網域為必填",
+ "emailRequired": "電子郵件地址為必填",
"domainAlreadyExists": "此網域已被新增",
"domainAdded": "網域新增成功",
"confirmRemoveDomain": "確定要移除網域 '{domain}' 嗎?",
@@ -3345,6 +3347,7 @@
"pageSubtitle": "調整應用程式的設定",
"platformSettings": "平台設定",
"scrapeIntervalRequired": "擷取間隔為必填",
+ "scrapeIntervalPositive": "擷取間隔必須為正數",
"scrapeIntervalDescription": "擷取間隔是監控系統收集指標的頻率 (以秒為單位)。",
"maxSeriesPerQueryLabel": "每次查詢最大系列數",
"maxSeriesPerQueryDescription": "單一 PromQL 查詢可傳回的最大系列數。有助於防止大型結果集導致 UI 凍結。留空以使用系統預設值 (40,000)。",