fix: form migration with zod for management module (#12877)

- form migration with zod schema for management module

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
This commit is contained in:
ktx-mihir
2026-07-09 06:59:45 +00:00
committed by GitHub
co-authored by github-actions[bot] Claude Fable 5 Abhay Padamani
parent be8e0adda3
commit 232642bef2
38 changed files with 3130 additions and 2092 deletions
@@ -305,6 +305,24 @@ export class ModelPricingPage {
}, { timeout: 5000 }).toBe(true);
}
/** Fill a tier's "add price" draft row WITHOUT committing it (no Add click).
* Used to exercise the save-time draft-key guard: an uncommitted key with
* spaces or a pure-integer key produces a toast at submit. (Committing the
* row instead surfaces an inline per-row error via the schema.)
* @param {string} key
* @param {number|string} valuePricePerMillion
* @param {number} [tierIndex=0]
*/
async fillDraftPriceRow(key, valuePricePerMillion, tierIndex = 0) {
const keyInput = this.page.locator(`[data-test="model-pricing-add-price-key-input-${tierIndex}-field"]`);
await keyInput.waitFor({ state: 'visible', timeout: 5000 });
await keyInput.fill(key);
const valueInput = this.page.locator(`[data-test="model-pricing-add-price-value-input-${tierIndex}-field"]`);
await valueInput.waitFor({ state: 'visible', timeout: 5000 });
await valueInput.fill(String(valuePricePerMillion));
}
async clickSave() {
await this.saveBtn.click();
await Promise.race([
@@ -176,20 +176,27 @@ test.describe("Model Pricing — Create & Form Validation", () => {
const pm = new PageManager(page);
await pm.modelPricingPage.gotoEditorCreate();
// Both fields empty → save disabled
await expect(pm.modelPricingPage.saveBtn).toBeDisabled({ timeout: 5000 });
// Save is a plain submit button — the OForm has no disabled-gate and
// validates submit-then-change, so field errors surface only after the
// first submit attempt (not on load / first keystroke).
await expect(pm.modelPricingPage.saveBtn).toBeEnabled();
// Name too long → inline error; fix → error clears
// Both fields empty → the first submit arms validation: required errors
// appear and the editor stays open (submit is blocked).
await pm.modelPricingPage.saveBtn.click();
await expect(pm.modelPricingPage.nameInputError).toBeVisible({ timeout: 5000 });
await expect(pm.modelPricingPage.patternInputError).toBeVisible({ timeout: 5000 });
await pm.modelPricingPage.editorTitle.waitFor({ state: 'visible', timeout: 5000 });
// Name too long → inline error; fix → error clears (revalidates on change)
await pm.modelPricingPage.fillName('a'.repeat(257));
await expect(pm.modelPricingPage.nameInputError).toBeVisible({ timeout: 5000 });
await expect(pm.modelPricingPage.saveBtn).toBeDisabled();
await pm.modelPricingPage.fillName(validName);
await expect(pm.modelPricingPage.nameInputError).not.toBeVisible({ timeout: 5000 });
// Invalid regex → inline error; fix → error clears
await pm.modelPricingPage.fillPattern('[invalid(');
await expect(pm.modelPricingPage.patternInputError).toBeVisible({ timeout: 5000 });
await expect(pm.modelPricingPage.saveBtn).toBeDisabled();
await pm.modelPricingPage.fillPattern(`^${validName}.*`);
await expect(pm.modelPricingPage.patternInputError).not.toBeVisible({ timeout: 5000 });
@@ -197,13 +204,19 @@ test.describe("Model Pricing — Create & Form Validation", () => {
await pm.modelPricingPage.saveBtn.click();
await pm.modelPricingPage.editorTitle.waitFor({ state: 'visible', timeout: 5000 });
// Key with spaces → toast error (filter by text — earlier toasts may still be visible)
await pm.modelPricingPage.addPriceRow('input tokens', '0.000002');
// Draft key with spaces → save-time toast. The "Add price" button now
// commits any non-empty key (bad keys get an inline row error instead),
// so key validation as a TOAST fires via the save() draft-row guard on an
// uncommitted draft. Leaving it uncommitted also keeps the form clean for
// the successful save below. (Filter by text — earlier toasts may linger.)
await pm.modelPricingPage.fillDraftPriceRow('input tokens', '0.000002');
await pm.modelPricingPage.saveBtn.click();
await expect(pm.modelPricingPage.toastMessage.filter({ hasText: /must not contain spaces/i }))
.toBeVisible({ timeout: 5000 });
// Pure integer key → toast error
await pm.modelPricingPage.addPriceRow('123', '0.000002');
// Draft key that is a pure integer → save-time toast
await pm.modelPricingPage.fillDraftPriceRow('123', '0.000002');
await pm.modelPricingPage.saveBtn.click();
await expect(pm.modelPricingPage.toastMessage.filter({ hasText: /cannot be a pure integer/i }))
.toBeVisible({ timeout: 5000 });
@@ -295,24 +295,25 @@ test.describe("Settings ModelPricingEditor form validation", () => {
testLogger.info('Navigated to Settings > Model Pricing');
});
test("should disable save or show error when model pricing name is empty", {
test("should show error when model pricing name is empty", {
tag: ['@domainFormValidation', '@P0', '@smoke']
}, async ({ page }) => {
testLogger.info('Verifying save is disabled or error shown when model pricing name is empty');
testLogger.info('Verifying name error shown when model pricing name is empty');
// Clear the name field to trigger validation
// Clear the name field
await pm.settingsFormValidation.clearModelPricingName();
const saveBtn = pm.settingsFormValidation.getModelPricingSaveBtnLocator();
// Attempt save to trigger validation. The OForm validates submit-then-change,
// so the required-field error surfaces only after the first submit attempt
// (the Save button itself is never disabled by validity).
await pm.settingsFormValidation.clickModelPricingSave();
const nameError = pm.settingsFormValidation.getModelPricingNameErrorLocator();
await nameError.waitFor({ state: 'visible', timeout: 5000 });
await expect(nameError).toBeVisible();
await expect(nameError).toContainText('Model name is required');
// Either the save button is disabled OR an error message is shown
const saveDisabled = await saveBtn.isDisabled().catch(() => false);
const errorVisible = await nameError.isVisible().catch(() => false);
expect(saveDisabled || errorVisible).toBe(true);
testLogger.info('Save button disabled or name error shown for empty model pricing name');
testLogger.info('Name error correctly shown when empty model pricing name');
});
test("should show pattern error when pattern is empty", {
@@ -16,6 +16,7 @@
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import DomainManagement from "./DomainManagement.vue";
import { isValidDomain, isValidEmail } from "./DomainManagement.schema";
import i18n from "@/locales";
import { nextTick } from "vue";
import {
@@ -207,8 +208,7 @@ describe("DomainManagement Integration Tests", () => {
expect(vm.domains.length).toBe(3); // From mockDomainData.complex
// 2. Add a new domain
vm.newDomain = "newcompany.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "newcompany.com" });
expect(vm.domains.length).toBe(4);
expect(vm.domains[3].name).toBe("newcompany.com");
expect(vm.domains[3].allowAllUsers).toBe(true);
@@ -217,12 +217,10 @@ describe("DomainManagement Integration Tests", () => {
vm.domains[3].allowAllUsers = false;
// 4. Add emails to restricted domain
vm.domains[3].newEmail = "admin@newcompany.com";
await vm.addEmail(vm.domains[3]);
await vm.addEmail(vm.domains[3], "admin@newcompany.com");
expect(vm.domains[3].allowedEmails).toContain("admin@newcompany.com");
vm.domains[3].newEmail = "user@newcompany.com";
await vm.addEmail(vm.domains[3]);
await vm.addEmail(vm.domains[3], "user@newcompany.com");
expect(vm.domains[3].allowedEmails).toContain("user@newcompany.com");
expect(vm.domains[3].allowedEmails.length).toBe(2);
@@ -288,7 +286,7 @@ describe("DomainManagement Integration Tests", () => {
domainValidationTestCases.forEach(({ input, expected, description }) => {
it(`should ${expected ? 'accept' : 'reject'} ${description}: "${input}"`, () => {
const vm = wrapper.vm;
expect(vm.isValidDomain(input)).toBe(expected);
expect(isValidDomain(input)).toBe(expected);
});
});
});
@@ -297,7 +295,7 @@ describe("DomainManagement Integration Tests", () => {
emailValidationTestCases.forEach(({ email, domain, expected, description }) => {
it(`should ${expected ? 'accept' : 'reject'} ${description}`, () => {
const vm = wrapper.vm;
expect(vm.isValidEmail(email, domain)).toBe(expected);
expect(isValidEmail(email, domain)).toBe(expected);
});
});
});
@@ -339,8 +337,7 @@ describe("DomainManagement Integration Tests", () => {
const initialState = JSON.parse(JSON.stringify(vm.domains));
// Add domain
vm.newDomain = "test.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "test.com" });
// Verify state change
expect(vm.domains.length).toBe(initialState.length + 1);
@@ -380,44 +377,39 @@ describe("DomainManagement Integration Tests", () => {
expect(wrapper.text()).toContain(vm.domains.length.toString());
// Add a domain and check count update
vm.newDomain = "test.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "test.com" });
await nextTick();
expect(wrapper.text()).toContain(vm.domains.length.toString());
});
it("should enable/disable buttons based on validation", async () => {
it("keeps the add-domain button always enabled (R3 — validation is schema-driven)", async () => {
wrapper = createWrapper();
await flushPromises();
const vm = wrapper.vm;
// Test add domain button state
vm.newDomain = "";
await nextTick();
// Try to find the add domain button with a simpler selector
const addDomainButton = wrapper.find('button');
const buttons = wrapper.findAll('button');
// Look for button with "Add Domain" text
let addButton = null;
for (let i = 0; i < buttons.length; i++) {
if (buttons[i].text().includes('Add Domain')) {
addButton = buttons[i];
break;
const findAddButton = () => {
const buttons = wrapper.findAll("button");
for (let i = 0; i < buttons.length; i++) {
if (buttons[i].text().includes("Add Domain")) return buttons[i];
}
}
return null;
};
// Empty input → button stays enabled (no :disabled gate); the schema
// blocks the actual submit instead.
vm.addDomainForm.form.setFieldValue("newDomain", "");
await nextTick();
let addButton = findAddButton();
if (addButton) {
expect(addButton.element.disabled).toBe(true);
expect(addButton.element.disabled).toBe(false);
}
// Set valid domain
vm.newDomain = "valid.com";
// Valid domain → still enabled.
vm.addDomainForm.form.setFieldValue("newDomain", "valid.com");
await nextTick();
addButton = findAddButton();
if (addButton) {
expect(addButton.element.disabled).toBe(false);
}
@@ -473,7 +465,7 @@ describe("DomainManagement Integration Tests", () => {
];
for (const maliciousDomain of maliciousDomains) {
expect(vm.isValidDomain(maliciousDomain)).toBe(false);
expect(isValidDomain(maliciousDomain)).toBe(false);
}
});
@@ -488,7 +480,7 @@ describe("DomainManagement Integration Tests", () => {
];
for (const maliciousEmail of maliciousEmails) {
expect(vm.isValidEmail(maliciousEmail, "example.com")).toBe(false);
expect(isValidEmail(maliciousEmail, "example.com")).toBe(false);
}
});
@@ -497,15 +489,15 @@ describe("DomainManagement Integration Tests", () => {
const longDomain = "a".repeat(250) + ".com"; // Domain names should be max 253 chars
const veryLongDomain = "a".repeat(300) + ".com";
expect(vm.isValidDomain(longDomain)).toBe(false);
expect(vm.isValidDomain(veryLongDomain)).toBe(false);
expect(isValidDomain(longDomain)).toBe(false);
expect(isValidDomain(veryLongDomain)).toBe(false);
});
it("should reject extremely long email addresses", async () => {
const vm = wrapper.vm;
const longEmail = "a".repeat(300) + "@example.com"; // Email addresses have practical limits
expect(vm.isValidEmail(longEmail, "example.com")).toBe(false);
expect(isValidEmail(longEmail, "example.com")).toBe(false);
});
it("should reject Unicode and special character exploits", async () => {
@@ -519,7 +511,7 @@ describe("DomainManagement Integration Tests", () => {
];
for (const unicodeDomain of unicodeExploits) {
expect(vm.isValidDomain(unicodeDomain)).toBe(false);
expect(isValidDomain(unicodeDomain)).toBe(false);
}
});
});
@@ -553,15 +545,13 @@ describe("DomainManagement Integration Tests", () => {
name: "test.com",
allowAllUsers: false,
allowedEmails: [],
newEmail: ""
};
vm.domains.push(testDomain);
// Add many emails
for (let i = 0; i < 50; i++) {
testDomain.newEmail = `user${i}@test.com`;
await vm.addEmail(testDomain);
await vm.addEmail(testDomain, `user${i}@test.com`);
}
expect(testDomain.allowedEmails.length).toBe(50);
@@ -571,16 +561,14 @@ describe("DomainManagement Integration Tests", () => {
const vm = wrapper.vm;
// Test empty domain
vm.newDomain = "";
expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid (optional)
expect(isValidDomain("")).toBe(true); // Empty is considered valid (optional)
// Test whitespace-only domain
vm.newDomain = " ";
expect(vm.isValidDomain(" ")).toBe(false);
expect(isValidDomain(" ")).toBe(false);
// Test tabs and newlines
expect(vm.isValidDomain("\t\n")).toBe(false);
expect(vm.isValidEmail(" ", "example.com")).toBe(false);
expect(isValidDomain("\t\n")).toBe(false);
expect(isValidEmail(" ", "example.com")).toBe(false);
});
});
@@ -591,8 +579,7 @@ describe("DomainManagement Integration Tests", () => {
// Rapid domain additions
const addPromises = [];
for (let i = 0; i < 5; i++) {
vm.newDomain = `rapid${i}.com`;
addPromises.push(vm.addDomain());
addPromises.push(vm.addDomain({ newDomain: `rapid${i}.com` }));
}
await Promise.all(addPromises);
@@ -608,8 +595,7 @@ describe("DomainManagement Integration Tests", () => {
const savePromise = vm.saveChanges();
// Modify data while saving
vm.newDomain = "concurrent.com";
vm.addDomain();
vm.addDomain({ newDomain: "concurrent.com" });
// Should complete without errors
await expect(savePromise).resolves.not.toThrow();
@@ -687,8 +673,7 @@ describe("DomainManagement Integration Tests", () => {
const vm = wrapper.vm;
// Start operations
vm.newDomain = "test.com";
const addPromise = vm.addDomain();
const addPromise = vm.addDomain({ newDomain: "test.com" });
const savePromise = vm.saveChanges();
// Unmount component immediately
@@ -705,8 +690,7 @@ describe("DomainManagement Integration Tests", () => {
const vm = wrapper.vm;
// Trigger various operations to create potential listeners
vm.newDomain = "test.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "test.com" });
await vm.saveChanges();
// Store a reference to test that the component exists
@@ -738,16 +722,21 @@ describe("DomainManagement Integration Tests", () => {
it("should maintain form state during validation errors", async () => {
const vm = wrapper.vm;
// Set up form data
vm.newDomain = "invalid domain with spaces";
// Try to add invalid domain
await vm.addDomain();
// Form should maintain state and not be corrupted
expect(vm.newDomain).toBe("invalid domain with spaces");
expect(vm.domains.length).toBe(3); // Should not have been added
const initialCount = vm.domains.length;
// Set up form data — an invalid domain.
vm.addDomainForm.form.setFieldValue("newDomain", "invalid domain with spaces");
await nextTick();
// Submit through the real form: the schema blocks the invalid domain,
// so @submit (addDomain) never runs — nothing is added and the field
// keeps its value.
await vm.addDomainForm.form.handleSubmit();
await flushPromises();
expect(vm.addDomainForm.form.state.isValid).toBe(false);
expect(vm.addDomainForm.form.state.values.newDomain).toBe("invalid domain with spaces");
expect(vm.domains.length).toBe(initialCount); // Should not have been added
});
});
});
@@ -0,0 +1,147 @@
// Copyright 2026 OpenObserve Inc.
//
// Validation schemas for the two add-rows in DomainManagement.vue (Settings >
// Domain Management). This is an add-to-list / dynamic-array shape (partial
// fit): only the two add-rows are OForm-validated; the rendered domain/email
// lists + radios are display, and the save-time "domain needs emails" check
// stays a submit-level guard in saveChanges().
//
// Restores the Quasar BEFORE rules (truthy→Zod inversion):
// • newDomain: `isValidDomain(v) || invalidDomain` → required + domain regex
// (the audit adds ≤253 + reject-malicious, already enforced by isValidDomain).
// • domain.newEmail: REQUIRED for the Add-Email action — the email must be
// present, valid, and belong to the domain (clicking "Add Email" with an
// empty box shows "Email is required" on first submit). The add-email form is
// its OWN OForm, so requiring it does NOT affect the domain-level
// "Save Changes" button. (Deliberate UX choice over the old empty-passes rule.)
//
// The pure validators live here so they are shared by the schema AND re-exposed
// from the component (the spec exercises them directly). Validation TIMING is
// owned by OForm (submit-then-change); these schemas only describe what is valid.
import { z } from "zod";
export const isValidDomain = (domain: any): boolean => {
// Handle null, undefined, and non-string inputs
if (domain === null || domain === undefined) return true; // Empty is valid
if (typeof domain !== "string") return false; // Non-strings are invalid
// Handle empty strings - empty is valid, but whitespace-only is not
const trimmed = domain.trim();
if (!trimmed) return domain.length === 0; // Empty string is valid, whitespace-only is not
// Security: Check for potentially malicious content (more targeted patterns)
const maliciousPatterns = [
"<script", "</script", "javascript:", "DROP TABLE", "SELECT FROM",
"INSERT INTO", "UPDATE SET", "DELETE FROM", "UNION SELECT", "--", "/*",
"*/", "\0", "\n", "\r",
];
const upperDomain = trimmed.toUpperCase();
if (
maliciousPatterns.some((pattern) =>
upperDomain.includes(pattern.toUpperCase()),
)
) {
return false;
}
// Length validation (DNS limit is 253 characters)
if (trimmed.length > 253) return false;
// Remove trailing dot if present (valid in DNS)
const cleanDomain = trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed;
// Each label can be 1-63 characters, the domain must have at least one dot.
const domainRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
try {
return domainRegex.test(cleanDomain);
} catch (error) {
return false; // Any regex error means invalid
}
};
export const isValidEmail = (email: any, domain: any): boolean => {
// Handle null, undefined, and non-string inputs
if (email === null || email === undefined || typeof email !== "string")
return false;
if (domain === null || domain === undefined || typeof domain !== "string")
return false;
// Handle empty strings
if (!email.trim() || !domain.trim()) return false;
// Security: Check for potentially malicious content
const maliciousPatterns = [
"<", ">", "script", "javascript:", "DROP", "SELECT", "INSERT", "UPDATE",
"DELETE", "UNION", "CREATE", "ALTER", "TABLE", "FROM", "--", "/*", "*/",
"'", '"', "\0", "\n", "\r", "\t",
];
const upperEmail = email.toUpperCase();
if (
maliciousPatterns.some((pattern) =>
upperEmail.includes(pattern.toUpperCase()),
)
) {
return false;
}
// Length validation (practical email limit)
if (email.length > 254 || domain.length > 253) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
try {
if (!emailRegex.test(email)) return false;
// Check if email belongs to the domain
return email.toLowerCase().endsWith(`@${domain.toLowerCase()}`);
} catch (error) {
return false; // Any error means invalid
}
};
// ── Add-domain row: required + valid domain ──────────────────────────────────
export const makeAddDomainSchema = (t: (_key: string) => string) =>
z.object({
newDomain: z
.string()
.min(1, t("settings.domainRequired") || "Domain is required")
.refine((v) => isValidDomain(v), {
message:
t("settings.invalidDomain") ||
"Please enter a valid domain (e.g. example.com)",
}),
});
export type AddDomainForm = z.infer<ReturnType<typeof makeAddDomainSchema>>;
// Typed default-values factory (matches the pattern used by every other
// settings form: licenseDefaults / extendTrialDefaults / generalSettingsDefaults
// …) instead of an inline `{ newDomain: '' }` literal in the template.
export const addDomainDefaults = (): AddDomainForm => ({ newDomain: "" });
// ── Add-email row (per-domain): REQUIRED + valid + belongs to the domain.
// Empty → "Email is required" (first submit); non-empty invalid → email error.
// Its own OForm, so requiring it does not gate the domain-level save. ────────
export const makeAddEmailSchema = (
domainName: string,
t: (_key: string) => string,
) =>
z.object({
newEmail: z
.string()
.min(1, t("settings.emailRequired") || "Email is required")
.refine((v) => isValidEmail(v, domainName), {
message: t("settings.invalidEmail") || "Please enter a valid email",
}),
});
export type AddEmailForm = z.infer<ReturnType<typeof makeAddEmailSchema>>;
// Typed default-values factory for the per-domain add-email row. The default is
// the same blank value for every domain, so a plain factory (not keyed by
// domain) is enough.
export const addEmailDefaults = (): AddEmailForm => ({ newEmail: "" });
@@ -16,6 +16,11 @@
import { DOMWrapper, flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, beforeEach, vi, afterEach, afterAll } from "vitest";
import DomainManagement from "./DomainManagement.vue";
import {
isValidDomain,
isValidEmail,
makeAddEmailSchema,
} from "./DomainManagement.schema";
import i18n from "@/locales";
import { nextTick } from "vue";
@@ -245,29 +250,29 @@ describe("DomainManagement Component", () => {
const vm = wrapper.vm;
// Valid domains
expect(vm.isValidDomain("example.com")).toBe(true);
expect(vm.isValidDomain("sub.example.com")).toBe(true);
expect(vm.isValidDomain("my-company.org")).toBe(true);
expect(isValidDomain("example.com")).toBe(true);
expect(isValidDomain("sub.example.com")).toBe(true);
expect(isValidDomain("my-company.org")).toBe(true);
// Invalid domains
expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid for optional validation
expect(vm.isValidDomain("invalid")).toBe(false);
expect(vm.isValidDomain("spaces in domain.com")).toBe(false);
expect(vm.isValidDomain(".example.com")).toBe(false);
expect(isValidDomain("")).toBe(true); // Empty is considered valid for optional validation
expect(isValidDomain("invalid")).toBe(false);
expect(isValidDomain("spaces in domain.com")).toBe(false);
expect(isValidDomain(".example.com")).toBe(false);
});
it("should validate email addresses for specific domains", async () => {
const vm = wrapper.vm;
// Valid emails for domain
expect(vm.isValidEmail("user@example.com", "example.com")).toBe(true);
expect(vm.isValidEmail("admin@company.org", "company.org")).toBe(true);
expect(isValidEmail("user@example.com", "example.com")).toBe(true);
expect(isValidEmail("admin@company.org", "company.org")).toBe(true);
// Invalid emails
expect(vm.isValidEmail("", "example.com")).toBe(false);
expect(vm.isValidEmail("invalid-email", "example.com")).toBe(false);
expect(vm.isValidEmail("user@different.com", "example.com")).toBe(false);
expect(vm.isValidEmail("user@example.com", "different.org")).toBe(false);
expect(isValidEmail("", "example.com")).toBe(false);
expect(isValidEmail("invalid-email", "example.com")).toBe(false);
expect(isValidEmail("user@different.com", "example.com")).toBe(false);
expect(isValidEmail("user@example.com", "different.org")).toBe(false);
});
});
@@ -280,10 +285,9 @@ describe("DomainManagement Component", () => {
it("should add a new domain successfully", async () => {
const vm = wrapper.vm;
vm.newDomain = "newdomain.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "newdomain.com" });
await nextTick();
expect(vm.domains).toContainEqual(
expect.objectContaining({
name: "newdomain.com",
@@ -296,9 +300,7 @@ describe("DomainManagement Component", () => {
it("should not add invalid domain", async () => {
const vm = wrapper.vm;
vm.newDomain = "invalid-domain";
expect(vm.isValidDomain("invalid-domain")).toBe(false);
expect(isValidDomain("invalid-domain")).toBe(false);
});
it("should not add duplicate domain", async () => {
@@ -309,8 +311,7 @@ describe("DomainManagement Component", () => {
const initialDomainsCount = vm.domains.length;
// Try to add a domain that already exists
vm.newDomain = "example.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "example.com" });
expect(vm.domains.length).toBe(initialDomainsCount);
});
@@ -318,10 +319,56 @@ describe("DomainManagement Component", () => {
it("should clear input after adding domain", async () => {
const vm = wrapper.vm;
vm.newDomain = "newdomain.com";
await vm.addDomain();
expect(vm.newDomain).toBe("");
vm.addDomainForm.form.setFieldValue("newDomain", "newdomain.com");
await vm.addDomain({ newDomain: "newdomain.com" });
expect(vm.addDomainForm.form.state.values.newDomain).toBe("");
});
});
describe("Add-domain schema validation (real OForm)", () => {
beforeEach(async () => {
wrapper = createWrapper();
await flushPromises();
});
it("blocks submit and does NOT add a domain when the input is empty", async () => {
const vm = wrapper.vm;
const initialCount = vm.domains.length;
await vm.addDomainForm.form.handleSubmit();
await flushPromises();
expect(vm.addDomainForm.form.state.isValid).toBe(false);
expect(vm.domains.length).toBe(initialCount);
});
it("blocks submit when the domain is invalid (restored domain regex rule)", async () => {
const vm = wrapper.vm;
const initialCount = vm.domains.length;
vm.addDomainForm.form.setFieldValue("newDomain", "not-a-domain");
await nextTick();
await vm.addDomainForm.form.handleSubmit();
await flushPromises();
expect(vm.addDomainForm.form.state.isValid).toBe(false);
expect(vm.domains.length).toBe(initialCount);
});
it("adds the domain when a valid value is submitted through the form", async () => {
const vm = wrapper.vm;
vm.addDomainForm.form.setFieldValue("newDomain", "valid-domain.com");
await nextTick();
await vm.addDomainForm.form.handleSubmit();
await flushPromises();
expect(
vm.domains.some((d: any) => d.name === "valid-domain.com"),
).toBe(true);
// Inline add-row cleared after a successful add.
expect(vm.addDomainForm.form.state.values.newDomain).toBe("");
});
});
@@ -371,12 +418,10 @@ describe("DomainManagement Component", () => {
if (restrictedDomain) {
const initialEmailCount = restrictedDomain.allowedEmails.length;
restrictedDomain.newEmail = `newuser@${restrictedDomain.name}`;
await vm.addEmail(restrictedDomain);
await vm.addEmail(restrictedDomain, `newuser@${restrictedDomain.name}`);
expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount + 1);
expect(restrictedDomain.allowedEmails).toContain(`newuser@${restrictedDomain.name}`);
expect(restrictedDomain.newEmail).toBe("");
}
});
@@ -387,8 +432,7 @@ describe("DomainManagement Component", () => {
if (restrictedDomain) {
const initialEmailCount = restrictedDomain.allowedEmails.length;
restrictedDomain.newEmail = "invalid-email";
await vm.addEmail(restrictedDomain);
await vm.addEmail(restrictedDomain, "invalid-email");
expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount);
}
@@ -402,8 +446,7 @@ describe("DomainManagement Component", () => {
const initialEmailCount = restrictedDomain.allowedEmails.length;
const existingEmail = restrictedDomain.allowedEmails[0];
restrictedDomain.newEmail = existingEmail;
await vm.addEmail(restrictedDomain);
await vm.addEmail(restrictedDomain, existingEmail);
expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount);
}
@@ -450,7 +493,6 @@ describe("DomainManagement Component", () => {
name: "test.com",
allowAllUsers: false,
allowedEmails: [],
newEmail: "",
});
await vm.saveChanges();
@@ -489,14 +531,14 @@ describe("DomainManagement Component", () => {
const vm = wrapper.vm;
// Modify some data
vm.newDomain = "test-input";
vm.addDomainForm.form.setFieldValue("newDomain", "test-input");
// Clear the mock call count from beforeEach/mount
mockDomainManagement.getDomainRestrictions.mockClear();
await vm.resetForm();
expect(vm.newDomain).toBe("");
expect(vm.addDomainForm.form.state.values.newDomain).toBe("");
expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledTimes(1); // Called once by resetForm
});
});
@@ -601,8 +643,7 @@ describe("DomainManagement Component", () => {
];
for (const xss of xssAttempts) {
vm.newDomain = xss;
expect(vm.isValidDomain(xss)).toBe(false);
expect(isValidDomain(xss)).toBe(false);
}
});
@@ -618,8 +659,8 @@ describe("DomainManagement Component", () => {
];
for (const sql of sqlInjections) {
expect(vm.isValidDomain(sql)).toBe(false);
expect(vm.isValidEmail(sql + "@test.com", "test.com")).toBe(false);
expect(isValidDomain(sql)).toBe(false);
expect(isValidEmail(sql + "@test.com", "test.com")).toBe(false);
}
});
});
@@ -629,14 +670,14 @@ describe("DomainManagement Component", () => {
const vm = wrapper.vm;
// Test null/undefined domain validation
expect(vm.isValidDomain(null)).toBe(true); // null treated as empty
expect(vm.isValidDomain(undefined)).toBe(true); // undefined treated as empty
expect(isValidDomain(null)).toBe(true); // null treated as empty
expect(isValidDomain(undefined)).toBe(true); // undefined treated as empty
// Test null/undefined email validation
expect(vm.isValidEmail(null, "test.com")).toBe(false);
expect(vm.isValidEmail(undefined, "test.com")).toBe(false);
expect(vm.isValidEmail("test@test.com", null)).toBe(false);
expect(vm.isValidEmail("test@test.com", undefined)).toBe(false);
expect(isValidEmail(null, "test.com")).toBe(false);
expect(isValidEmail(undefined, "test.com")).toBe(false);
expect(isValidEmail("test@test.com", null)).toBe(false);
expect(isValidEmail("test@test.com", undefined)).toBe(false);
});
it("should reject non-string inputs", async () => {
@@ -652,8 +693,8 @@ describe("DomainManagement Component", () => {
];
for (const input of nonStringInputs) {
expect(vm.isValidDomain(input)).toBe(false);
expect(vm.isValidEmail(input, "test.com")).toBe(false);
expect(isValidDomain(input)).toBe(false);
expect(isValidEmail(input, "test.com")).toBe(false);
}
});
@@ -663,8 +704,8 @@ describe("DomainManagement Component", () => {
const hugeDomain = "a".repeat(10000) + ".com";
const hugeEmail = "a".repeat(10000) + "@test.com";
expect(vm.isValidDomain(hugeDomain)).toBe(false);
expect(vm.isValidEmail(hugeEmail, "test.com")).toBe(false);
expect(isValidDomain(hugeDomain)).toBe(false);
expect(isValidEmail(hugeEmail, "test.com")).toBe(false);
});
});
@@ -709,8 +750,7 @@ describe("DomainManagement Component", () => {
// Rapid form submissions
for (let i = 0; i < 10; i++) {
vm.newDomain = `rapid${i}.com`;
vm.addDomain(); // Don't await to simulate rapid clicking
vm.addDomain({ newDomain: `rapid${i}.com` }); // Don't await to simulate rapid clicking
}
// Wait for all operations to complete
@@ -721,8 +761,10 @@ describe("DomainManagement Component", () => {
it("should maintain performance with large datasets", async () => {
const vm = wrapper.vm;
// Add many domains (reduced count to prevent timeout)
// Add many domains (reduced count to prevent timeout). Each restricted
// domain now renders its own add-email <OForm>, so this stress scenario
// is a little heavier — give it more headroom under parallel CPU load.
const largeDomainSet = [];
for (let i = 0; i < 50; i++) {
largeDomainSet.push({
@@ -735,11 +777,10 @@ describe("DomainManagement Component", () => {
vm.domains.splice(0, vm.domains.length, ...largeDomainSet);
// Should still function normally
vm.newDomain = "newdomain.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "newdomain.com" });
expect(vm.domains.length).toBe(51);
}, 10000);
}, 20000);
});
describe("State Consistency Testing", () => {
@@ -751,7 +792,6 @@ describe("DomainManagement Component", () => {
name: "test.com",
allowAllUsers: false,
allowedEmails: ["user1@test.com"],
newEmail: ""
});
const domain = vm.domains[vm.domains.length - 1];
@@ -774,10 +814,10 @@ describe("DomainManagement Component", () => {
// Rapid validation calls
const validationPromises = [
Promise.resolve(vm.isValidDomain("test1.com")),
Promise.resolve(vm.isValidDomain("test2.com")),
Promise.resolve(vm.isValidEmail("user@test.com", "test.com")),
Promise.resolve(vm.isValidEmail("admin@test.com", "test.com"))
Promise.resolve(isValidDomain("test1.com")),
Promise.resolve(isValidDomain("test2.com")),
Promise.resolve(isValidEmail("user@test.com", "test.com")),
Promise.resolve(isValidEmail("admin@test.com", "test.com"))
];
const results = await Promise.all(validationPromises);
@@ -795,8 +835,7 @@ describe("DomainManagement Component", () => {
const vm = wrapper.vm;
// Start long-running operations
vm.newDomain = "test.com";
const addPromise = vm.addDomain();
const addPromise = vm.addDomain({ newDomain: "test.com" });
const savePromise = vm.saveChanges();
// Destroy component immediately
@@ -819,13 +858,11 @@ describe("DomainManagement Component", () => {
name: `stress-test-${i}.com`,
allowAllUsers: false,
allowedEmails: [`user@stress-test-${i}.com`],
newEmail: ""
});
}
// Component operations should still work under load
vm.newDomain = "memory-test.com";
await vm.addDomain();
await vm.addDomain({ newDomain: "memory-test.com" });
expect(vm.domains.some(d => d.name === "memory-test.com")).toBe(true);
expect(vm.domains.length).toBe(initialCount + 30 + 1); // Original + stress domains + new domain
@@ -836,3 +873,82 @@ describe("DomainManagement Component", () => {
});
});
});
describe("makeAddEmailSchema (required: empty fails, must be valid + belong to domain)", () => {
const schema = makeAddEmailSchema("example.com", (k: string) => k);
it("FAILS when the email is empty/omitted (required for the Add-Email action)", () => {
// Option A: the Add-Email field is required, so empty → "Email is required".
// (No .trim() — matches pre-migration; whitespace-only is caught as invalid.)
expect(schema.safeParse({ newEmail: "" }).success).toBe(false);
expect(schema.safeParse({ newEmail: undefined }).success).toBe(false);
expect(schema.safeParse({}).success).toBe(false);
});
it("passes a valid email that belongs to the domain", () => {
expect(schema.safeParse({ newEmail: "user@example.com" }).success).toBe(
true,
);
});
it("rejects a malformed email or one from another domain", () => {
expect(schema.safeParse({ newEmail: "invalid-email" }).success).toBe(false);
expect(schema.safeParse({ newEmail: "user@other.com" }).success).toBe(
false,
);
});
describe("Add-email form — first-submit validation (repro)", () => {
const createWrapper = () =>
mount(DomainManagement, { global: { plugins: [i18n] } });
const emailFormFor = async (wrapper: any, name: string) => {
const vm = wrapper.vm as any;
vm.domains.push({ name, allowAllUsers: false, allowedEmails: [] });
await nextTick();
await flushPromises();
await nextTick();
return vm.emailFormRefs[name]?.form;
};
it("INVALID email on the FIRST submit reveals the error and does not add", async () => {
const wrapper = createWrapper();
await flushPromises();
const form = await emailFormFor(wrapper, "openobaseve.ai");
expect(form).toBeTruthy();
form.setFieldValue("newEmail", "abc"); // invalid
await nextTick();
await form.handleSubmit(); // FIRST submit
await flushPromises();
await nextTick();
const domain = (wrapper.vm as any).domains.find(
(d: any) => d.name === "openobaseve.ai",
);
expect(domain.allowedEmails).not.toContain("abc");
// The bug would be: isValid true / no error on first submit.
expect(form.state.isValid).toBe(false);
expect((form.getFieldMeta("newEmail")?.errors ?? []).length).toBeGreaterThan(0);
wrapper.unmount();
});
it("EMPTY email on the FIRST submit shows 'required' and does not add (Option A)", async () => {
const wrapper = createWrapper();
await flushPromises();
const form = await emailFormFor(wrapper, "openobaseve.ai");
expect(form).toBeTruthy();
await form.handleSubmit(); // FIRST submit, empty
await flushPromises();
await nextTick();
const domain = (wrapper.vm as any).domains.find(
(d: any) => d.name === "openobaseve.ai",
);
expect(domain.allowedEmails).toEqual([]);
expect(form.state.isValid).toBe(false); // required now → empty blocked
expect((form.getFieldMeta("newEmail")?.errors ?? []).length).toBeGreaterThan(0);
wrapper.unmount();
});
});
});
+102 -133
View File
@@ -168,31 +168,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="text-base font-bold mb-3">
{{ t("settings.domainAndAllowedUsers") }}
</div>
<div class="flex gap-x-2 items-center">
<!-- Fixed-width wrapper: OInput fills its parent (w-full), so
constraining the parent sizes the input without overriding it. -->
<OForm
ref="addDomainForm"
:schema="addDomainSchema"
:default-values="addDomainDefaults()"
@submit="addDomain"
v-slot="{ isSubmitting }"
class="flex gap-x-2 items-start"
>
<div class="w-[18.75rem] shrink-0">
<OInput
<OFormInput
data-test="domain-management-new-domain-input"
v-model="newDomain"
name="newDomain"
class="domain-input"
@keydown.enter="addDomain"
:placeholder="t('settings.domainPlaceholder')"
:error="!!domainError"
:error-message="domainError"
@update:model-value="domainError = ''"
/>
</div>
<OButton
data-test="domain-management-add-domain-btn"
variant="primary"
size="sm-action"
@click="addDomain"
:disabled="!newDomain || !isValidDomain(newDomain)"
type="submit"
:loading="isSubmitting"
>{{ t('settings.addDomain') }}
</OButton>
</div>
</OForm>
<div class="text-xs text-gray-400 mt-1">
{{ t('settings.domainHint', { at_sign: '@' }) }}
</div>
@@ -211,7 +212,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:class="store.state.theme === 'dark' ? 'border-[#444] bg-[#1e1e1e]' : 'bg-white'"
>
<div class="flex items-center justify-between px-3 py-2 border-b border-b-(--o2-border) rounded-t-lg" :class="store.state.theme === 'dark' ? 'bg-[#2a2a2a] border-b-[#444]' : 'bg-[#f5f5f5]'">
<div class="text-base font-bold" :data-test="`domain-management-domain-name-${domain.name}`">{{ domain.name }}</div>
<div
:data-test="`domain-management-domain-name-${domain.name}`"
class="text-base font-bold"
>{{ domain.name }}</div>
<OButton
icon-left="close"
variant="ghost-destructive"
@@ -249,20 +253,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Specific users section -->
<div v-if="!domain.allowAllUsers" class="specific-users-section ml-6">
<div class="flex gap-x-2 items-end">
<OInput
v-model="domain.newEmail"
:label="t('settings.emailPlaceholder', { domain: '@' + domain.name })"
class="email-input min-w-62.5"
@keydown.enter="addEmail(domain)"
/>
<OButton
variant="primary"
size="sm-action"
@click="addEmail(domain)"
:disabled="!domain.newEmail || !isValidEmail(domain.newEmail, domain.name)"
>{{ t('settings.addEmail') }}</OButton>
</div>
<OForm
:ref="(el) => setEmailFormRef(domain.name, el)"
:schema="getEmailSchema(domain.name)"
:default-values="addEmailDefaults()"
@submit="(v) => addEmail(domain, v.newEmail)"
v-slot="{ isSubmitting }"
>
<!-- Hint label above the row, so the error can grow below the
input without shoving the Add button out of alignment. -->
<div class="o-input-label text-sm font-semibold leading-tight mb-1">
{{ t('settings.emailPlaceholder', { domain: '@' + domain.name }) }}
</div>
<div class="flex gap-x-2 items-start">
<OFormInput
name="newEmail"
class="email-input min-w-62.5"
/>
<OButton
variant="primary"
size="sm-action"
type="submit"
:loading="isSubmitting"
>{{ t('settings.addEmail') }}</OButton>
</div>
</OForm>
<!-- Email List -->
<div v-if="domain.allowedEmails && domain.allowedEmails.length > 0">
@@ -346,7 +361,8 @@ import { ref, reactive, onMounted, onActivated, watch, computed } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
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 OSelect from "@/lib/forms/Select/OSelect.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
@@ -364,12 +380,19 @@ import ORadio from "@/lib/forms/Radio/ORadio.vue";
import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import {
isValidEmail,
makeAddDomainSchema,
makeAddEmailSchema,
addDomainDefaults,
addEmailDefaults,
type AddDomainForm,
} from "./DomainManagement.schema";
interface Domain {
name: string;
allowAllUsers: boolean;
allowedEmails: string[];
newEmail: string;
}
const { t } = useI18n();
@@ -382,11 +405,33 @@ const pendingRemoveEmail = ref<{ domain: any; emailIndex: number; email: string
const store = useStore();
const router = useRouter();
const newDomain = ref("");
const domainError = ref("");
const domains = reactive<Domain[]>([]);
const saving = ref(false);
// Schema-driven validation replaces the manual domainError ref + the
// :disabled="!newDomain || !isValidDomain" gate (R3). The pure validators live
// in the schema file (used by the Zod refines); the component only needs
// `isValidEmail` for the addEmail guard, imported directly.
const addDomainForm = ref<any>(null);
const addDomainSchema = makeAddDomainSchema(t);
// Per-domain email add-row forms: a schema cache (keyed by domain name, since
// the email schema embeds the domain) + a ref map so each row can be reset
// after a successful add.
const emailSchemaCache = new Map<string, ReturnType<typeof makeAddEmailSchema>>();
const getEmailSchema = (domainName: string) => {
if (!emailSchemaCache.has(domainName)) {
emailSchemaCache.set(domainName, makeAddEmailSchema(domainName, t));
}
return emailSchemaCache.get(domainName);
};
const emailFormRefs = ref<Record<string, any>>({});
const setEmailFormRef = (domainName: string, el: any) => {
if (el) emailFormRefs.value[domainName] = el;
else delete emailFormRefs.value[domainName];
};
// Claim parser function state
const claimParserFunction = ref("");
const originalClaimParserFunction = ref(""); // Track original value to detect changes
@@ -445,7 +490,7 @@ const loadDomainSettings = async () => {
.map((domain: any) => ({
name: domain.domain,
allowAllUsers: domain.allow_all_users,
allowedEmails: domain.allowed_emails || []
allowedEmails: domain.allowed_emails || [],
}));
domains.splice(0, domains.length, ...loadedDomains);
}
@@ -466,91 +511,15 @@ const loadDomainSettings = async () => {
}
};
const isValidDomain = (domain: any): boolean => {
// Handle null, undefined, and non-string inputs
if (domain === null || domain === undefined) return true; // Empty is valid
if (typeof domain !== 'string') return false; // Non-strings are invalid
// Handle empty strings - empty is valid, but whitespace-only is not
const trimmed = domain.trim();
if (!trimmed) return domain.length === 0; // Empty string is valid, whitespace-only is not
// Security: Check for potentially malicious content (more targeted patterns)
const maliciousPatterns = [
'<script', '</script', 'javascript:', 'DROP TABLE', 'SELECT FROM', 'INSERT INTO',
'UPDATE SET', 'DELETE FROM', 'UNION SELECT', '--', '/*', '*/', '\0', '\n', '\r'
];
const upperDomain = trimmed.toUpperCase();
if (maliciousPatterns.some(pattern => upperDomain.includes(pattern.toUpperCase()))) {
return false;
}
// Length validation (DNS limit is 253 characters)
if (trimmed.length > 253) return false;
// Remove trailing dot if present (valid in DNS)
const cleanDomain = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
// Improved domain validation that properly handles hyphens and edge cases
// Domain parts can contain letters, numbers, and hyphens (but not start/end with hyphens)
// Each label can be 1-63 characters, and the domain must have at least one dot
const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
try {
return domainRegex.test(cleanDomain);
} catch (error) {
return false; // Any regex error means invalid
}
};
const isValidEmail = (email: any, domain: any): boolean => {
// Handle null, undefined, and non-string inputs
if (email === null || email === undefined || typeof email !== 'string') return false;
if (domain === null || domain === undefined || typeof domain !== 'string') return false;
// Handle empty strings
if (!email.trim() || !domain.trim()) return false;
// Security: Check for potentially malicious content
const maliciousPatterns = [
'<', '>', 'script', 'javascript:', 'DROP', 'SELECT', 'INSERT', 'UPDATE', 'DELETE',
'UNION', 'CREATE', 'ALTER', 'TABLE', 'FROM', '--', '/*', '*/', "'", '"',
'\0', '\n', '\r', '\t'
];
const upperEmail = email.toUpperCase();
if (maliciousPatterns.some(pattern => upperEmail.includes(pattern.toUpperCase()))) {
return false;
}
// Length validation (practical email limit)
if (email.length > 254 || domain.length > 253) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
try {
if (!emailRegex.test(email)) return false;
// Check if email belongs to the domain
return email.toLowerCase().endsWith(`@${domain.toLowerCase()}`);
} catch (error) {
return false; // Any error means invalid
}
};
const addDomain = () => {
if (!newDomain.value) {
domainError.value = t("settings.domainRequired") || "Domain is required";
return;
}
if (!isValidDomain(newDomain.value)) {
domainError.value = t("settings.invalidDomain") || "Please enter a valid domain (e.g. example.com)";
return;
}
// @submit handler — fires only once the schema passes (required + valid
// domain), so the old empty/invalid guards are gone. The submitted form value
// is the single source of truth; the cross-state duplicate check stays here.
const addDomain = (value?: AddDomainForm) => {
const candidate = (value?.newDomain ?? "").trim();
if (!candidate) return;
// Check if domain already exists
if (domains.some(d => d.name.toLowerCase() === newDomain.value.toLowerCase())) {
if (domains.some(d => d.name.toLowerCase() === candidate.toLowerCase())) {
toast({
variant: "error",
message: t("settings.domainAlreadyExists"),
@@ -559,19 +528,14 @@ const addDomain = () => {
}
domains.push({
name: newDomain.value,
name: candidate,
allowAllUsers: true,
allowedEmails: [],
newEmail: ""
});
newDomain.value = "";
// toast({
// variant: "success",
// message: t("settings.domainAdded"),
// timeout: 3000,
// });
// Inline add-row cleared after save → reset() clears the field AND submit
// state (submissionAttempts → 0), so no post-save "required" flash.
addDomainForm.value?.form?.reset();
};
const removeDomain = (index: number) => {
@@ -591,11 +555,15 @@ const doRemoveDomain = () => {
});
};
const addEmail = (domain: Domain) => {
if (!domain.newEmail || !isValidEmail(domain.newEmail, domain.name)) return;
// @submit handler for a domain's email add-row. The schema is conditional
// (empty passes), so an empty submit is a no-op; otherwise the email must be
// valid + belong to the domain. Still callable directly with the row model.
const addEmail = (domain: Domain, emailValue?: string) => {
const email = (emailValue ?? "").trim();
if (!email || !isValidEmail(email, domain.name)) return;
// Check if email already exists
if (domain.allowedEmails.includes(domain.newEmail.toLowerCase())) {
if (domain.allowedEmails.includes(email.toLowerCase())) {
toast({
variant: "error",
message: t("settings.emailAlreadyExists"),
@@ -603,8 +571,8 @@ const addEmail = (domain: Domain) => {
return;
}
domain.allowedEmails.push(domain.newEmail.toLowerCase());
domain.newEmail = "";
domain.allowedEmails.push(email.toLowerCase());
emailFormRefs.value[domain.name]?.form?.reset();
toast({
variant: "success",
@@ -801,7 +769,7 @@ const viewAllErrors = () => {
const saveChanges = async () => {
saving.value = true;
try {
// Validate all domains have proper configuration
for (const domain of domains) {
@@ -844,8 +812,9 @@ const saveChanges = async () => {
};
const resetForm = () => {
newDomain.value = "";
// Reset the add-domain row through the form (clears the field + submit state),
// keeping TanStack the single owner of that field — no parallel ref.
addDomainForm.value?.form?.reset();
loadDomainSettings();
};
</script>
@@ -0,0 +1,61 @@
// Copyright 2026 OpenObserve Inc.
//
// Validation schema for the General Settings "platform settings" form (the two
// number fields inside the <OForm>). The logo text / logo uploads / theme color
// chips are pure-UI controls with their own inline actions and are decoupled
// OUTSIDE the form, so they are NOT in this schema.
//
// Restores the Quasar BEFORE rules (truthy→Zod inversion):
// • scrapeInterval: `!!v || scrapeIntervalRequired` (+ HTML min=0)
// → required AND ≥ 0 (0 is a valid value).
// • maxSeriesPerQuery: empty→pass; else `v>=1000 && v<=1000000`
// → OPTIONAL, only range-checked when present (NOT required).
//
// The <input type="number"> emits a string, so the rules read the raw value via
// Number(); the submit handler coerces at use.
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid.
import { z } from "zod";
const isEmpty = (v: unknown) => v === "" || v === null || v === undefined;
// Factory (takes `t`) so the messages are localized like makeLicenseSchema /
// makeAddEmailSchema, rather than hardcoded English. The component owns the
// `t` instance and builds the schema once in setup().
export const makeGeneralSettingsSchema = (t: (_key: string) => string) =>
z.object({
// Required (non-empty) and ≥ 0. 0 passes; empty fails as "required".
scrape_interval: z
.any()
.refine((v) => !isEmpty(v), {
message:
t("settings.scrapeIntervalRequired") || "Scrape interval is required",
})
.refine(
(v) => isEmpty(v) || (!Number.isNaN(Number(v)) && Number(v) >= 0),
{
message:
t("settings.scrapeIntervalPositive") ||
"Scrape interval must be a positive number",
},
),
// Optional — only range-checked (1000..1000000) when a value is present.
max_series_per_query: z.any().refine(
(v) =>
isEmpty(v) ||
(!Number.isNaN(Number(v)) &&
Number(v) >= 1000 &&
Number(v) <= 1000000),
{
message:
t("settings.maxSeriesPerQueryValidation") ||
"Max series per query must be between 1000 and 1000000",
},
),
});
export type GeneralSettingsForm = z.infer<
ReturnType<typeof makeGeneralSettingsSchema>
>;
+123 -67
View File
@@ -144,31 +144,9 @@ const createWrapper = (props = {}, options = {}) => {
store: mockStore,
},
stubs: {
OForm: {
template:
'<form data-test-stub="o-form" @submit.prevent=\'$emit("submit", $event)\'><slot></slot></form>',
emits: ["submit"],
},
OInput: {
template: `<input
data-test-stub='o-input'
:data-test='$attrs["data-test"]'
:value='modelValue'
@input='$emit("update:modelValue", Number($event.target.value))'
:type='type'
:min='min'
:placeholder='placeholder'
/>`,
props: [
"modelValue",
"type",
"min",
"placeholder",
"error",
"errorMessage",
],
emits: ["update:modelValue"],
},
// OForm / OFormInput / OInput are intentionally NOT stubbed so the real
// schema validation runs (per the migration playbook: at least one path
// must mount the real <OForm>).
OButton: {
template: `<button
data-test-stub='o-button'
@@ -317,33 +295,33 @@ describe("General", () => {
expect(wrapper.exists()).toBe(true);
});
it("should initialize with organization settings values", () => {
it("should seed the form defaults from organization settings", () => {
const wrapper = createWrapper();
expect(wrapper.vm.scrapeIntereval).toBe(30);
const form = wrapper.findComponent({ name: "OForm" });
expect(form.vm.form.state.values.scrape_interval).toBe(30);
});
});
describe("Form inputs", () => {
it("should update scrape interval value", async () => {
it("should keep the scrape interval and max series inputs (data-tests preserved)", () => {
const wrapper = createWrapper();
const scrapeInput = wrapper.find('[data-test-stub="o-input"]');
await scrapeInput.setValue("45");
expect(wrapper.vm.scrapeIntereval).toBe(45);
expect(
wrapper.find('[data-test="general-settings-scrape-interval"]').exists(),
).toBe(true);
expect(
wrapper
.find('[data-test="general-settings-max-series-per-query"]')
.exists(),
).toBe(true);
});
});
describe("Form submission", () => {
describe("Form submission (real OForm)", () => {
it("should save organization settings successfully", async () => {
const wrapper = createWrapper();
const form = wrapper.findComponent({ name: "OForm" });
// Set the scrape interval in the component
wrapper.vm.scrapeIntereval = 30;
await wrapper.vm.$nextTick();
const form = wrapper.find('[data-test-stub="o-form"]');
await form.trigger("submit");
await form.vm.form.handleSubmit();
await nextTick();
// The dispatch is called with the spread of existing organizationSettings plus scrape_interval
@@ -371,8 +349,8 @@ describe("General", () => {
});
const wrapper = createWrapper();
const form = wrapper.find('[data-test-stub="o-form"]');
await form.trigger("submit");
const form = wrapper.findComponent({ name: "OForm" });
await form.vm.form.handleSubmit();
await nextTick();
expect(mockToast).toHaveBeenCalledWith({
@@ -385,8 +363,8 @@ describe("General", () => {
mockOrganizations.post_organization_settings.mockRejectedValue({});
const wrapper = createWrapper();
const form = wrapper.find('[data-test-stub="o-form"]');
await form.trigger("submit");
const form = wrapper.findComponent({ name: "OForm" });
await form.vm.form.handleSubmit();
await nextTick();
expect(mockToast).toHaveBeenCalledWith({
@@ -831,19 +809,100 @@ describe("General", () => {
});
});
describe("Form validation", () => {
it("should validate scrape interval is required", () => {
describe("Form validation (real OForm)", () => {
it("blocks submit and does NOT save when scrape interval is empty (restored required rule)", async () => {
const wrapper = createWrapper();
const scrapeInput = wrapper.findComponent({ name: "OInput" });
const form = wrapper.findComponent({ name: "OForm" });
form.vm.form.setFieldValue("scrape_interval", "");
if (scrapeInput.exists()) {
// OInput uses error/errorMessage props instead of rules
// Validation happens in the onSubmit handler, not declaratively
expect(scrapeInput.props("error")).toBeDefined();
} else {
// Fallback: verify component has validation logic
expect(wrapper.vm.scrapeIntereval).toBeDefined();
}
await form.vm.form.handleSubmit();
await nextTick();
expect(form.vm.form.state.isValid).toBe(false);
expect(
mockOrganizations.post_organization_settings,
).not.toHaveBeenCalled();
});
it("blocks submit when scrape interval is negative", async () => {
const wrapper = createWrapper();
const form = wrapper.findComponent({ name: "OForm" });
form.vm.form.setFieldValue("scrape_interval", -5);
await form.vm.form.handleSubmit();
await nextTick();
expect(form.vm.form.state.isValid).toBe(false);
expect(
mockOrganizations.post_organization_settings,
).not.toHaveBeenCalled();
});
it("allows a scrape interval of 0", async () => {
const wrapper = createWrapper();
const form = wrapper.findComponent({ name: "OForm" });
form.vm.form.setFieldValue("scrape_interval", 0);
await form.vm.form.handleSubmit();
await nextTick();
expect(
mockOrganizations.post_organization_settings,
).toHaveBeenCalled();
});
it("blocks submit when max series per query is below 1000 (restored optional range rule)", async () => {
const wrapper = createWrapper();
const form = wrapper.findComponent({ name: "OForm" });
form.vm.form.setFieldValue("max_series_per_query", 500);
await form.vm.form.handleSubmit();
await nextTick();
expect(form.vm.form.state.isValid).toBe(false);
expect(
mockOrganizations.post_organization_settings,
).not.toHaveBeenCalled();
});
it("blocks submit when max series per query is above 1000000", async () => {
const wrapper = createWrapper();
const form = wrapper.findComponent({ name: "OForm" });
form.vm.form.setFieldValue("max_series_per_query", 2000000);
await form.vm.form.handleSubmit();
await nextTick();
expect(form.vm.form.state.isValid).toBe(false);
expect(
mockOrganizations.post_organization_settings,
).not.toHaveBeenCalled();
});
it("keeps max series per query OPTIONAL — empty value saves", async () => {
const wrapper = createWrapper();
const form = wrapper.findComponent({ name: "OForm" });
// default is null (empty) — should still submit
await form.vm.form.handleSubmit();
await nextTick();
expect(
mockOrganizations.post_organization_settings,
).toHaveBeenCalled();
});
it("submits a valid in-range max series per query", async () => {
const wrapper = createWrapper();
const form = wrapper.findComponent({ name: "OForm" });
form.vm.form.setFieldValue("max_series_per_query", 50000);
await form.vm.form.handleSubmit();
await nextTick();
expect(mockStore.dispatch).toHaveBeenCalledWith(
"setOrganizationSettings",
expect.objectContaining({ max_series_per_query: 50000 }),
);
});
});
@@ -878,17 +937,13 @@ describe("General", () => {
});
describe("Accessibility", () => {
it("should have proper form labels", () => {
it("should render the scrape interval as a number input", () => {
const wrapper = createWrapper();
const scrapeInput = wrapper.findComponent({ name: "OInput" });
if (scrapeInput.exists()) {
// OInput has proper aria attributes or associated labels
expect(scrapeInput.props("type")).toBeDefined();
} else {
// Fallback: verify component has proper structure
expect(wrapper.exists()).toBe(true);
}
const input = wrapper.find(
'[data-test="general-settings-scrape-interval"] input',
);
expect(input.exists()).toBe(true);
expect(input.attributes("type")).toBe("number");
});
});
@@ -897,8 +952,9 @@ describe("General", () => {
mockStore.state.organizationData.organizationSettings = null;
const wrapper = createWrapper();
// Component uses default values when organizationSettings is null
expect(wrapper.vm.scrapeIntereval).toBe(15); // default from component
// The form falls back to the default scrape_interval (15) from the schema defaults.
const form = wrapper.findComponent({ name: "OForm" });
expect(form.vm.form.state.values.scrape_interval).toBe(15);
});
it("should handle upload when not enterprise", async () => {
+64 -79
View File
@@ -19,24 +19,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div>
<!-- Section header (title + description) is provided full-width by the
Settings shell; this component renders only the form content. -->
<!-- platform settings section -->
<div class="">
<!-- platform settings section. The page gutter is owned by the Settings
shell's ConstrainedPage; this block adds none of its own. -->
<div>
<GroupHeader :title="t('settings.platformSettings')" :showIcon="false" />
<div class="w-full flex flex-col">
<OForm @submit.stop="onSubmit.execute">
<OForm
:schema="generalSettingsSchema"
:default-values="generalSettingsDefaults"
@submit="saveGeneralSettings"
v-slot="{ isSubmitting }"
>
<!-- scape interval section -->
<div class="settings-grid-item grid grid-cols-3 gap-4 items-center py-4 border-b border-(--o2-border-color)">
<span class="individual-setting-title text-sm font-medium leading-5">
{{ t("settings.scrapintervalLabel") }}
</span>
<OInput
v-model.number="scrapeIntereval"
<OFormInput
name="scrape_interval"
type="number"
min="0"
class="ml-2"
:error="!!scrapeIntervalError"
:error-message="scrapeIntervalError"
@update:model-value="scrapeIntervalError = ''"
data-test="general-settings-scrape-interval"
style="width: 120px"
/>
@@ -50,15 +53,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="individual-setting-title text-sm font-medium leading-5">
{{ t("settings.maxSeriesPerQueryLabel") }}
</span>
<OInput
v-model.number="maxSeriesPerQuery"
<OFormInput
name="max_series_per_query"
type="number"
:min="1000"
:max="1000000"
class="ml-2"
:error="!!maxSeriesError"
:error-message="maxSeriesError"
@update:model-value="maxSeriesError = ''"
:placeholder="'40000 (' + t('settings.systemDefault') + ')'"
data-test="general-settings-max-series-per-query"
style="width: 180px"
@@ -68,7 +68,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTooltip side="top" :content="t('settings.maxSeriesPerQueryTooltip')" />
</OIcon>
</template>
</OInput>
</OFormInput>
<span class="individual-setting-description text-[13px] opacity-70">
{{ t("settings.maxSeriesPerQueryDescription") }}
</span>
@@ -143,10 +143,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="flex justify-start">
<OButton
data-test="dashboard-add-submit"
:loading="onSubmit.isLoading.value"
:loading="isSubmitting"
variant="primary"
size="sm-action"
@click="onSubmit.execute"
type="submit"
>
{{ t("dashboard.save") }}
</OButton>
@@ -168,7 +168,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:showIcon="false"
/>
</div>
<div class="">
<div>
<div class="settings-grid-item no-border-bottom grid grid-cols-3 gap-4 items-center py-4 border-b border-(--o2-border-color)">
<span class="individual-setting-title text-sm font-medium leading-5">
{{ t("settings.customLogoText") }}
@@ -192,10 +192,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<OButton
data-test="settings_ent_logo_custom_text_save_btn"
:loading="onSubmit.isLoading.value"
:loading="loadingState"
variant="outline"
size="icon-xs-sq"
type="submit"
type="button"
@click="updateCustomText"
icon-left="check"
/>
@@ -217,11 +217,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</span>
<OButton
data-test="settings_ent_logo_custom_text_edit_btn"
:loading="onSubmit.isLoading.value"
:loading="loadingState"
variant="outline"
size="icon-xs-sq"
class="ml-2"
type="submit"
type="button"
@click="editingText = !editingText"
icon-left="edit"
/>
@@ -286,10 +286,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<OButton
data-test="settings_ent_logo_custom_light_save_btn"
:loading="onSubmit.isLoading.value"
:loading="loadingState"
variant="outline"
size="icon-xs-sq"
type="submit"
type="button"
@click="uploadImage(filesLight, 'light')"
icon-left="check"
/>
@@ -357,10 +357,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<OButton
data-test="settings_ent_logo_custom_dark_save_btn"
:loading="onSubmit.isLoading.value"
:loading="loadingState"
variant="outline"
size="icon-xs-sq"
type="submit"
type="button"
@click="uploadImage(filesDark, 'dark')"
icon-left="check"
/>
@@ -405,11 +405,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts">
// @ts-ignore
import { defineComponent, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, defineComponent, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
import { useLoading } from "@/composables/useLoading";
import organizations from "@/services/organizations";
import settingsService from "@/services/settings";
import config from "@/aws-exports";
@@ -426,8 +425,13 @@ import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OFile from "@/lib/forms/File/OFile.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OColor from "@/lib/forms/Color/OColor.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import {
makeGeneralSettingsSchema,
type GeneralSettingsForm,
} from "./General.schema";
export default defineComponent({
name: "PageGeneralSettings",
@@ -454,6 +458,7 @@ export default defineComponent({
OInput,
OFile,
OForm,
OFormInput,
OColor,
},
setup() {
@@ -461,16 +466,20 @@ export default defineComponent({
const store = useStore();
const router: any = useRouter();
const scrapeIntervalError = ref("");
const maxSeriesError = ref("");
const scrapeIntereval = ref(
store.state?.organizationData?.organizationSettings?.scrape_interval ??
// Schema-driven validation replaces the manual scrapeIntervalError /
// maxSeriesError refs + the imperative if-checks in onSubmit.
// Built once from the component's `t` so the messages are localized.
const generalSettingsSchema = makeGeneralSettingsSchema(t);
// Dynamic defaults (edit-prefill from the store) → a typed computed.
const generalSettingsDefaults = computed((): GeneralSettingsForm => ({
scrape_interval:
store.state?.organizationData?.organizationSettings?.scrape_interval ??
15,
);
const maxSeriesPerQuery = ref(
store.state?.organizationData?.organizationSettings
?.max_series_per_query ?? null,
);
max_series_per_query:
store.state?.organizationData?.organizationSettings
?.max_series_per_query ?? null,
}));
const loadingState = ref(false);
const customText = ref("");
@@ -514,10 +523,7 @@ export default defineComponent({
* Priority: Vuex store tempThemeColors > organizationSettings > defaults
*/
const updateFromStore = () => {
// Update scrape interval setting
scrapeIntereval.value =
store.state?.organizationData?.organizationSettings?.scrape_interval ??
15;
// (scrape_interval is now form-owned via :default-values — no ref to sync.)
// Get theme colors from store with priority order
// 1. Check Vuex store for temporary preview colors (highest priority)
@@ -585,44 +591,24 @@ export default defineComponent({
},
);
const onSubmit = useLoading(async () => {
// Validate scrape interval
if (!scrapeIntereval.value && scrapeIntereval.value !== 0) {
scrapeIntervalError.value = t("settings.scrapeIntervalRequired") || "Scrape interval is required";
return;
}
if (scrapeIntereval.value < 0) {
scrapeIntervalError.value = t("settings.scrapeIntervalPositive") || "Scrape interval must be a positive number";
return;
}
// Validate max series per query (optional field — only validate when provided)
if (
maxSeriesPerQuery.value !== null &&
maxSeriesPerQuery.value !== undefined &&
maxSeriesPerQuery.value !== ""
) {
if (maxSeriesPerQuery.value < 1000) {
maxSeriesError.value = t("settings.maxSeriesMinError") || "Minimum value is 1000";
return;
}
if (maxSeriesPerQuery.value > 1000000) {
maxSeriesError.value = t("settings.maxSeriesMaxError") || "Maximum value is 1000000";
return;
}
}
// @submit handler — fires only once the schema passes (scrape_interval
// required + ≥0; max_series_per_query optional range). The <input
// type="number"> emits a string, so coerce at use. Awaited by OForm so the
// inline Save button's spinner spans the POST (no useLoading wrapper).
const saveGeneralSettings = async (value: GeneralSettingsForm) => {
const maxSeriesRaw = value.max_series_per_query;
const maxSeriesNum =
maxSeriesRaw === null || maxSeriesRaw === undefined || maxSeriesRaw === ""
? null
: Number(maxSeriesRaw);
try {
//set organizations settings in store
//scrape interval will be in number
store.dispatch("setOrganizationSettings", {
...store.state?.organizationData?.organizationSettings,
scrape_interval: scrapeIntereval.value,
max_series_per_query:
maxSeriesPerQuery.value === null ||
maxSeriesPerQuery.value === undefined ||
maxSeriesPerQuery.value === ""
? null
: maxSeriesPerQuery.value,
scrape_interval: Number(value.scrape_interval),
max_series_per_query: maxSeriesNum,
light_mode_theme_color: customLightColor.value,
dark_mode_theme_color: customDarkColor.value,
});
@@ -654,7 +640,7 @@ export default defineComponent({
message: err?.message || t("settings.somethingWentWrong"),
});
}
});
};
const uploadImage = (fileList: any = null, theme: string = "light") => {
const selectedFiles = fileList || files.value;
@@ -1024,11 +1010,11 @@ export default defineComponent({
store,
config,
router,
scrapeIntereval,
scrapeIntervalError,
maxSeriesPerQuery,
maxSeriesError,
onSubmit,
// Form wiring (Options-API: schema + defaults MUST be returned so :schema
// resolves and validation runs).
generalSettingsSchema,
generalSettingsDefaults,
saveGeneralSettings,
files,
filesLight,
filesDark,
@@ -1088,4 +1074,3 @@ export default defineComponent({
border-color: rgba(255, 255, 255, 0.25);
}
</style>
@@ -0,0 +1,23 @@
// Copyright 2026 OpenObserve Inc.
//
// Validation schema for the License key entry (License.vue). The same
// `licenseKey` field is entered in two mutually-exclusive cards (the "no
// license" card and the "update license" card); both share this schema.
//
// Replaces the `:disabled="!licenseKey.trim()"` Save gate with a schema rule
// (R3 — the Save button stays enabled; the schema blocks an empty submit).
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid.
import { z } from "zod";
export const makeLicenseSchema = (t: (_key: string) => string) =>
z.object({
licenseKey: z
.string()
.trim()
.min(1, t("about.license_key_required") || "License key is required"),
});
export type LicenseForm = z.infer<ReturnType<typeof makeLicenseSchema>>;
+53 -3
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import License from './License.vue';
import { makeLicenseSchema } from './License.schema';
import licenseServer from '@/services/license_server';
import { createStore } from 'vuex';
import i18n from '@/locales';
@@ -260,14 +261,14 @@ describe('License.vue', () => {
expect(getLicenseBtn).toBeTruthy();
});
it('should disable Update License button when license key is empty', () => {
it('keeps the Update License button enabled even when empty (R3 — schema-gated)', () => {
const updateBtn = wrapper.findAll('button').find((btn: any) =>
btn.text().includes('Update License')
);
expect(updateBtn?.attributes('disabled')).toBeDefined();
expect(updateBtn?.attributes('disabled')).toBeUndefined();
});
it('should enable Update License button when license key is entered', async () => {
it('should keep the Update License button enabled when a key is entered', async () => {
const textarea = wrapper.find('textarea');
await textarea.setValue('new-license-key-value');
await wrapper.vm.$nextTick();
@@ -277,6 +278,36 @@ describe('License.vue', () => {
);
expect(updateBtn?.attributes('disabled')).toBeUndefined();
});
it('blocks the submit and does NOT call update_license when the key is empty (real OForm)', async () => {
vi.mocked(licenseServer.update_license).mockResolvedValue(
createAxiosResponse({ success: true }),
);
const form = wrapper.findComponent({ name: 'OForm' });
expect(form.exists()).toBe(true);
await (form.vm as any).form.handleSubmit();
await flushPromises();
expect((form.vm as any).form.state.isValid).toBe(false);
expect(licenseServer.update_license).not.toHaveBeenCalled();
});
it('submits when a valid key is entered (real OForm)', async () => {
vi.mocked(licenseServer.update_license).mockResolvedValue(
createAxiosResponse({ success: true }),
);
const textarea = wrapper.find('textarea');
await textarea.setValue('a-valid-license-key');
await wrapper.vm.$nextTick();
const form = wrapper.findComponent({ name: 'OForm' });
await (form.vm as any).form.handleSubmit();
await flushPromises();
expect(licenseServer.update_license).toHaveBeenCalledWith(
'a-valid-license-key',
);
});
});
describe('Active License State', () => {
@@ -972,3 +1003,22 @@ describe('License.vue', () => {
});
});
});
describe('makeLicenseSchema', () => {
const schema = makeLicenseSchema((k: string) => k);
it('rejects a whitespace-only key (the .trim() rule is load-bearing)', () => {
// Without .trim() a single space passes .min(1); this guards that.
expect(schema.safeParse({ licenseKey: ' ' }).success).toBe(false);
});
it('rejects an empty key', () => {
expect(schema.safeParse({ licenseKey: '' }).success).toBe(false);
});
it('accepts a non-empty key and trims surrounding whitespace', () => {
const res = schema.safeParse({ licenseKey: ' abc-123 ' });
expect(res.success).toBe(true);
if (res.success) expect(res.data.licenseKey).toBe('abc-123');
});
});
+139 -85
View File
@@ -1,4 +1,4 @@
<template>
<template>
<!-- Page gutter is owned by the Settings shell's ConstrainedPage. -->
<div>
<LicensePeriod @updateLicense="showUpdateFormAndFocus"></LicensePeriod>
@@ -41,36 +41,43 @@
<div class="text-base font-medium mb-3">
{{ t("about.enter_license_key") }}
</div>
<OTextarea
data-test="no-license-key-input"
v-model="licenseKey"
:rows="8"
:placeholder="t('about.paste_license_placeholder')"
style="min-height: 200px"
/>
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
<OIcon
name="check-circle"
class="text-green-600 mr-2"
size="md"
/>
<span class="text-sm">{{
t("about.license_auto_filled")
}}</span>
</div>
</div>
<OButton
data-test="no-license-update-btn"
variant="primary"
size="sm-action"
class="mt-2"
:loading="updating"
:disabled="!licenseKey.trim()"
@click="updateLicense"
<OForm
ref="noLicenseForm"
:schema="licenseSchema"
:default-values="licenseDefaults"
@submit="updateLicense"
v-slot="{ isSubmitting }"
>
{{ t("about.update_license") }}
</OButton>
<OFormTextarea
data-test="no-license-key-input"
name="licenseKey"
:rows="8"
:placeholder="t('about.paste_license_placeholder')"
style="min-height: 200px"
/>
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
<OIcon
name="check-circle"
class="text-green-600 mr-2"
size="md"
/>
<span class="text-sm">{{
t("about.license_auto_filled")
}}</span>
</div>
</div>
<OButton
data-test="no-license-update-btn"
variant="primary"
size="sm-action"
class="mt-2"
type="submit"
:loading="isSubmitting"
>
{{ t("about.update_license") }}
</OButton>
</OForm>
</OCardSection>
</OCard>
</div>
@@ -98,10 +105,13 @@
{{ t("about.status_lbl") }}
</td>
<td class="px-3 py-2 leading-[1.2]">
<OTag
type="licenseStatus"
:value="licenseData?.expired ? 'expired' : 'active'"
/>
<OBadge :variant="licenseData?.expired ? 'error' : 'success'">
{{
licenseData?.expired
? t("about.expired_lbl")
: t("about.active_lbl")
}}
</OBadge>
</td>
</tr>
<tr class="border-b border-solid border-[var(--o2-border-color)]">
@@ -194,48 +204,57 @@
<div class="text-base font-medium mb-2">
{{ t("about.update_license_key") }}
</div>
<OTextarea
data-test="update-license-key-input"
v-model="licenseKey"
:rows="6"
:placeholder="t('about.paste_new_license_placeholder')"
style="min-height: 150px"
/>
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
<OIcon
name="check-circle"
class="text-green-600 mr-2"
size="md"
/>
<span class="text-sm">{{
t("about.license_auto_filled")
}}</span>
<OForm
ref="updateLicenseForm"
:schema="licenseSchema"
:default-values="licenseDefaults"
@submit="updateLicense"
v-slot="{ isSubmitting }"
>
<OFormTextarea
data-test="update-license-key-input"
name="licenseKey"
:rows="6"
:placeholder="t('about.paste_new_license_placeholder')"
style="min-height: 150px"
/>
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
<OIcon
name="check-circle"
class="text-green-600 mr-2"
size="md"
/>
<span class="text-sm">{{
t("about.license_auto_filled")
}}</span>
</div>
</div>
</div>
<div class="flex gap-2">
<OButton
data-test="cancel-update-license-btn"
variant="outline"
size="sm-action"
@click="
showUpdateForm = false;
licenseKey = '';
"
>
{{ t("common.cancel") }}
</OButton>
<OButton
data-test="confirm-update-license-btn"
variant="primary"
size="sm-action"
:loading="updating"
:disabled="!licenseKey.trim()"
@click="updateLicense"
>
{{ t("about.update_license") }}
</OButton>
</div>
<div class="flex gap-2">
<OButton
data-test="cancel-update-license-btn"
variant="outline"
size="sm-action"
type="button"
:disabled="isSubmitting"
@click="
showUpdateForm = false;
licenseKey = '';
"
>
{{ t("common.cancel") }}
</OButton>
<OButton
data-test="confirm-update-license-btn"
variant="primary"
size="sm-action"
type="submit"
:loading="isSubmitting"
>
{{ t("about.update_license") }}
</OButton>
</div>
</OForm>
</OCardSection>
</OCard>
</div>
@@ -441,6 +460,7 @@ import {
ref,
onMounted,
computed,
watch,
defineAsyncComponent,
} from "vue";
import { useI18n } from "vue-i18n";
@@ -452,13 +472,16 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormTextarea from "@/lib/forms/Input/OFormTextarea.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OCard from "@/lib/core/Card/OCard.vue";
import OCardSection from "@/lib/core/Card/OCardSection.vue";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import { copyToClipboard } from "@/utils/clipboard";
import { makeLicenseSchema, type LicenseForm } from "./License.schema";
const RenderDashboardCharts = defineAsyncComponent(
() => import("@/views/Dashboards/RenderDashboardCharts.vue"),
@@ -473,8 +496,10 @@ export default defineComponent({
ODialog,
OSpinner,
OIcon,
OTag,
OBadge,
OTextarea,
OForm,
OFormTextarea,
OCard,
OCardSection,
},
@@ -482,7 +507,6 @@ export default defineComponent({
const { t } = useI18n();
const { confirm } = useConfirmDialog();
const loading = ref(false);
const updating = ref(false);
const licenseData = ref<any>({});
const licenseKey = ref("");
const showUpdateForm = ref(false);
@@ -492,6 +516,29 @@ export default defineComponent({
const usageDashboardData = ref<any>(null);
const dashboardRenderKey = ref(0);
// Schema-driven validation replaces the `:disabled="!licenseKey.trim()"`
// gate. The same licenseKey is entered in two mutually-exclusive cards, each
// wrapped in its own <OForm> that OWNS the field (by name). `licenseKey` is
// only the shared INCOMING working value (auto-filled from URL, cleared on
// save/cancel) — bridged one-way into whichever form is mounted via the
// watch below; the typed value is read back from the submit payload, never
// from this ref. Options-API: schema/defaults MUST be returned from setup().
const licenseSchema = makeLicenseSchema(t);
const noLicenseForm = ref<any>(null);
const updateLicenseForm = ref<any>(null);
const licenseDefaults = computed(
(): LicenseForm => ({ licenseKey: licenseKey.value }),
);
// Bridge programmatic licenseKey changes (URL auto-fill, cancel/clear) into
// whichever card's form is mounted. Use form.reset (NOT setFieldValue) so the
// field re-seeds AND submit state resets (submissionAttempts → 0) — no
// post-save "required" flash on the v-show'd update card when clearing to "".
watch(licenseKey, (v) => {
noLicenseForm.value?.form?.reset({ licenseKey: v ?? "" });
updateLicenseForm.value?.form?.reset({ licenseKey: v ?? "" });
});
const loadLicenseData = async () => {
try {
loading.value = true;
@@ -509,10 +556,14 @@ export default defineComponent({
}
};
const updateLicense = async () => {
// @submit handler — fires only once the schema passes (licenseKey required +
// trim), so the `:disabled` gate is gone (R3). Still callable directly with
// no args (reads the shared working ref); OForm awaits it so the inline Save
// button's spinner spans the request.
const updateLicense = async (value?: LicenseForm) => {
const key = (value?.licenseKey ?? licenseKey.value ?? "").trim();
try {
updating.value = true;
await licenseServer.update_license(licenseKey.value.trim());
await licenseServer.update_license(key);
toast({
variant: "success",
message: t("about.license_updated_success"),
@@ -537,8 +588,6 @@ export default defineComponent({
" : " +
(error?.response?.data?.message || "unexpected error"),
});
} finally {
updating.value = false;
}
};
@@ -945,13 +994,19 @@ export default defineComponent({
return {
t,
store,
loading,
updating,
licenseData,
licenseKey,
showUpdateForm,
showLicenseKeyModal,
updateLicense,
// Form wiring (Options-API: schema/defaults MUST be returned so :schema
// resolves and validation runs).
licenseSchema,
licenseDefaults,
noLicenseForm,
updateLicenseForm,
formatDate,
isIngestionUnlimited,
ingestionUsagePercent,
@@ -973,7 +1028,6 @@ export default defineComponent({
generateUsageDashboard,
limitBreachAllowedCount,
DOMPurify,
store,
};
},
});
@@ -0,0 +1,150 @@
// Copyright 2026 OpenObserve Inc.
//
// Full validation schema for ModelPricingEditor.vue — scalars (name +
// match_pattern) AND the dynamic tiers/prices array.
//
// The API model stores prices as a `{ key: perTokenPrice }` MAP; TanStack
// field-arrays operate on ARRAYS, so the form models prices as an array of
// `{ key, value }` rows (value held as PER-MILLION for display). The component
// converts map↔rows at edit-prefill and submit.
//
// Restores the manual-`:error` BEFORE rules (inventory §6b) AND the imperative
// save() guards as schema rules:
// • name: required + max 256
// • match_pattern: required + max 512 + valid RegExp (Rust inline flags stripped)
// • price-row key: not a pure integer, no spaces (per row)
// • condition.usage_key: not a pure integer (non-default tiers)
//
// Two structural rules with no single field to attach to — "default tier needs
// ≥1 non-zero price" and "a draft price has a value but no key" — stay as
// submit-handler guards (toasts), since they don't map to one OForm* field.
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid.
import { z } from "zod";
const PURE_INT = /^\d+$/;
const HAS_SPACE = /\s/;
/**
* Strip Rust/PCRE inline flag groups that JavaScript RegExp doesn't understand
* (e.g. `(?i)`, `(?ims)`). Flag-scoped groups like `(?i:...)` are left intact.
*/
export function stripInlineFlags(pattern: string): string {
return pattern.replace(/\(\?[imsxu]+\)/g, "");
}
// One committed price row. `value` is held as PER-MILLION in the form.
export const priceRowSchema = z.object({
key: z.string(),
value: z.coerce.number(),
});
export const tierConditionSchema = z.object({
usage_key: z.string(),
operator: z.string(),
value: z.coerce.number(),
});
export const tierSchema = z.object({
name: z.string(),
// null for the default (first) tier; an object for conditional tiers.
condition: tierConditionSchema.nullable().optional(),
prices: z.array(priceRowSchema).default([]),
// Staging "add price" row — non-validated form state, auto-committed at submit.
draftKey: z.string().optional().default(""),
draftValue: z.coerce.number().optional().default(0),
});
export const makeModelPricingSchema = (
t: (_key: string, _named?: Record<string, unknown>) => string,
) =>
z
.object({
name: z.string(),
match_pattern: z.string(),
tiers: z.array(tierSchema).default([]),
})
.superRefine((val, ctx) => {
// ── name ──────────────────────────────────────────────────────────────
const name = String(val.name ?? "");
if (!name.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("modelPricing.nameRequired"),
});
} else if (name.length > 256) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("modelPricing.nameTooLong"),
});
}
// ── match_pattern ─────────────────────────────────────────────────────
const pattern = String(val.match_pattern ?? "");
if (!pattern.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match_pattern"],
message: t("modelPricing.patternRequired"),
});
} else if (pattern.length > 512) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match_pattern"],
message: t("modelPricing.patternTooLong"),
});
} else {
try {
new RegExp(stripInlineFlags(pattern));
} catch (e: any) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["match_pattern"],
message: t("modelPricing.invalidRegex", { error: e.message }),
});
}
}
// ── tiers / prices ────────────────────────────────────────────────────
(val.tiers ?? []).forEach((tier, i) => {
// committed price-row keys: not pure-int, no spaces
(tier.prices ?? []).forEach((row, j) => {
const k = String(row.key ?? "").trim();
if (k && PURE_INT.test(k)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["tiers", i, "prices", j, "key"],
message: t("modelPricing.usageKeyPureInteger"),
});
} else if (k && HAS_SPACE.test(k)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["tiers", i, "prices", j, "key"],
message: t("modelPricing.usageKeyContainsSpaces"),
});
}
});
// non-default tier condition.usage_key: not a pure integer
if (i > 0 && tier.condition) {
const ck = String(tier.condition.usage_key ?? "").trim();
if (ck && PURE_INT.test(ck)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["tiers", i, "condition", "usage_key"],
message: t("modelPricing.tierUsageKeyPlainNumber", {
name: tier.name || `#${i + 1}`,
}),
});
}
}
});
});
export type ModelPricingForm = z.infer<ReturnType<typeof makeModelPricingSchema>>;
export type ModelPricingTier = z.infer<typeof tierSchema>;
export type ModelPricingPriceRow = z.infer<typeof priceRowSchema>;
@@ -17,7 +17,10 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import ModelPricingEditor from "./ModelPricingEditor.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import i18n from "@/locales";
import { makeModelPricingSchema } from "./ModelPricingEditor.schema";
// Service mocks
@@ -127,14 +130,17 @@ const ODialogStub = {
// OButton stub — keeps clicks working and the slot content rendered
const OButtonStub = {
name: "OButton",
props: ["variant", "size", "active", "disabled", "loading"],
props: ["variant", "size", "active", "disabled", "loading", "type"],
emits: ["click"],
// Mirror the real OButton default (type="button") so stubbed buttons inside
// the <OForm> don't accidentally submit it on click.
template: `
<button
data-test-stub="o-button"
:data-variant="variant"
:data-size="size"
:data-active="String(active)"
:type="type || 'button'"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
@@ -221,6 +227,28 @@ const createWrapper = (overrides: { query?: Record<string, any> } = {}) => {
});
};
// ── Field-array test helpers: drive the real form (tiers/prices are now
// form-owned; values are PER-MILLION rows). ──────────────────────────────
// OWNER pattern: the component creates the form via useOForm and exposes it as
// `form` (handed to <OForm :form="form">) — read it directly, not via a ref.
const getForm = (wrapper: any) => (wrapper.vm as any).form;
const row = (key: string, value: number) => ({ key, value });
const tier = (overrides: Record<string, any> = {}) => ({
name: "Default",
condition: null,
prices: [] as any[],
draftKey: "",
draftValue: 0,
...overrides,
});
// Seed a valid form: name + pattern + a default tier with one non-zero price.
const fillValid = (wrapper: any, tiers?: any[]) => {
const form = getForm(wrapper);
form.setFieldValue("name", "GPT 4o");
form.setFieldValue("match_pattern", "gpt-4o");
form.setFieldValue("tiers", tiers ?? [tier({ prices: [row("input", 1)] })]);
};
describe("ModelPricingEditor.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -287,14 +315,15 @@ describe("ModelPricingEditor.vue", () => {
});
describe("Initial state", () => {
it("initializes the model with a single default tier", async () => {
it("initializes the form with a single default tier (no prices, no condition)", async () => {
const wrapper = createWrapper();
await flushPromises();
const vm = wrapper.vm as any;
expect(vm.model.tiers).toHaveLength(1);
expect(vm.model.tiers[0].name).toBe("Default");
expect(vm.model.tiers[0].condition).toBeNull();
expect(vm.addState).toHaveLength(1);
await nextTick();
const tiers = (wrapper.vm as any).formTiers;
expect(tiers).toHaveLength(1);
expect(tiers[0].name).toBe("Default");
expect(tiers[0].condition).toBeNull();
expect(tiers[0].prices).toEqual([]);
});
it("loads existing models on mount for shadow-conflict detection", async () => {
@@ -319,159 +348,256 @@ describe("ModelPricingEditor.vue", () => {
});
});
describe("Validation: nameError computed", () => {
it("requires a model name", async () => {
const wrapper = createWrapper();
await flushPromises();
expect((wrapper.vm as any).nameError).toBe("Model name is required");
// The name/match_pattern rules moved from computed refs into the co-located
// Zod schema; test the schema directly (messages resolve via the real i18n).
describe("Schema validation: name", () => {
const schema = makeModelPricingSchema(i18n.global.t as any);
const nameIssue = (name: string) => {
const res = schema.safeParse({ name, match_pattern: "gpt" });
return res.success
? ""
: res.error.issues.find((iss: any) => iss.path[0] === "name")?.message ?? "";
};
it("requires a model name", () => {
expect(nameIssue("")).toBe("Model name is required");
});
it("rejects names over 256 chars", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "a".repeat(257);
await nextTick();
expect((wrapper.vm as any).nameError).toBe("Model name must be 256 characters or fewer");
it("rejects names over 256 chars", () => {
expect(nameIssue("a".repeat(257))).toBe(
"Model name must be 256 characters or fewer",
);
});
it("clears name error for a valid name", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "Valid Name";
await nextTick();
expect((wrapper.vm as any).nameError).toBe("");
it("accepts a valid name", () => {
expect(nameIssue("Valid Name")).toBe("");
});
});
describe("Validation: regexError computed", () => {
it("requires a match pattern", async () => {
const wrapper = createWrapper();
await flushPromises();
expect((wrapper.vm as any).regexError).toBe("Match pattern is required");
describe("Schema validation: match_pattern", () => {
const schema = makeModelPricingSchema(i18n.global.t as any);
const patternIssue = (match_pattern: string) => {
const res = schema.safeParse({ name: "X", match_pattern });
return res.success
? ""
: res.error.issues.find((iss: any) => iss.path[0] === "match_pattern")
?.message ?? "";
};
it("requires a match pattern", () => {
expect(patternIssue("")).toBe("Match pattern is required");
});
it("rejects patterns over 512 chars", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.match_pattern = "a".repeat(513);
await nextTick();
expect((wrapper.vm as any).regexError).toBe("Match pattern must be 512 characters or fewer");
it("rejects patterns over 512 chars", () => {
expect(patternIssue("a".repeat(513))).toBe(
"Match pattern must be 512 characters or fewer",
);
});
it("reports an invalid regex with the parser error message", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.match_pattern = "([";
await nextTick();
expect((wrapper.vm as any).regexError).toContain("Invalid regex:");
it("reports an invalid regex with the parser error message", () => {
expect(patternIssue("([")).toContain("Invalid regex:");
});
it("strips Rust-style inline flags before validating", async () => {
const wrapper = createWrapper();
await flushPromises();
// (?i) is not valid in JS RegExp but the component strips it
(wrapper.vm as any).model.match_pattern = "(?i)gpt-4";
await nextTick();
expect((wrapper.vm as any).regexError).toBe("");
it("strips Rust-style inline flags before validating", () => {
// (?i) is not valid in JS RegExp but the schema strips it
expect(patternIssue("(?i)gpt-4")).toBe("");
});
it("clears the error for valid patterns", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.match_pattern = "gpt-.*";
await nextTick();
expect((wrapper.vm as any).regexError).toBe("");
it("accepts a valid pattern", () => {
expect(patternIssue("gpt-.*")).toBe("");
});
});
describe("Tier management", () => {
it("addTier appends a conditional tier with default condition", async () => {
describe("Schema validation: price-row key", () => {
const schema = makeModelPricingSchema(i18n.global.t as any);
// Isolate the committed price-row key rule (path tiers[i].prices[j].key).
const keyIssue = (key: string) => {
const res = schema.safeParse({
name: "X",
match_pattern: "gpt",
tiers: [
{ name: "Default", condition: null, prices: [{ key, value: 1 }] },
],
});
return res.success
? ""
: res.error.issues.find(
(iss: any) =>
iss.path[0] === "tiers" &&
iss.path[2] === "prices" &&
iss.path[4] === "key",
)?.message ?? "";
};
it("rejects a pure-integer price key", () => {
expect(keyIssue("123")).toBe("Usage key cannot be a pure integer");
});
it("rejects a price key that contains spaces", () => {
expect(keyIssue("input tokens")).toBe(
"Usage key must not contain spaces",
);
});
it("accepts a valid alphanumeric price key", () => {
expect(keyIssue("input")).toBe("");
});
it("ignores an empty price key (blank/draft row)", () => {
expect(keyIssue("")).toBe("");
});
});
describe("Schema validation (real OForm)", () => {
it("blocks submit and calls neither create nor update when name/pattern are empty", async () => {
const wrapper = createWrapper();
await flushPromises();
const form = (wrapper.vm as any).form;
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(mockService.create).not.toHaveBeenCalled();
expect(mockService.update).not.toHaveBeenCalled();
});
});
describe("Tier management (field-array)", () => {
it("addTier appends a conditional tier with a default condition", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).addTier();
const vm = wrapper.vm as any;
expect(vm.model.tiers).toHaveLength(2);
expect(vm.model.tiers[1].condition).toEqual({
await nextTick();
const tiers = (wrapper.vm as any).formTiers;
expect(tiers).toHaveLength(2);
expect(tiers[1].condition).toEqual({
usage_key: "input",
operator: "gt",
value: 200000,
});
expect(vm.addState).toHaveLength(2);
});
it("removeTier removes the tier at the given index", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).addTier();
await nextTick();
(wrapper.vm as any).removeTier(1);
expect((wrapper.vm as any).model.tiers).toHaveLength(1);
expect((wrapper.vm as any).addState).toHaveLength(1);
await nextTick();
expect((wrapper.vm as any).formTiers).toHaveLength(1);
});
it("hides the remove button on the only tier (just one tier rendered)", async () => {
it("hides the remove button on the only tier", async () => {
const wrapper = createWrapper();
await flushPromises();
const outlineDestructive = wrapper
.findAll('[data-test-stub="o-button"]')
.filter((b) => b.attributes("data-variant") === "outline-destructive");
// No tier remove buttons when only the default tier exists (only price-row delete buttons would have this variant — empty prices means no rows)
// No tier remove buttons when only the default tier exists (and the default
// tier starts with no price rows → no price-delete buttons either).
expect(outlineDestructive.length).toBe(0);
});
});
describe("Pricing template helpers", () => {
it("applyTemplate seeds the tier with template keys and zero defaults", async () => {
describe("Pricing template helpers (field-array)", () => {
it("applyTemplate seeds the tier rows with template keys and zero defaults", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier = (wrapper.vm as any).model.tiers[0];
(wrapper.vm as any).applyTemplate(tier, ["input", "output"]);
expect(tier.prices).toEqual({ input: 0, output: 0 });
(wrapper.vm as any).applyTemplate(0, ["input", "output"]);
await nextTick();
const prices = (wrapper.vm as any).formTiers[0].prices;
expect(prices.map((r: any) => r.key)).toEqual(["input", "output"]);
expect(prices.every((r: any) => r.value === 0)).toBe(true);
});
it("applyTemplate preserves existing values for matching keys", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier = (wrapper.vm as any).model.tiers[0];
tier.prices = { input: 0.5, foo: 1 };
(wrapper.vm as any).applyTemplate(tier, ["input", "output"]);
expect(tier.prices).toEqual({ input: 0.5, output: 0 });
getForm(wrapper).setFieldValue("tiers", [
tier({ prices: [row("input", 0.5), row("foo", 1)] }),
]);
await nextTick();
(wrapper.vm as any).applyTemplate(0, ["input", "output"]);
await nextTick();
const prices = (wrapper.vm as any).formTiers[0].prices;
expect(prices.map((r: any) => [r.key, r.value])).toEqual([
["input", 0.5],
["output", 0],
]);
});
it("clearTemplate removes the supplied keys from a tier", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier = (wrapper.vm as any).model.tiers[0];
tier.prices = { input: 0.1, output: 0.2, foo: 0.3 };
(wrapper.vm as any).clearTemplate(tier, ["input", "output"]);
expect(tier.prices).toEqual({ foo: 0.3 });
getForm(wrapper).setFieldValue("tiers", [
tier({ prices: [row("input", 0.1), row("output", 0.2), row("foo", 0.3)] }),
]);
await nextTick();
(wrapper.vm as any).clearTemplate(0, ["input", "output"]);
await nextTick();
const prices = (wrapper.vm as any).formTiers[0].prices;
expect(prices.map((r: any) => r.key)).toEqual(["foo"]);
});
it("isTemplateActive returns true when prices match the template keys exactly", async () => {
it("isTemplateActive returns true when rows match the template keys exactly", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier = { prices: { input: 0, output: 0 } };
expect((wrapper.vm as any).isTemplateActive(tier, ["input", "output"])).toBe(true);
getForm(wrapper).setFieldValue("tiers", [
tier({ prices: [row("input", 0), row("output", 0)] }),
]);
await nextTick();
expect((wrapper.vm as any).isTemplateActive(0, ["input", "output"])).toBe(true);
});
it("isTemplateActive returns false when prices do not match the template", async () => {
it("isTemplateActive returns false when rows do not match the template", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier = { prices: { input: 0 } };
expect((wrapper.vm as any).isTemplateActive(tier, ["input", "output"])).toBe(false);
const tier2 = { prices: { input: 0, output: 0, foo: 0 } };
expect((wrapper.vm as any).isTemplateActive(tier2, ["input", "output"])).toBe(false);
getForm(wrapper).setFieldValue("tiers", [tier({ prices: [row("input", 0)] })]);
await nextTick();
expect((wrapper.vm as any).isTemplateActive(0, ["input", "output"])).toBe(false);
});
});
describe("Price entry helpers", () => {
it("updatePrice and deletePrice mutate tier.prices", async () => {
it("addPrice commits the draft row into the tier and clears the draft", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: { input: 1 } };
(wrapper.vm as any).updatePrice(tier, "output", 2);
expect(tier.prices).toEqual({ input: 1, output: 2 });
(wrapper.vm as any).deletePrice(tier, "input");
expect(tier.prices).toEqual({ output: 2 });
getForm(wrapper).setFieldValue("tiers", [
tier({ draftKey: "input", draftValue: 5 }),
]);
await nextTick();
(wrapper.vm as any).addPrice(0);
await nextTick();
const t = (wrapper.vm as any).formTiers[0];
expect(t.prices.map((r: any) => [r.key, r.value])).toEqual([["input", 5]]);
expect(t.draftKey).toBe("");
expect(t.draftValue).toBe(0);
});
it("addPrice is a no-op when the draft key is empty", async () => {
const wrapper = createWrapper();
await flushPromises();
getForm(wrapper).setFieldValue("tiers", [
tier({ draftKey: " ", draftValue: 5 }),
]);
await nextTick();
(wrapper.vm as any).addPrice(0);
await nextTick();
expect((wrapper.vm as any).formTiers[0].prices).toHaveLength(0);
});
it("removePrice removes the row at the given index", async () => {
const wrapper = createWrapper();
await flushPromises();
getForm(wrapper).setFieldValue("tiers", [
tier({ prices: [row("a", 1), row("b", 2)] }),
]);
await nextTick();
(wrapper.vm as any).removePrice(0, 0);
await nextTick();
const prices = (wrapper.vm as any).formTiers[0].prices;
expect(prices.map((r: any) => r.key)).toEqual(["b"]);
});
it("toPerMillion / fromPerMillion are inverses for non-zero", async () => {
@@ -505,119 +631,22 @@ describe("ModelPricingEditor.vue", () => {
});
});
describe("addPrice", () => {
it("returns true and is a no-op when the key is empty", async () => {
describe("previewEntries (field-array)", () => {
it("appends a pending row when the draft has a key", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: {} };
(wrapper.vm as any).addState[0] = { key: " ", value: 5 };
const result = (wrapper.vm as any).addPrice(tier, 0);
expect(result).toBe(true);
expect(tier.prices).toEqual({});
});
it("rejects keys that are pure integers and notifies", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: {} };
(wrapper.vm as any).addState[0] = { key: "123", value: 1 };
const result = (wrapper.vm as any).addPrice(tier, 0);
expect(result).toBe(false);
expect(mockToastFn).toHaveBeenCalledWith(
expect.objectContaining({ variant: "error", message: "Usage key cannot be a pure integer" }),
);
});
it("rejects keys containing spaces and notifies", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: {} };
(wrapper.vm as any).addState[0] = { key: "input tokens", value: 1 };
const result = (wrapper.vm as any).addPrice(tier, 0);
expect(result).toBe(false);
expect(mockToastFn).toHaveBeenCalledWith(
expect.objectContaining({ variant: "error", message: "Usage key must not contain spaces" }),
);
});
it("commits the pending entry and resets addState", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: {} };
(wrapper.vm as any).addState[0] = { key: "input", value: 1 }; // 1 per million
const result = (wrapper.vm as any).addPrice(tier, 0);
expect(result).toBe(true);
expect(tier.prices.input).toBeCloseTo(0.000001);
expect((wrapper.vm as any).addState[0]).toEqual({ key: "", value: 0 });
});
});
describe("renamePriceByIndex", () => {
it("renames the key at the same position preserving order and value", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: { a: 1, b: 2, c: 3 } };
(wrapper.vm as any).renamePriceByIndex(tier, 1, "B");
expect(Object.keys(tier.prices)).toEqual(["a", "B", "c"]);
expect(tier.prices.B).toBe(2);
});
it("is a no-op when newKey equals oldKey", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: { a: 1 } };
(wrapper.vm as any).renamePriceByIndex(tier, 0, "a");
expect(tier.prices).toEqual({ a: 1 });
});
it("notifies and aborts when the new key fails validation", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: { a: 1 } };
(wrapper.vm as any).renamePriceByIndex(tier, 0, "1");
expect(tier.prices).toEqual({ a: 1 });
expect(mockToastFn).toHaveBeenCalled();
});
it("ignores out-of-range indices", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: { a: 1 } };
(wrapper.vm as any).renamePriceByIndex(tier, 5, "x");
expect(tier.prices).toEqual({ a: 1 });
});
});
describe("priceEntries + previewEntries", () => {
it("priceEntries returns stable IDs per index", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier: any = { prices: { a: 1, b: 2 } };
const first = (wrapper.vm as any).priceEntries(tier);
const second = (wrapper.vm as any).priceEntries(tier);
expect(first[0].stableId).toBe(second[0].stableId);
expect(first[1].stableId).toBe(second[1].stableId);
});
it("previewEntries appends a pending row when addState has a key", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier = (wrapper.vm as any).model.tiers[0];
tier.prices = { input: 0.000001 };
(wrapper.vm as any).addState[0] = { key: "output", value: 2 };
const preview = (wrapper.vm as any).previewEntries(tier, 0);
const t = tier({ prices: [row("input", 1)], draftKey: "output", draftValue: 2 });
const preview = (wrapper.vm as any).previewEntries(t);
expect(preview).toHaveLength(2);
const pending = preview.find((p: any) => p.key === "output");
expect(pending.stableId).toBe(-1);
expect(pending.pending).toBe(true);
});
it("previewEntries does not duplicate when pending key already exists", async () => {
it("does not duplicate when the draft key already exists in the rows", async () => {
const wrapper = createWrapper();
await flushPromises();
const tier = (wrapper.vm as any).model.tiers[0];
tier.prices = { input: 0.000001 };
(wrapper.vm as any).addState[0] = { key: "input", value: 99 };
const preview = (wrapper.vm as any).previewEntries(tier, 0);
const t = tier({ prices: [row("input", 1)], draftKey: "input", draftValue: 99 });
const preview = (wrapper.vm as any).previewEntries(t);
expect(preview).toHaveLength(1);
expect(preview[0].key).toBe("input");
});
@@ -694,24 +723,26 @@ describe("ModelPricingEditor.vue", () => {
});
describe("save flow — validation guards", () => {
it("does not call the API when name/regex are invalid", async () => {
it("does not call the API when name/pattern are invalid (schema-gated submit)", async () => {
const wrapper = createWrapper();
await flushPromises();
// both fields empty by default — invalid
await (wrapper.vm as any).save();
// both fields empty by default — invalid; submit through the real form.
const form = getForm(wrapper);
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(mockService.create).not.toHaveBeenCalled();
expect(mockService.update).not.toHaveBeenCalled();
// touched flags are flipped so errors render
expect((wrapper.vm as any).nameTouched).toBe(true);
expect((wrapper.vm as any).patternTouched).toBe(true);
});
it("warns when default tier has no prices and aborts", async () => {
it("warns when the default tier has no prices and aborts", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "M";
(wrapper.vm as any).model.match_pattern = "gpt";
await (wrapper.vm as any).save();
// valid name/pattern, but a default tier with NO price rows
fillValid(wrapper, [tier({ prices: [] })]);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockToastFn).toHaveBeenCalledWith(
expect.objectContaining({
variant: "error",
@@ -721,14 +752,15 @@ describe("ModelPricingEditor.vue", () => {
expect(mockService.create).not.toHaveBeenCalled();
});
it("warns when a pending row has a value but no key", async () => {
it("warns when a draft row has a value but no key", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "M";
(wrapper.vm as any).model.match_pattern = "gpt";
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
(wrapper.vm as any).addState[0] = { key: "", value: 5 };
await (wrapper.vm as any).save();
fillValid(wrapper, [
tier({ prices: [row("input", 1000)], draftKey: "", draftValue: 5 }),
]);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockToastFn).toHaveBeenCalledWith(
expect.objectContaining({
variant: "error",
@@ -738,21 +770,22 @@ describe("ModelPricingEditor.vue", () => {
expect(mockService.create).not.toHaveBeenCalled();
});
it("rejects a non-default tier whose condition.usage_key is a pure integer", async () => {
it("blocks submit when a non-default tier's condition.usage_key is a pure integer (schema)", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "M";
(wrapper.vm as any).model.match_pattern = "gpt";
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
(wrapper.vm as any).addTier();
(wrapper.vm as any).model.tiers[1].condition.usage_key = "42";
await (wrapper.vm as any).save();
expect(mockToastFn).toHaveBeenCalledWith(
expect.objectContaining({
variant: "error",
message: expect.stringContaining("Usage key cannot be a plain number"),
fillValid(wrapper, [
tier({ prices: [row("input", 1000)] }),
tier({
name: "Tier 2",
condition: { usage_key: "42", operator: "gt", value: 1 },
prices: [],
}),
);
]);
await nextTick();
const form = getForm(wrapper);
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(mockService.create).not.toHaveBeenCalled();
});
});
@@ -761,10 +794,9 @@ describe("ModelPricingEditor.vue", () => {
it("calls create when there is no model id and notifies success", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "GPT 4o";
(wrapper.vm as any).model.match_pattern = "gpt-4o";
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
await (wrapper.vm as any).save();
fillValid(wrapper);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockService.create).toHaveBeenCalledWith(
"test-org",
@@ -776,6 +808,59 @@ describe("ModelPricingEditor.vue", () => {
expect(mockRouterPush).toHaveBeenCalled();
});
it("converts per-million rows back to a per-token price map", async () => {
const wrapper = createWrapper();
await flushPromises();
// 1000 per-million → 0.001 per-token
fillValid(wrapper, [tier({ prices: [row("input", 1000)] })]);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
const payload = mockService.create.mock.calls[0][1];
expect(payload.tiers[0].prices.input).toBeCloseTo(0.001);
});
it("sends the exact API tier shape (keys + types) and leaks no draft/helper fields", async () => {
const wrapper = createWrapper();
await flushPromises();
// A tier with a committed row AND a draft row: the draft must be committed
// into `prices`, and the form-only draftKey/draftValue must NOT leak into
// the payload (the {...value} regression ④b guards against).
fillValid(wrapper, [
tier({
prices: [row("input", 1000)],
draftKey: "output",
draftValue: 2000,
}),
]);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockService.create).toHaveBeenCalledTimes(1);
const payload = mockService.create.mock.calls[0][1];
// top-level scalars — exact value + correct type
expect(typeof payload.name).toBe("string");
expect(payload.name).toBe("GPT 4o");
expect(typeof payload.match_pattern).toBe("string");
expect(payload.match_pattern).toBe("gpt-4o");
expect(Array.isArray(payload.tiers)).toBe(true);
// EXACT tier shape — only {condition, name, prices}; NO draftKey/draftValue leak
const t0 = payload.tiers[0];
expect(Object.keys(t0).sort()).toEqual(["condition", "name", "prices"]);
expect(t0.condition).toBeNull(); // default (first) tier
expect(typeof t0.name).toBe("string");
// prices is a per-token MAP of numbers (committed row + committed draft)
expect(Object.keys(t0.prices).sort()).toEqual(["input", "output"]);
expect(typeof t0.prices.input).toBe("number");
expect(typeof t0.prices.output).toBe("number");
expect(t0.prices.input).toBeCloseTo(0.001); // 1000 per-million → per-token
expect(t0.prices.output).toBeCloseTo(0.002); // 2000 per-million → per-token
});
it("calls update when the model has an id", async () => {
mockService.get.mockResolvedValue({
data: {
@@ -788,7 +873,7 @@ describe("ModelPricingEditor.vue", () => {
});
const wrapper = createWrapper({ query: { id: "abc" } });
await flushPromises();
await (wrapper.vm as any).save();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockService.update).toHaveBeenCalledWith(
"test-org",
@@ -800,12 +885,15 @@ describe("ModelPricingEditor.vue", () => {
it("clears the condition on the default tier before saving", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "M";
(wrapper.vm as any).model.match_pattern = "gpt";
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
// simulate a stale condition on the default tier
(wrapper.vm as any).model.tiers[0].condition = { usage_key: "x", operator: "gt", value: 0 };
await (wrapper.vm as any).save();
// a stale condition on the default tier — should be nulled on save
fillValid(wrapper, [
tier({
prices: [row("input", 1000)],
condition: { usage_key: "x", operator: "gt", value: 0 },
}),
]);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockService.create).toHaveBeenCalledWith(
"test-org",
@@ -817,13 +905,14 @@ describe("ModelPricingEditor.vue", () => {
);
});
it("auto-commits a pending row before saving", async () => {
it("auto-commits a draft row before saving", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "M";
(wrapper.vm as any).model.match_pattern = "gpt";
(wrapper.vm as any).addState[0] = { key: "input", value: 1 };
await (wrapper.vm as any).save();
fillValid(wrapper, [
tier({ prices: [], draftKey: "input", draftValue: 1 }),
]);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockService.create).toHaveBeenCalled();
const payload = mockService.create.mock.calls[0][1];
@@ -846,10 +935,9 @@ describe("ModelPricingEditor.vue", () => {
});
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "Mine";
(wrapper.vm as any).model.match_pattern = "gpt-4o";
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
await (wrapper.vm as any).save();
fillValid(wrapper);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockToastFn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -867,10 +955,9 @@ describe("ModelPricingEditor.vue", () => {
});
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "M";
(wrapper.vm as any).model.match_pattern = "gpt";
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
await (wrapper.vm as any).save();
fillValid(wrapper);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
expect(mockToastFn).toHaveBeenCalledWith(
expect.objectContaining({
@@ -878,7 +965,9 @@ describe("ModelPricingEditor.vue", () => {
message: expect.stringContaining("server boom"),
}),
);
expect((wrapper.vm as any).saving).toBe(false);
// Loading is form-driven: after the awaited submit settles (even on
// error), TanStack's isSubmitting resets — no manual `saving` flag.
expect(getForm(wrapper).state.isSubmitting).toBe(false);
});
it("suppresses 403 error notification (handled globally)", async () => {
@@ -887,10 +976,9 @@ describe("ModelPricingEditor.vue", () => {
});
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "M";
(wrapper.vm as any).model.match_pattern = "gpt";
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
await (wrapper.vm as any).save();
fillValid(wrapper);
await nextTick();
await getForm(wrapper).handleSubmit();
await flushPromises();
const negativeCalls = mockToastFn.mock.calls.filter(
([arg]) => arg && arg.variant === "error",
@@ -900,18 +988,21 @@ describe("ModelPricingEditor.vue", () => {
});
describe("Footer save button state", () => {
it("is disabled when name or regex error is present", async () => {
it("stays enabled even when name/pattern are invalid (R3 — validation is schema-driven)", async () => {
const wrapper = createWrapper();
await flushPromises();
// Both fields empty (invalid) — the Save button is NOT disabled; the
// schema blocks the actual submit instead.
const saveBtn = wrapper.find('[data-test="model-pricing-editor-save-btn"]');
expect(saveBtn.attributes("disabled")).toBeDefined();
expect(saveBtn.attributes("disabled")).toBeUndefined();
});
it("is enabled when name and pattern are valid", async () => {
const wrapper = createWrapper();
await flushPromises();
(wrapper.vm as any).model.name = "valid";
(wrapper.vm as any).model.match_pattern = "gpt-.*";
const form = (wrapper.vm as any).form;
form.setFieldValue("name", "valid");
form.setFieldValue("match_pattern", "gpt-.*");
await nextTick();
const saveBtn = wrapper.find('[data-test="model-pricing-editor-save-btn"]');
expect(saveBtn.attributes("disabled")).toBeUndefined();
@@ -919,7 +1010,7 @@ describe("ModelPricingEditor.vue", () => {
});
describe("Edit-mode hydration", () => {
it("hydrates the model from the service and seeds tier conditions", async () => {
it("hydrates the form from the service, converts prices to rows, and seeds tier conditions", async () => {
mockService.get.mockResolvedValue({
data: {
id: "abc",
@@ -935,11 +1026,22 @@ describe("ModelPricingEditor.vue", () => {
});
const wrapper = createWrapper({ query: { id: "abc" } });
await flushPromises();
const tiers = (wrapper.vm as any).model.tiers;
await nextTick();
// The loaded scalar values are seeded into the form.
const form = getForm(wrapper);
expect(form.state.values.name).toBe("Existing");
expect(form.state.values.match_pattern).toBe("gpt-.*");
// tiers converted to per-million ROW shape; condition defaulted on tier 2.
const tiers = (wrapper.vm as any).formTiers;
expect(tiers).toHaveLength(2);
// 0.001 per-token → 1000 per-million
expect(tiers[0].prices.map((r: any) => [r.key, r.value])).toEqual([
["input", 1000],
]);
expect(tiers[0].condition).toBeNull();
expect(tiers[1].condition).toEqual({ usage_key: "input", operator: "gt", value: 0 });
expect((wrapper.vm as any).nameTouched).toBe(true);
expect((wrapper.vm as any).patternTouched).toBe(true);
});
it("notifies and continues when get() rejects with non-403", async () => {
@@ -956,4 +1058,134 @@ describe("ModelPricingEditor.vue", () => {
);
});
});
// Regression: deleting a non-last price row must not leave the OTHER rows'
// rendered inputs showing stale/shifted values. The form DATA was always
// correct (the preview read it fine); the bug was a v-for :key (stable id) vs
// index-based field-name mismatch, so reused field components kept stale name
// bindings on a middle delete.
describe("Price row delete — rendered inputs stay in sync (field-array keying)", () => {
// Read each price-KEY field's rendered value (the OInput model-value =
// the bound field's value). If the field component is bound to the wrong
// path after a delete, this surfaces it (form data alone would not).
const renderedPriceKeys = (wrapper: any) =>
wrapper
.findAllComponents(OFormInput)
.filter((c: any) => /\.prices\[\d+\]\.key$/.test(c.props("name") || ""))
.map((c: any) => c.findComponent(OInput).props("modelValue"));
it("removing the FIRST of four price rows leaves the other three inputs showing the correct keys", async () => {
const wrapper = createWrapper();
await flushPromises();
getForm(wrapper).setFieldValue("tiers", [
tier({
prices: [
row("input", 1),
row("output", 2),
row("cache_read_input_tokens", 3),
row("output_reasoning_tokens", 4),
],
}),
]);
await nextTick();
await flushPromises();
// sanity — all four rendered in order
expect(renderedPriceKeys(wrapper)).toEqual([
"input",
"output",
"cache_read_input_tokens",
"output_reasoning_tokens",
]);
(wrapper.vm as any).removePrice(0, 0); // delete "input"
await nextTick();
await flushPromises();
// form data is correct (only "input" removed)
expect(
(wrapper.vm as any).formTiers[0].prices.map((p: any) => p.key),
).toEqual(["output", "cache_read_input_tokens", "output_reasoning_tokens"]);
// the RENDERED inputs must match the data — not shifted, not blank
expect(renderedPriceKeys(wrapper)).toEqual([
"output",
"cache_read_input_tokens",
"output_reasoning_tokens",
]);
});
const renderedTierNames = (wrapper: any) =>
wrapper
.findAllComponents(OFormInput)
.filter((c: any) => /^tiers\[\d+\]\.name$/.test(c.props("name") || ""))
.map((c: any) => c.findComponent(OInput).props("modelValue"));
it("removing a MIDDLE tier keeps the remaining tiers' name inputs correct", async () => {
const wrapper = createWrapper();
await flushPromises();
getForm(wrapper).setFieldValue("tiers", [
tier({ name: "Default", prices: [row("input", 1)] }),
tier({
name: "Tier B",
condition: { usage_key: "input", operator: "gt", value: 100 },
}),
tier({
name: "Tier C",
condition: { usage_key: "input", operator: "gt", value: 200 },
}),
]);
await nextTick();
await flushPromises();
expect(renderedTierNames(wrapper)).toEqual(["Default", "Tier B", "Tier C"]);
(wrapper.vm as any).removeTier(1); // delete "Tier B"
await nextTick();
await flushPromises();
expect((wrapper.vm as any).formTiers.map((t: any) => t.name)).toEqual([
"Default",
"Tier C",
]);
expect(renderedTierNames(wrapper)).toEqual(["Default", "Tier C"]);
});
it("edit-mode renders prices in the SAME order the API returns them (frontend does not reorder)", async () => {
mockService.get.mockResolvedValue({
data: {
id: "m1",
name: "GPT",
match_pattern: "gpt-4o",
enabled: true,
tiers: [
{
name: "Default",
condition: null,
// API returns this exact key order
prices: {
input: 0.0000001,
output: 0,
cache_read_input_tokens: 0,
output_reasoning_tokens: 0,
},
},
],
},
});
const wrapper = createWrapper({ query: { id: "m1" } });
await flushPromises();
await nextTick();
// modelToForm preserves the map's key order exactly — no sort, no reverse
expect(
(wrapper.vm as any).formTiers[0].prices.map((p: any) => p.key),
).toEqual([
"input",
"output",
"cache_read_input_tokens",
"output_reasoning_tokens",
]);
});
});
});
+318 -280
View File
@@ -1,4 +1,4 @@
<!-- Copyright 2026 OpenObserve Inc.
<!-- 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
@@ -35,6 +35,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</AppPageHeader>
<!-- Form Body -->
<OForm
:form="form"
v-slot="{ isSubmitting }"
class="flex flex-col"
style="flex: 1; min-height: 0;"
>
<div
class="px-3 py-3"
style="
@@ -76,13 +82,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</OButton>
</div>
<OInput
v-model="model.name"
<OFormInput
name="name"
:placeholder="t('modelPricing.modelNamePlaceholder')"
:error="nameTouched && !!nameError"
:error-message="nameError"
@blur="nameTouched = true"
@update:model-value="nameTouched = true"
data-test="model-pricing-name-input"
/>
</div>
@@ -109,13 +111,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</OButton>
</div>
<OInput
v-model="model.match_pattern"
<OFormInput
name="match_pattern"
:placeholder="t('modelPricing.matchPatternPlaceholder')"
:error="patternTouched && !!regexError"
:error-message="regexError"
@blur="patternTouched = true"
@update:model-value="patternTouched = true"
data-test="model-pricing-pattern-input"
/>
</div>
@@ -206,8 +204,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="form-card-body flex flex-col gap-3 px-4 pt-[10px] pb-2">
<div
v-for="(tier, idx) in model.tiers"
:key="idx as number"
v-for="(tier, idx) in formTiers"
:key="idx"
class="border border-(--o2-border-color) rounded-[10px] overflow-hidden shadow-[0_1px_4px_rgba(0,0,0,0.05)] dark:shadow-[0_1px_4px_rgba(0,0,0,0.3)]"
>
<!-- Tier Header -->
@@ -216,19 +214,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="tier-name-label text-xs font-medium opacity-50 whitespace-nowrap shrink-0">{{
t("modelPricing.tierName")
}}</span>
<OInput
v-model="tier.name"
<OFormInput
:name="`tiers[${idx}].name`"
:placeholder="t('modelPricing.tierNamePlaceholder')"
:data-test="`model-pricing-tier-name-input-${idx}`"
/>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<OButton
v-if="model.tiers.length > 1"
v-if="formTiers.length > 1"
variant="outline-destructive"
size="icon"
type="button"
:data-test="`model-pricing-tier-remove-btn-${idx}`"
@click="removeTier(idx as number)"
@click="removeTier(idx)"
>
<OIcon name="delete" size="sm" />
</OButton>
@@ -239,7 +238,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="tier-body p-3 px-4 flex flex-col gap-3">
<!-- Condition row (non-default tiers only) -->
<div
v-if="(idx as number) > 0 && tier.condition"
v-if="idx > 0 && tier.condition"
class="py-3 px-[14px] rounded-lg bg-[rgba(0,0,0,0.02)] border border-(--o2-border-color) dark:bg-[rgba(255,255,255,0.03)]"
>
<div class="sub-label mb-2 text-[11px] font-semibold tracking-[0.06em] opacity-65">
@@ -247,25 +246,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div class="flex gap-2 items-end flex-nowrap">
<div class="flex-1 min-w-[130px]">
<OInput
v-model="tier.condition.usage_key"
<OFormInput
:name="`tiers[${idx}].condition.usage_key`"
:label="t('modelPricing.usageKeyCol')"
:placeholder="t('modelPricing.usageKeyPlaceholder')"
:data-test="`model-pricing-tier-condition-key-input-${idx}`"
/>
</div>
<div class="w-[90px] flex-shrink-0">
<OSelect
v-model="tier.condition.operator"
<OFormSelect
:name="`tiers[${idx}].condition.operator`"
:options="operators"
labelKey="label"
valueKey="value"
label-key="label"
value-key="value"
:data-test="`model-pricing-tier-condition-operator-select-${idx}`"
/>
</div>
<div class="w-[140px] flex-shrink-0">
<OInput
v-model.number="tier.condition.value"
<OFormInput
:name="`tiers[${idx}].condition.value`"
:label="t('modelPricing.threshold')"
type="number"
:data-test="`model-pricing-tier-condition-value-input-${idx}`"
@@ -283,10 +282,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-for="tpl in usageTemplates"
:key="tpl.name"
variant="pricing-chip"
:active="isTemplateActive(tier, tpl.keys)"
type="button"
:active="isTemplateActive(idx, tpl.keys)"
class="!rounded-[1.25rem] !h-auto !py-[0.3125rem] !px-[0.875rem] !text-xs !font-medium !gap-[0.375rem]"
:data-test="`model-pricing-tier-template-btn-${idx}-${tpl.name.toLowerCase()}`"
@click="applyTemplate(tier, tpl.keys)"
@click="applyTemplate(idx, tpl.keys)"
>
<template #icon-left>
<span
@@ -296,9 +296,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
{{ tpl.name }}
<span
v-if="isTemplateActive(tier, tpl.keys)"
v-if="isTemplateActive(idx, tpl.keys)"
class="text-sm leading-none opacity-75 ml-0.5 hover:opacity-100"
@click.stop="clearTemplate(tier, tpl.keys)"
@click.stop="clearTemplate(idx, tpl.keys)"
>×</span
>
</OButton>
@@ -316,7 +316,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="price-table overflow-hidden">
<!-- Column headers (only when rows exist) -->
<div
v-if="Object.keys(tier.prices).length"
v-if="tier.prices.length"
class="price-table-head grid grid-cols-[1fr_160px_auto] gap-2 py-[6px] px-3 text-[11px] font-semibold tracking-[0.01em] opacity-45"
>
<span>{{ t("modelPricing.usageKeyCol") }}</span>
@@ -324,54 +324,57 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span></span>
</div>
<!-- Existing rows — use stable numeric index as :key so
renaming a key doesn't destroy/recreate the DOM node -->
<!-- Existing rows — form-owned (tiers[i].prices[j]); value
is held PER-MILLION so it binds directly. -->
<!-- Key by INDEX (matching the index-based field names): a
stable-id key makes Vue reuse+reorder row components on a
middle delete, but each field's `form.Field` binds to its
`name` at creation and does NOT re-bind when the name
shifts — so reused rows would show stale (shifted) values.
Index keys keep each position's name fixed. (Same pattern
as CreateDestinationForm's headers.) -->
<div
v-for="(entry, entryIdx) in priceEntries(tier)"
:key="entry.stableId"
class="price-row grid grid-cols-[1fr_160px_auto] gap-2 items-center py-0.5 px-3"
v-for="(_entry, entryIdx) in tier.prices"
:key="entryIdx"
class="price-row grid grid-cols-[1fr_160px_auto] gap-2 items-start py-0.5 px-3"
>
<OInput
:model-value="entry.key"
<OFormInput
:name="`tiers[${idx}].prices[${entryIdx}].key`"
:placeholder="t('modelPricing.usageKeyPlaceholder')"
:data-test="`model-pricing-price-key-input-${idx}-${entryIdx}`"
@update:model-value="
(val: any) => renamePriceByIndex(tier, entryIdx, val)
"
/>
<OInput
:model-value="toPerMillion(entry.value)"
<OFormInput
:name="`tiers[${idx}].prices[${entryIdx}].value`"
type="number"
:min="0"
step="0.01"
:placeholder="t('modelPricing.pricePlaceholder')"
:data-test="`model-pricing-price-value-input-${idx}-${entryIdx}`"
@update:model-value="
(val: any) =>
updatePrice(
tier,
entry.key,
fromPerMillion(Number(val)),
)
"
>
<template #icon-left
><span class="price-dollar text-xs pb-0.5">$</span></template
>
</OInput>
<OButton
variant="outline-destructive"
size="icon"
:data-test="`model-pricing-price-delete-btn-${idx}-${entryIdx}`"
@click="deletePrice(tier, entry.key)"
>
<OIcon name="delete" size="sm" />
</OButton>
</OFormInput>
<!-- Fixed input-height band keeps the delete button centered
against the input row even when the key field grows a
below-field error (row is items-start so the error can't
push the value input / this button downward). -->
<div class="h-8.5 flex items-center">
<OButton
variant="outline-destructive"
size="icon"
type="button"
:data-test="`model-pricing-price-delete-btn-${idx}-${entryIdx}`"
@click="removePrice(idx, entryIdx)"
>
<OIcon name="delete" size="sm" />
</OButton>
</div>
</div>
<!-- Empty state -->
<div
v-if="!Object.keys(tier.prices).length"
v-if="!tier.prices.length"
class="price-empty flex flex-col items-center p-4 gap-[3px]"
>
<div class="price-empty-title text-xs font-medium">
@@ -382,21 +385,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<!-- Add row -->
<!-- Add row (staging draft, form-owned) -->
<div
class="price-add-row grid grid-cols-[1fr_160px_auto] gap-2 items-center py-1 px-3"
:class="{
'price-add-row--no-top': !Object.keys(tier.prices)
.length,
'price-add-row--no-top': !tier.prices.length,
}"
>
<OInput
v-model="addState[idx as number].key"
<OFormInput
:name="`tiers[${idx}].draftKey`"
:placeholder="t('modelPricing.addUsageKeyPlaceholder')"
:data-test="`model-pricing-add-price-key-input-${idx}`"
/>
<OInput
v-model.number="addState[idx as number].value"
<OFormInput
:name="`tiers[${idx}].draftValue`"
type="number"
:min="0"
step="0.01"
@@ -406,13 +408,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #icon-left
><span class="price-dollar text-xs pb-0.5">$</span></template
>
</OInput>
</OFormInput>
<OButton
variant="outline"
size="icon"
:disabled="!addState[idx as number].key.trim()"
type="button"
:disabled="!String(tier.draftKey ?? '').trim()"
:data-test="`model-pricing-add-price-btn-${idx}`"
@click="addPrice(tier, idx)"
@click="addPrice(idx)"
>
<OIcon name="add" size="xs" />
</OButton>
@@ -421,7 +424,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Price Preview Table -->
<div
v-if="previewEntries(tier, idx as number).length"
v-if="previewEntries(tier).length"
class="mt-5 border rounded"
style="
background: rgba(0, 0, 0, 0.015);
@@ -456,11 +459,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</thead>
<tbody>
<tr
v-for="entry in previewEntries(tier, idx as number)"
:key="entry.stableId"
v-for="entry in previewEntries(tier)"
:key="entry.pending ? '__pending__' : entry.key"
class="border-b last:border-none"
:class="{
'preview-row-pending opacity-50 italic': entry.stableId === -1,
'preview-row-pending opacity-50 italic': entry.pending,
}"
style="border-color: var(--o2-border-color)"
>
@@ -470,10 +473,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ entry.key }}
</td>
<td class="px-4 py-2 text-gray-600">
${{ formatPreviewCost(entry.value, 1000) }}
${{ formatPreviewCost(fromPerMillion(entry.value), 1000) }}
</td>
<td class="px-4 py-2 text-gray-600">
${{ formatPreviewCost(entry.value, 1000000) }}
${{ formatPreviewCost(fromPerMillion(entry.value), 1000000) }}
</td>
</tr>
</tbody>
@@ -485,6 +488,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
variant="outline"
size="sm-action"
type="button"
class="self-start"
data-test="model-pricing-add-tier-btn"
@click="addTier"
@@ -501,6 +505,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
variant="outline"
size="sm-action"
type="button"
:disabled="isSubmitting"
@click="goBack"
data-test="model-pricing-editor-cancel-btn"
>
@@ -509,14 +515,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
variant="primary"
size="sm-action"
:loading="saving"
:disabled="!!nameError || !!regexError"
@click="save"
type="submit"
:loading="isSubmitting"
data-test="model-pricing-editor-save-btn"
>
{{ t("modelPricing.save") }}
</OButton>
</div>
</OForm>
</div>
</template>
@@ -529,25 +535,26 @@ import OButton from "@/lib/core/Button/OButton.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import modelPricingService from "@/services/model_pricing";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import AppPageHeader from "@/components/common/AppPageHeader.vue";
import { copyToClipboard } from "@/utils/clipboard";
import { toast } from "@/lib/feedback/Toast/useToast";
import {
makeModelPricingSchema,
type ModelPricingForm,
} from "./ModelPricingEditor.schema";
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const route = useRoute();
const saving = ref(false);
const existingModels = ref<any[]>([]);
const nameTouched = ref(false);
const patternTouched = ref(false);
const addState = ref<Array<{ key: string; value: number }>>([
{ key: "", value: 0 },
]);
const showExamples = ref(false);
const copiedPattern = ref<string | null>(null);
@@ -574,38 +581,10 @@ const orgIdentifier = computed(
() => store.state.selectedOrganization?.identifier || "",
);
/** Real-time name validation. */
const nameError = computed(() => {
const name = model.value.name;
if (!name || !name.trim()) return t("modelPricing.nameRequired");
if (name.length > 256) return t("modelPricing.nameTooLong");
return "";
});
/**
* Strip Rust/PCRE inline flag groups that JavaScript RegExp doesn't understand.
* Handles: (?i), (?m), (?s), (?x), (?u), and combinations like (?ims).
* Does NOT strip flag-scoped groups like (?i:...) — those are left as non-capturing groups.
*/
function stripInlineFlags(pattern: string): string {
// (?FLAGS) where FLAGS is one or more of i, m, s, x, u — standalone (not followed by ':')
return pattern.replace(/\(\?[imsxu]+\)/g, "");
}
/** Real-time regex validation — shows error as the user types. */
const regexError = computed(() => {
const pattern = model.value.match_pattern;
if (!pattern || !pattern.trim()) return t("modelPricing.patternRequired");
if (pattern.length > 512) return t("modelPricing.patternTooLong");
try {
// Strip Rust-specific inline flags before testing with JS RegExp.
// The backend (Rust regex crate) is the authority; this is a best-effort client check.
new RegExp(stripInlineFlags(pattern));
return "";
} catch (e: any) {
return t("modelPricing.invalidRegex", { error: e.message });
}
});
// Scalar validation is now schema-driven (name + match_pattern). The manual
// nameError/regexError computeds + nameTouched/patternTouched refs + the
// :disabled Save gate are gone (R3).
const modelPricingSchema = makeModelPricingSchema(t);
const isEdit = computed(
() => !!route.query.id && route.query.duplicate !== "true",
@@ -616,6 +595,29 @@ const headerTitle = computed(() =>
const model = ref<any>(createEmptyModel());
// Dynamic defaults (edit-prefill projects the working model) → a typed computed.
// `model.value` holds the API-shaped record (per-token price MAP); the form holds
// the per-million ROW shape, so convert. For edit mode the async load re-seeds
// the form via form.reset(modelToForm(...)) in onBeforeMount once data arrives.
const modelPricingDefaults = computed(
(): ModelPricingForm => modelToForm(model.value),
);
// Rule ③ OWNER pattern: this component OWNS <OForm> and must read the form-owned
// `tiers` array reactively to drive the v-for rows, so it creates the form here
// with useOForm and hands it to <OForm :form="form"> — ONE source of truth, no
// mirror ref / store.subscribe copy. `save` is the awaited submit handler (auto
// Save spinner). Async edit-prefill re-seeds via form.reset() once data loads.
const form = useOForm<ModelPricingForm>({
defaultValues: modelPricingDefaults.value,
schema: modelPricingSchema,
onSubmit: save,
});
// Reactive view of the form-owned `tiers` array — form.useStore tracks array
// mutations (a plain form.state.values read in a computed would not).
const formTiers = form.useStore((s: any) => s.values.tiers ?? []);
function createEmptyModel() {
return {
id: null,
@@ -626,15 +628,80 @@ function createEmptyModel() {
};
}
// API-shaped tier (per-token price map). Used to seed the working `model`.
function newTier(name: string, condition: any = null) {
return { name, condition, prices: {} as Record<string, number> };
}
function resetAddState(tierCount: number) {
addState.value = Array.from({ length: tierCount }, () => ({
key: "",
value: 0,
// ── map ↔ rows converters (the field-array unlock) ───────────────────────────
// One blank FORM tier (per-million row shape) for "Add tier".
function newFormTier(name: string, condition: any = null) {
return {
name,
condition,
prices: [] as Array<{ key: string; value: number }>,
draftKey: "",
draftValue: 0,
};
}
// API model (per-token price MAP) → FORM value (per-million ROW array).
function modelToForm(m: any): ModelPricingForm {
const tiers = (m?.tiers ?? []).map((tier: any, i: number) => ({
name: tier.name ?? "",
condition:
i === 0
? null
: tier.condition
? { ...tier.condition }
: { usage_key: "input", operator: "gt", value: 0 },
prices: Object.entries(tier.prices ?? {}).map(([k, v]) => ({
key: k,
value: toPerMillion(Number(v)),
})),
draftKey: "",
draftValue: 0,
}));
return {
name: m?.name ?? "",
match_pattern: m?.match_pattern ?? "",
tiers,
};
}
// FORM value (per-million ROW array) → API tiers (per-token price MAP). Drops
// blank keys and auto-commits each tier's non-empty draft row.
function formToModelTiers(tiers: any[]): any[] {
return (tiers ?? []).map((tier: any, i: number) => {
const prices: Record<string, number> = {};
for (const row of tier.prices ?? []) {
const k = String(row.key ?? "").trim();
if (k) prices[k] = fromPerMillion(Number(row.value) || 0);
}
const dk = String(tier.draftKey ?? "").trim();
if (dk) prices[dk] = fromPerMillion(Number(tier.draftValue) || 0);
return {
name: tier.name,
condition:
i === 0
? null
: tier.condition
? {
usage_key: tier.condition.usage_key,
operator: tier.condition.operator,
value: Number(tier.condition.value) || 0,
}
: null,
prices,
};
});
}
// Replace the whole form-owned tiers array (the safe field-array mutation, as
// CreateDestinationForm does for `headers`); `formTiers` (form.useStore) re-syncs
// reactively and the template re-renders.
function setTiers(next: any[]) {
form.setFieldValue("tiers", next, { dontUpdateMeta: true });
}
const usageTemplates = [
@@ -669,99 +736,93 @@ const operators = [
{ label: "!=", value: "neq" },
];
// ── Field-array operations (whole-array setFieldValue; `formTiers`
// (form.useStore) re-syncs reactively + re-renders) ──────────────────────
function addTier() {
model.value.tiers.push(
newTier(t("modelPricing.tierConditionalName"), {
setTiers([
...formTiers.value,
newFormTier(t("modelPricing.tierConditionalName"), {
usage_key: "input",
operator: "gt",
value: 200000,
}),
);
addState.value.push({ key: "", value: 0 });
]);
}
function removeTier(idx: number) {
model.value.tiers.splice(idx, 1);
addState.value.splice(idx, 1);
setTiers(formTiers.value.filter((_: any, i: number) => i !== idx));
}
function updatePrice(tier: any, key: string, value: number) {
tier.prices[key] = value;
function removePrice(tierIdx: number, entryIdx: number) {
setTiers(
formTiers.value.map((tier: any, i: number) =>
i !== tierIdx
? tier
: {
...tier,
prices: tier.prices.filter(
(_: any, j: number) => j !== entryIdx,
),
},
),
);
}
function deletePrice(tier: any, key: string) {
delete tier.prices[key];
tier.prices = { ...tier.prices };
// Commit a tier's staging draft row into its committed price rows, then clear
// the draft. (Validation of the key happens schema-side / at submit.)
function addPrice(tierIdx: number) {
setTiers(
formTiers.value.map((tier: any, i: number) => {
if (i !== tierIdx) return tier;
const dk = String(tier.draftKey ?? "").trim();
if (!dk) return tier;
return {
...tier,
prices: [
...tier.prices,
{ key: dk, value: Number(tier.draftValue) || 0 },
],
draftKey: "",
draftValue: 0,
};
}),
);
}
/** Stable ID counter for price entries — survives key renames without resetting. */
let nextStableId = 0;
const stableIdMap = new WeakMap<any, Map<number, number>>();
/** Convert tier.prices object to an array with stable IDs for v-for :key.
* Each entry gets a numeric stableId that persists across key renames. */
function priceEntries(
tier: any,
): Array<{ key: string; value: number; stableId: number }> {
if (!stableIdMap.has(tier)) stableIdMap.set(tier, new Map());
const idMap = stableIdMap.get(tier)!;
return Object.entries(tier.prices || {}).map(([k, v], idx) => {
if (!idMap.has(idx)) idMap.set(idx, nextStableId++);
return { key: k, value: Number(v), stableId: idMap.get(idx)! };
});
}
/** Rename a price entry by its array index — keeps the DOM node alive. */
function renamePriceByIndex(tier: any, index: number, newKey: string) {
const entries = Object.entries(tier.prices);
if (index < 0 || index >= entries.length) return;
const [oldKey, val] = entries[index];
if (newKey === oldKey) return;
const keyError = validateUsageKey(newKey);
if (keyError) {
notifyWarn(keyError);
return;
}
// Rebuild the object preserving insertion order with the new key at the same position
const rebuilt: Record<string, number> = {};
for (let i = 0; i < entries.length; i++) {
if (i === index) {
rebuilt[newKey] = val as number;
} else {
rebuilt[entries[i][0]] = entries[i][1] as number;
}
}
tier.prices = rebuilt;
}
function renamePrice(tier: any, oldKey: string, newKey: string) {
if (!newKey || newKey === oldKey) return;
const val = tier.prices[oldKey];
delete tier.prices[oldKey];
tier.prices[newKey] = val;
tier.prices = { ...tier.prices };
}
function isTemplateActive(tier: any, templateKeys: string[]): boolean {
const priceKeys = Object.keys(tier.prices || {});
function isTemplateActive(tierIdx: number, templateKeys: string[]): boolean {
const tier = formTiers.value[tierIdx];
if (!tier) return false;
const priceKeys = (tier.prices ?? []).map((r: any) => r.key);
if (priceKeys.length !== templateKeys.length) return false;
return templateKeys.every((k) => priceKeys.includes(k));
}
function applyTemplate(tier: any, keys: string[]) {
const next: Record<string, number> = {};
for (const key of keys) {
next[key] = tier.prices[key] ?? 0;
}
tier.prices = next;
function applyTemplate(tierIdx: number, keys: string[]) {
setTiers(
formTiers.value.map((tier: any, i: number) => {
if (i !== tierIdx) return tier;
const newPrices = keys.map((k) => {
const found = (tier.prices ?? []).find((r: any) => r.key === k);
return { key: k, value: found ? found.value : 0 };
});
return { ...tier, prices: newPrices };
}),
);
}
function clearTemplate(tier: any, keys: string[]) {
const next = { ...tier.prices };
for (const key of keys) {
delete next[key];
}
tier.prices = next;
function clearTemplate(tierIdx: number, keys: string[]) {
setTiers(
formTiers.value.map((tier: any, i: number) =>
i !== tierIdx
? tier
: {
...tier,
prices: (tier.prices ?? []).filter(
(r: any) => !keys.includes(r.key),
),
},
),
);
}
function toPerMillion(perToken: number): number {
@@ -772,22 +833,19 @@ function fromPerMillion(perMillion: number): number {
return perMillion > 0 ? perMillion / 1_000_000 : 0;
}
/** Entries for the live preview = committed prices + any pending add-row entry. */
function previewEntries(tier: any, idx: number) {
const committed = priceEntries(tier);
const pending = addState.value[idx];
if (pending?.key.trim()) {
const pendingValue = fromPerMillion(pending.value || 0);
// Don't duplicate if the key already exists
if (!committed.find((e) => e.key === pending.key.trim())) {
committed.push({
key: pending.key.trim(),
value: pendingValue,
stableId: -1,
});
}
/** Live preview = committed price rows + the (non-empty) draft. Values are
* PER-MILLION; the template converts to per-token for the cost columns. */
function previewEntries(
tier: any,
): Array<{ key: string; value: number; pending?: boolean }> {
const out = (tier.prices ?? [])
.filter((r: any) => String(r.key ?? "").trim())
.map((r: any) => ({ key: String(r.key), value: Number(r.value) || 0 }));
const dk = String(tier.draftKey ?? "").trim();
if (dk && !out.find((e: any) => e.key === dk)) {
out.push({ key: dk, value: Number(tier.draftValue) || 0, pending: true });
}
return committed;
return out;
}
function formatPreviewCost(costPerUnit: number, multiplier: number) {
@@ -806,23 +864,6 @@ function validateUsageKey(key: string): string | null {
return null;
}
function addPrice(tier: any, idx: number | string): boolean {
const i = Number(idx);
const key = (addState.value[i]?.key || "").trim();
if (!key) return true;
const keyError = validateUsageKey(key);
if (keyError) {
notifyWarn(keyError);
return false;
}
tier.prices = {
...tier.prices,
[key]: fromPerMillion(addState.value[i].value || 0),
};
addState.value[i] = { key: "", value: 0 };
return true;
}
function goBack() {
router.push({
name: "modelPricing",
@@ -847,59 +888,55 @@ function notifyError(prefix: string, e: any) {
});
}
async function save() {
const m = model.value;
// @submit handler. The schema validates name/match_pattern + per-row key
// validity + condition.usage_key before @submit fires (R3). Two STRUCTURAL
// rules that don't map to a single field stay here as guards (toasts): a draft
// row with a value but no key, and "the default tier needs ≥1 non-zero price".
// `value` is the raw form value (per-million ROW shape); convert to the API
// (per-token MAP) shape for the payload.
async function save(value?: ModelPricingForm) {
const tiers = (value?.tiers ?? formTiers.value) as any[];
// Auto-commit any pending add-row values before validation.
// Users often type a price and hit Save without clicking "+".
for (let i = 0; i < m.tiers.length; i++) {
const pending = addState.value[i];
if (pending && pending.value > 0 && !pending.key.trim()) {
notifyWarn(
t("modelPricing.tierPriceMissingKey", { name: m.tiers[i].name }),
);
return;
}
if (pending && pending.key.trim()) {
if (!addPrice(m.tiers[i], i)) return;
}
}
// Validate condition usage_key on non-default tiers — must not be a pure integer
for (let i = 1; i < m.tiers.length; i++) {
const c = m.tiers[i].condition;
if (!c) continue;
const key = (c.usage_key || "").trim();
if (key && /^\d+$/.test(key)) {
notifyWarn(
t("modelPricing.tierUsageKeyPlainNumber", {
name: m.tiers[i].name || `#${i + 1}`,
}),
);
// Draft-row guards: a non-empty draft key must be a valid usage key; a draft
// with a value but no key is an error (mirrors the old addPrice/save guards).
for (const tier of tiers) {
const dk = String(tier.draftKey ?? "").trim();
if (dk) {
const keyError = validateUsageKey(dk);
if (keyError) {
notifyWarn(keyError);
return;
}
} else if (Number(tier.draftValue) > 0) {
notifyWarn(t("modelPricing.tierPriceMissingKey", { name: tier.name }));
return;
}
}
// Name and pattern have inline errors — just mark fields as touched so errors show,
// no duplicate snackbar needed.
if (nameError.value || regexError.value) {
nameTouched.value = true;
patternTouched.value = true;
return;
}
// Require at least one price in the default tier (no inline field for this)
const defaultTier = m.tiers?.[0];
// Require at least one non-zero price in the default tier (committed + draft).
const defaultTier = tiers[0];
if (defaultTier) {
const priceValues = Object.values(defaultTier.prices || {}) as number[];
if (priceValues.length === 0 || priceValues.every((v: number) => v === 0)) {
const values = (defaultTier.prices ?? [])
.filter((r: any) => String(r.key ?? "").trim())
.map((r: any) => Number(r.value) || 0);
if (String(defaultTier.draftKey ?? "").trim()) {
values.push(Number(defaultTier.draftValue) || 0);
}
if (values.length === 0 || values.every((v: number) => v === 0)) {
notifyWarn(t("modelPricing.addDefaultPrice"));
return;
}
}
if (m.tiers.length > 0) {
m.tiers[0].condition = null;
}
// Build the API-shaped payload: keep the loaded record's metadata (id,
// enabled, valid_from, sort_order, org_id, source) from `model`, take the
// validated scalars + converted tiers from the form.
const m: any = {
...model.value,
name: value?.name ?? model.value.name,
match_pattern: value?.match_pattern ?? model.value.match_pattern,
tiers: formToModelTiers(tiers),
};
// Warn if another enabled org entry has the same pattern and higher priority
// (same valid_from context — it will shadow this entry at runtime).
@@ -916,7 +953,8 @@ async function save() {
return other.name.localeCompare(m.name) < 0;
});
saving.value = true;
// Loading is form-driven: OForm awaits this handler, so the Save button's
// spinner (isSubmitting) spans the POST — no manual flag needed.
try {
if (m.id) {
await modelPricingService.update(orgIdentifier.value, m.id, m);
@@ -939,8 +977,6 @@ async function save() {
goBack();
} catch (e: any) {
notifyError(t("modelPricing.errSave"), e);
} finally {
saving.value = false;
}
}
@@ -966,8 +1002,6 @@ onBeforeMount(async () => {
const found = res.data;
if (found) {
model.value = JSON.parse(JSON.stringify(found));
nameTouched.value = true;
patternTouched.value = true; // existing model — show validation immediately
if (isDuplicate) {
model.value.id = null;
model.value.org_id = orgIdentifier.value;
@@ -984,12 +1018,16 @@ onBeforeMount(async () => {
};
}
}
// Data arrived after mount → re-seed the whole form (scalars + the
// converted tier/price rows) once (the documented "async edit-prefill"
// pattern; not a per-keystroke watch). formTiers (form.useStore) updates
// reactively from the reset — no manual re-sync.
form.reset(modelToForm(model.value));
}
} catch (e: any) {
notifyError(t("modelPricing.errLoadModel"), e);
}
}
resetAddState(model.value.tiers.length);
});
</script>
@@ -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 <input>), 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(<key>)`:
// • 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<typeof makeOrgStorageEditorSchema>
>;
@@ -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 <http://www.gnu.org/licenses/>.
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: "<div><slot /></div>" },
OStep: { template: "<div><slot /></div>" },
AppPageHeader: { template: "<div><slot name='title' /></div>" },
OIcon: { template: "<span />" },
OButton: {
template: `<button :data-test="$attrs['data-test']" :type="type" @click="$emit('click', $event)"><slot /></button>`,
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",
},
});
});
});
});
+141 -158
View File
@@ -34,7 +34,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Stepper -->
<div class="card-container h-[calc(100vh-7rem)] py-2 px-3 overflow-auto">
<div style="max-width: 720px;">
<OForm ref="storageForm" :default-values="{}" @submit="submitStorage">
<OForm
ref="storageForm"
:schema="orgStorageEditorSchema"
:default-values="orgStorageEditorDefaults"
@submit="submitStorage"
>
<OStepper
v-model="step"
ref="stepper"
@@ -135,51 +140,48 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- AwsCredentials Fields -->
<template v-if="selectedProvider === 'AwsCredentials'">
<div class="flex flex-col gap-y-3">
<OInput
<OFormInput
v-if="!isCloud"
data-test="storage-settings-server-url-input"
v-model="formData.server_url"
name="server_url"
label="Server URL"
class="no-border showLabelOnTop"
flat
:disabled="isEditMode"
/>
<OInput
<OFormInput
data-test="storage-settings-region-input"
v-model="formData.region"
name="region"
label="Region"
class="no-border showLabelOnTop"
flat
:disabled="isEditMode || !!cloudRegion"
/>
<OInput
<OFormInput
data-test="storage-settings-bucket-name-input"
v-model="formData.bucket_name"
label="Bucket Name *"
name="bucket_name"
label="Bucket Name"
required
class="no-border showLabelOnTop"
flat
:disabled="isEditMode"
:error="!!fieldErrors.bucket_name"
:error-message="fieldErrors.bucket_name"
/>
<OInput
<OFormInput
data-test="storage-settings-access-key-input"
v-model="formData.access_key"
label="Access Key *"
name="access_key"
label="Access Key"
required
class="no-border showLabelOnTop"
flat
:error="!!fieldErrors.access_key"
:error-message="fieldErrors.access_key"
/>
<OInput
<OFormInput
data-test="storage-settings-secret-key-input"
v-model="formData.secret_key"
label="Secret Key *"
name="secret_key"
label="Secret Key"
required
class="no-border showLabelOnTop"
flat
type="password"
:error="!!fieldErrors.secret_key"
:error-message="fieldErrors.secret_key"
/>
</div>
</template>
@@ -187,40 +189,37 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- AzureCredentials Fields -->
<template v-if="selectedProvider === 'AzureCredentials'">
<div class="flex flex-col gap-y-3">
<OInput
<OFormInput
data-test="storage-settings-access-key-input"
v-model="formData.storage_account"
label="Storage Account Name *"
name="storage_account"
label="Storage Account Name"
required
class="no-border showLabelOnTop"
flat
:disabled="isEditMode"
:error="!!fieldErrors.storage_account"
:error-message="fieldErrors.storage_account"
/>
<OInput
<OFormInput
data-test="storage-settings-bucket-name-input"
v-model="formData.bucket_name"
label="Bucket Name *"
name="bucket_name"
label="Bucket Name"
required
class="no-border showLabelOnTop"
flat
:disabled="isEditMode"
:error="!!fieldErrors.bucket_name"
:error-message="fieldErrors.bucket_name"
/>
<OInput
<OFormInput
data-test="storage-settings-secret-key-input"
v-model="formData.secret_key"
label="Secret Key *"
name="secret_key"
label="Secret Key"
required
class="no-border showLabelOnTop"
flat
type="password"
:error="!!fieldErrors.secret_key"
:error-message="fieldErrors.secret_key"
/>
<OInput
<OFormInput
v-if="!isCloud"
data-test="storage-settings-server-url-input"
v-model="formData.server_url"
name="server_url"
label="Server URL"
class="no-border showLabelOnTop"
flat
@@ -232,29 +231,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- GcpCredentials Fields -->
<template v-if="selectedProvider === 'GcpCredentials'">
<div class="flex flex-col gap-y-3">
<OInput
<OFormInput
data-test="storage-settings-bucket-name-input"
v-model="formData.bucket_name"
label="Bucket Name *"
name="bucket_name"
label="Bucket Name"
required
class="no-border showLabelOnTop"
flat
:disabled="isEditMode"
:error="!!fieldErrors.bucket_name"
:error-message="fieldErrors.bucket_name"
/>
<OInput
<OFormInput
data-test="storage-settings-access-key-input"
v-model="formData.access_key"
label="Access Key *"
name="access_key"
label="Access Key"
required
class="no-border showLabelOnTop"
flat
:error="!!fieldErrors.access_key"
:error-message="fieldErrors.access_key"
/>
<OInput
<OFormInput
v-if="!isCloud"
data-test="storage-settings-server-url-input"
v-model="formData.server_url"
name="server_url"
label="Server URL"
class="no-border showLabelOnTop"
flat
@@ -282,43 +279,39 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<div class="flex flex-col gap-y-3">
<OInput
<OFormInput
data-test="storage-settings-bucket-name-input"
v-model="formData.bucket_name"
label="Bucket Name *"
name="bucket_name"
label="Bucket Name"
required
class="no-border showLabelOnTop"
flat
:disabled="isEditMode"
:error="!!fieldErrors.bucket_name"
:error-message="fieldErrors.bucket_name"
/>
<OInput
<OFormInput
data-test="storage-settings-region-input"
v-model="formData.region"
label="Region *"
name="region"
label="Region"
required
class="no-border showLabelOnTop"
flat
:disabled="isEditMode || !!cloudRegion"
:error="!!fieldErrors.region"
:error-message="fieldErrors.region"
/>
<OInput
<OFormInput
data-test="storage-settings-role-arn-input"
v-model="formData.role_arn"
label="Role ARN *"
name="role_arn"
label="Role ARN"
required
class="no-border showLabelOnTop"
flat
:error="!!fieldErrors.role_arn"
:error-message="fieldErrors.role_arn"
/>
<OInput
<OFormInput
data-test="storage-settings-role-external-id-input"
v-model="formData.external_id"
label="External Id *"
name="external_id"
label="External Id"
required
class="no-border showLabelOnTop"
flat
:error="!!fieldErrors.external_id"
:error-message="fieldErrors.external_id"
/>
</div>
</template>
@@ -407,7 +400,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, reactive, watch } from "vue";
import { ref, computed, onMounted, watch } from "vue";
defineOptions({ name: "OrgStorageEditor" });
import { useI18n } from "vue-i18n";
@@ -419,10 +412,14 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OStepper from "@/lib/navigation/Stepper/OStepper.vue";
import OStep from "@/lib/navigation/Stepper/OStep.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 AppPageHeader from "@/components/common/AppPageHeader.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import {
makeOrgStorageEditorSchema,
type OrgStorageEditorForm,
} from "./OrgStorageEditor.schema";
const props = defineProps<{
action: "add" | "edit";
@@ -439,15 +436,15 @@ const { t } = useI18n();
const step = ref(1);
const selectedProvider = ref("");
const fieldErrors = reactive<Record<string, string>>({
bucket_name: "",
access_key: "",
secret_key: "",
storage_account: "",
region: "",
role_arn: "",
external_id: "",
});
// OForm instance ref — used for the selectedProvider bridge (setFieldValue),
// provider-change reset, and async edit-prefill (form.reset).
const storageForm = ref<any>(null);
// Schema-driven validation replaces the manual fieldErrors map +
// validateStorageForm(). Provider-discriminated via superRefine on the bridged
// `selectedProvider` field.
const orgStorageEditorSchema = makeOrgStorageEditorSchema(t);
const existingConfig = ref<any>(null);
const isEditMode = computed(() => props.action === "edit");
@@ -458,16 +455,26 @@ const headerTitle = computed(() =>
);
const isCloud = computed(() => config.isCloud === "true");
const formData = reactive({
bucket_name: "",
server_url: "",
region: (store.state as any).zoConfig?.org_storage_region || "",
access_key: "",
storage_account: "",
secret_key: "",
role_arn: "",
external_id: "",
});
// Blank form values for a given provider (region seeded from cloud/store).
function blankFormValues(provider: string): OrgStorageEditorForm {
return {
selectedProvider: provider,
bucket_name: "",
server_url: "",
region: cloudRegion.value || (store.state as any).zoConfig?.org_storage_region || "",
access_key: "",
storage_account: "",
secret_key: "",
role_arn: "",
external_id: "",
};
}
// Dynamic defaults (region from cloud/store, provider projected from the card
// grid) → a typed computed.
const orgStorageEditorDefaults = computed((): OrgStorageEditorForm =>
blankFormValues(selectedProvider.value),
);
const cloudProviders = computed(() => {
const raw = (store.state as any).zoConfig?.org_storage_providers;
@@ -525,74 +532,42 @@ function prevStep() {
if (step.value > 1) step.value--;
}
function resetFormData() {
formData.bucket_name = "";
formData.server_url = "";
formData.region = cloudRegion.value;
formData.access_key = "";
formData.storage_account = "";
formData.secret_key = "";
formData.role_arn = "";
formData.external_id = "";
}
function buildDataPayload() {
function buildDataPayload(value: OrgStorageEditorForm) {
const data: Record<string, string> = {};
switch (selectedProvider.value) {
case "AwsCredentials":
data.bucket_name = formData.bucket_name;
data.server_url = isCloud.value ? "" : formData.server_url;
data.region = formData.region;
data.access_key = formData.access_key;
data.secret_key = formData.secret_key;
data.bucket_name = value.bucket_name;
data.server_url = isCloud.value ? "" : value.server_url;
data.region = value.region;
data.access_key = value.access_key;
data.secret_key = value.secret_key;
break;
case "AzureCredentials":
data.bucket_name = formData.bucket_name;
data.server_url = isCloud.value ? "" : formData.server_url;
data.storage_account = formData.storage_account;
data.secret_key = formData.secret_key;
data.bucket_name = value.bucket_name;
data.server_url = isCloud.value ? "" : value.server_url;
data.storage_account = value.storage_account;
data.secret_key = value.secret_key;
break;
case "GcpCredentials":
data.bucket_name = formData.bucket_name;
data.server_url = isCloud.value ? "" : formData.server_url;
data.access_key = formData.access_key;
data.bucket_name = value.bucket_name;
data.server_url = isCloud.value ? "" : value.server_url;
data.access_key = value.access_key;
break;
case "AwsRoleArn":
data.bucket_name = formData.bucket_name;
data.region = formData.region;
data.role_arn = formData.role_arn;
data.external_id = formData.external_id;
data.bucket_name = value.bucket_name;
data.region = value.region;
data.role_arn = value.role_arn;
data.external_id = value.external_id;
break;
}
return data;
}
function validateStorageForm(): boolean {
Object.keys(fieldErrors).forEach((k) => (fieldErrors[k] = ""));
const p = selectedProvider.value;
if (p === "AwsCredentials") {
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
if (!formData.access_key?.trim()) fieldErrors.access_key = t("storage_settings.accessKeyRequired");
if (!formData.secret_key?.trim()) fieldErrors.secret_key = t("storage_settings.secretKeyRequired");
} else if (p === "AzureCredentials") {
if (!formData.storage_account?.trim()) fieldErrors.storage_account = t("storage_settings.storageAccountRequired");
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
if (!formData.secret_key?.trim()) fieldErrors.secret_key = t("storage_settings.secretKeyRequired");
} else if (p === "GcpCredentials") {
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
if (!formData.access_key?.trim()) fieldErrors.access_key = t("storage_settings.accessKeyRequired");
} else if (p === "AwsRoleArn") {
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
if (!formData.region?.trim()) fieldErrors.region = t("storage_settings.regionRequired");
if (!formData.role_arn?.trim()) fieldErrors.role_arn = t("storage_settings.roleARNRequired");
if (!formData.external_id?.trim()) fieldErrors.external_id = t("storage_settings.externalIdRequired");
}
return Object.values(fieldErrors).every((e) => !e);
}
async function submitStorage() {
if (!validateStorageForm()) return;
// @submit handler — fires only once the provider-discriminated schema passes,
// so the manual validateStorageForm()/fieldErrors are gone. The Save button is
// inline (type=submit inside <OForm>) so Enter works natively; OForm awaits this
// so the spinner spans the POST.
async function submitStorage(value: OrgStorageEditorForm) {
const dismiss = toast({
variant: "loading",
message: "Please wait...",
@@ -602,7 +577,7 @@ async function submitStorage() {
const orgId = store.state.selectedOrganization.identifier;
const payload = {
provider: selectedProvider.value,
data: buildDataPayload(),
data: buildDataPayload(value),
};
try {
@@ -648,17 +623,22 @@ onMounted(async () => {
step.value = 2;
// data is already a parsed object from the API
const parsed = res.data.data || {};
// prefill non-credential fields (they will be disabled in the form)
formData.bucket_name = parsed.bucket_name || "";
formData.server_url = parsed.server_url || "";
formData.region = parsed.region || "";
// credentials must be entered fresh — never prefill with masked values
formData.access_key = "";
formData.secret_key = "";
formData.role_arn = "";
// external id is something that is technically a credential, but not masked
formData.external_id = parsed.external_id || "";
formData.storage_account = parsed.storage_account || "";
// Prefill the form once the (async) config arrives — form.reset, not a
// per-field loop (the documented "data arrives after mount" pattern).
// Non-credential fields are disabled in the form; credentials must be
// entered fresh — never prefill with masked values. external_id is a
// credential but not masked, so it IS prefilled.
storageForm.value?.form?.reset({
selectedProvider: res.data.provider,
bucket_name: parsed.bucket_name || "",
server_url: parsed.server_url || "",
region: parsed.region || "",
access_key: "",
secret_key: "",
role_arn: "",
external_id: parsed.external_id || "",
storage_account: parsed.storage_account || "",
});
}
} catch {
toast({
@@ -669,10 +649,13 @@ onMounted(async () => {
}
});
// Watch for provider change to reset form data (skip in edit mode since provider is locked)
// Bridge the provider card grid into the form (the documented sanctioned
// discriminator bridge) so superRefine can branch on it. On a create-mode
// provider change, reset the credential fields fresh for the new provider.
watch(selectedProvider, (newProvider) => {
storageForm.value?.form?.setFieldValue("selectedProvider", newProvider);
if (newProvider && !isEditMode.value) {
resetFormData();
storageForm.value?.form?.reset(blankFormValues(newProvider));
}
});
</script>
@@ -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 <OForm>, 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<ReturnType<typeof makeContractSchema>>;
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<typeof extendTrialSchema>;
export const extendTrialDefaults = (): ExtendTrialForm => ({ extendedTrial: 1 });
@@ -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 <OForm> 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,
);
}
});
});
@@ -149,27 +149,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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)"
>
<div class="flex flex-col gap-3">
<div class="font-bold">Week(s)</div>
<div class="flex gap-1">
<span
v-for="page in 4"
:key="page"
@click="extendedTrial = page"
:class="[
'cursor-pointer px-2 py-1 border border-gray-300',
extendedTrial === page
? 'bg-(--o2-primary-btn-bg) text-(--o2-primary-btn-text) border-(--o2-primary-btn-bg)'
: 'bg-white text-gray-700 border-gray-300',
]"
>
{{ page }}
</span>
<OForm
id="org-extend-trial-form"
ref="extendTrialFormRef"
:schema="extendTrialSchema"
:default-values="extendTrialDefaults"
@submit="onExtendTrialSubmit"
>
<div class="flex flex-col gap-3">
<div class="font-bold">Week(s)</div>
<div class="flex gap-1">
<span
v-for="page in 4"
:key="page"
@click="extendedTrial = page"
:class="[
'cursor-pointer px-2 py-1 border border-gray-300',
extendedTrial === page
? 'bg-(--o2-primary-btn-bg) text-(--o2-primary-btn-text) border-(--o2-primary-btn-bg)'
: 'bg-white text-gray-700 border-gray-300',
]"
>
{{ page }}
</span>
</div>
</div>
</div>
</OForm>
</ODialog>
<!-- External Contract Dialog -->
@@ -180,25 +188,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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"
>
<div class="mb-3">
<div class="font-bold mb-1">
{{ contractMode === 'create' ? 'End Date' : 'New End Date' }}
</div>
<OInput
v-model="contractEndDate"
type="date"
data-test="contract-end-date-input"
/>
</div>
<div
v-if="contractMode === 'extend' && contractDataRow?.contract_end_date"
class="text-xs text-gray-500"
<OForm
id="org-contract-form"
:schema="contractSchema"
:default-values="contractDefaults()"
@submit="submitContract"
>
Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }}
</div>
<div class="mb-3">
<OFormInput
name="contractEndDate"
type="date"
data-test="contract-end-date-input"
:label="contractMode === 'create' ? 'End Date' : 'New End Date'"
required
/>
</div>
<div
v-if="contractMode === 'extend' && contractDataRow?.contract_end_date"
class="text-xs text-gray-500"
>
Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }}
</div>
</OForm>
</ODialog>
</div>
</template>
@@ -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<any>({});
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 <OForm> 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<any>(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 <input>, 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
// <OForm :default-values> 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,
};
},
});
</script>
</script>
@@ -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<typeof makeOrganizationSettingsSchema>
>;
File diff suppressed because it is too large Load Diff
@@ -8,20 +8,25 @@
</div>
</div>
<div class="mb-3">
<OForm
id="organization-settings-form"
class="mb-3"
:schema="organizationSettingsSchema"
:default-values="organizationSettingsDefaults"
@submit="saveOrgSettings"
v-slot="{ isSubmitting }"
>
<div
data-test="add-role-rolename-input-btn"
class="trace-id-field-name o2-input mb-2 w-100"
>
<OInput
<OFormInput
data-test="settings-org-trace-id-input"
v-model.trim="traceIdFieldName"
:label="t('settings.traceIdFieldName') + ' *'"
name="traceIdFieldName"
:label="t('settings.traceIdFieldName')"
required
class="py-3 showLabelOnTop"
:error="!!traceIdFieldNameError"
:error-message="traceIdFieldNameError"
help-text="Use alphanumeric and '+=,.@-_' characters only, without spaces."
@update:model-value="updateFieldName('trace'); traceIdFieldNameError = ''"
/>
</div>
@@ -29,31 +34,29 @@
data-test="add-role-rolename-input-btn"
class="span-id-field-name o2-input w-100"
>
<OInput
<OFormInput
data-test="settings-org-span-id-input"
v-model.trim="spanIdFieldName"
:label="t('settings.spanIdFieldName') + ' *'"
name="spanIdFieldName"
:label="t('settings.spanIdFieldName')"
required
class="py-3 showLabelOnTop"
:error="!!spanIdFieldNameError"
:error-message="spanIdFieldNameError"
help-text="Use alphanumeric and '+=,.@-_' characters only, without spaces."
@update:model-value="updateFieldName('span'); spanIdFieldNameError = ''"
/>
</div>
<div v-if="config.isCloud !== 'true'" data-test="add-toggle-ingestion" class="span-id-field-name o2-input w-100">
<OSwitch
<OFormSwitch
data-test="add-toggle-ingestion-btn"
v-model="toggleIngestionLogs"
name="toggleIngestionLogs"
:label="t('settings.toggleIngestionLogsLabel')"
class="mt-2"
/>
</div>
<div data-test="add-toggle-usage-stream" class="o2-input">
<OSwitch
<OFormSwitch
data-test="add-toggle-usage-stream-btn"
v-model="usageStreamEnabled"
name="usageStreamEnabled"
:label="t('settings.usageStreamEnabledLabel')"
class="mt-2"
/>
@@ -82,10 +85,10 @@
variant="primary"
size="sm-action"
type="submit"
@click="saveOrgSettings"
:loading="isSubmitting"
>{{ t('alerts.save') }}</OButton>
</div>
</div>
</OForm>
</div>
</template>
@@ -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,
});
</script>
+3
View File
@@ -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.",
+3
View File
@@ -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).",
+3
View File
@@ -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).",
+3
View File
@@ -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).",
+3
View File
@@ -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).",
+3
View File
@@ -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).",
+7 -4
View File
@@ -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} が見つかりません",
+9 -6
View File
@@ -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": "이름",
+3
View File
@@ -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).",
+3
View File
@@ -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).",
+3
View File
@@ -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).",
+9 -6
View File
@@ -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": "姓名",
+3
View File
@@ -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)。",