@@ -67,10 +72,12 @@ import config from "@/aws-exports";
import { getImageURL, verifyOrganizationStatus } from "@/utils/zincutils";
import { resolveTab } from "@/utils/routeTabMaps";
import { firstSourceOption, readFirstSource } from "@/components/login/firstSourceOptions";
+import { featureState } from "@/utils/featureGate";
+import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
export default defineComponent({
name: "RecommendedPage",
- components: { DataSourceSidebarLayout, ORouteTab, FirstSourcePickGroup },
+ components: { DataSourceSidebarLayout, ORouteTab, FirstSourcePickGroup, FeatureLockExempt },
props: {
currOrgIdentifier: {
type: String,
@@ -250,13 +257,29 @@ export default defineComponent({
contentClass: "tab_content",
});
+ // Guides owned by a license feature: locked keeps the tab with a lock, hidden drops it.
+ const LICENSED_TABS: Record = {
+ frontendMonitoring: "rum",
+ recommendedMcp: "mcp",
+ };
+ const tabState = (name: string) =>
+ LICENSED_TABS[name]
+ ? featureState(store.state.zoConfig?.features, LICENSED_TABS[name])
+ : "enabled";
+ const licensedTabs = computed(() =>
+ recommendedTabs.filter((tab) => tabState(tab.name) !== "hidden"),
+ );
+ const lockedTabs = computed(
+ () => new Set(recommendedTabs.map((tab) => tab.name).filter((n) => tabState(n) === "locked")),
+ );
+
// A pick from another category links across to its guide.
const pickTab = computed(() => {
const option = firstSourceOption(
readFirstSource(store.state.selectedOrganization.identifier),
);
if (!option?.route) return undefined;
- const listed = recommendedTabs.find((tab) => tab.name === option.route);
+ const listed = licensedTabs.value.find((tab) => tab.name === option.route);
if (listed) return listed;
return {
name: option.route,
@@ -284,7 +307,8 @@ export default defineComponent({
verifyOrganizationStatus,
tabs,
ingestTabType,
- recommendedTabs,
+ recommendedTabs: licensedTabs,
+ lockedTabs,
pickTab,
pickMatches,
};
diff --git a/web/src/components/ingestion/ai/McpServerCard.spec.ts b/web/src/components/ingestion/ai/McpServerCard.spec.ts
index 471856dcf2..744d76a734 100644
--- a/web/src/components/ingestion/ai/McpServerCard.spec.ts
+++ b/web/src/components/ingestion/ai/McpServerCard.spec.ts
@@ -178,6 +178,39 @@ describe("McpServerCard", () => {
expect(wrapper.find('[data-test="ai-integrations-mcp-generate-btn"]').exists()).toBe(false);
});
+ it("disables Generate with a lock and mints nothing when mcp is license-locked", async () => {
+ const wrapper = mountCard({ features: { mcp: "locked" } });
+
+ const btn = wrapper
+ .findAllComponents({ name: "OButton" })
+ .find((b) => b.attributes("data-test") === "ai-integrations-mcp-generate-btn")!;
+ expect(btn.props("disabled")).toBe(true);
+ expect(btn.props("iconLeft")).toBe("lock");
+ await btn.trigger("click");
+ expect(mcp.generate).not.toHaveBeenCalled();
+ });
+
+ it("disables Manage with a lock when mcp is license-locked", () => {
+ mcp.credential = CREDENTIAL;
+ const wrapper = mountCard({ features: { mcp: "locked" } });
+
+ const btn = wrapper
+ .findAllComponents({ name: "OButton" })
+ .find((b) => b.attributes("data-test") === "ai-integrations-mcp-manage-btn")!;
+ expect(btn.props("disabled")).toBe(true);
+ expect(btn.props("iconLeft")).toBe("lock");
+ });
+
+ it("keeps Generate live when mcp is enabled", () => {
+ const wrapper = mountCard({ features: { mcp: "enabled" } });
+
+ const btn = wrapper
+ .findAllComponents({ name: "OButton" })
+ .find((b) => b.attributes("data-test") === "ai-integrations-mcp-generate-btn")!;
+ expect(btn.props("disabled")).toBeFalsy();
+ expect(btn.props("iconLeft")).toBeUndefined();
+ });
+
it("mints once per click", async () => {
const wrapper = mountCard();
diff --git a/web/src/components/ingestion/ai/McpServerCard.vue b/web/src/components/ingestion/ai/McpServerCard.vue
index 35ef6ccecd..cac6efbdbc 100644
--- a/web/src/components/ingestion/ai/McpServerCard.vue
+++ b/web/src/components/ingestion/ai/McpServerCard.vue
@@ -51,6 +51,8 @@ import OCodeBlock from "@/lib/core/Code/OCodeBlock.vue";
import OText from "@/lib/core/Typography/OText.vue";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
+import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
+import { useLicenseLock } from "@/composables/useFeatureLock";
import SetupCardRenderer from "@/components/ingestion/setupCard/SetupCardRenderer.vue";
import type { RichCardContent } from "@/components/ingestion/setupCard/types";
@@ -63,6 +65,7 @@ const { t } = useI18nTyped();
const store = useStore();
const router = useRouter();
const { generate, generating, error: genError, credential } = useMcpCredential();
+const { locked: mcpLocked, lockedReason: mcpLockedReason } = useLicenseLock("mcp");
const endpoint = computed(() => `${props.subs.url}/api/${props.subs.org}/mcp`);
@@ -508,7 +511,21 @@ const openSkillsRepo = () => {
+
+
+
+ {{ t("ingestion.mcp.credential.generate") }}
+
+
+
{
>
{{ t("ingestion.mcp.credential.download") }}
+
+
+
+ {{ t("ingestion.mcp.credential.manage") }}
+
+
+
{
expect(dashboardBtn.exists()).toBe(true);
});
+ it("locks Add Dashboard and fetches nothing when dashboards are license-locked", async () => {
+ (mockStore.state as any).zoConfig = { features: { dashboards: "locked" } };
+ try {
+ wrapper = createWrapper();
+ const btn = wrapper
+ .findAllComponents({ name: "OButton" })
+ .find((b) => b.attributes("data-test") === "aws-cloudwatch-logs-add-dashboard-btn")!;
+ expect(btn.props("disabled")).toBe(true);
+ expect(btn.props("iconLeft")).toBe("lock");
+ await (wrapper.vm as any).handleAddDashboard();
+ expect(fetch).not.toHaveBeenCalled();
+ } finally {
+ delete (mockStore.state as any).zoConfig;
+ }
+ });
+
it("should render documentation button when documentationUrl present", () => {
wrapper = createWrapper();
const docsBtn = wrapper.find('[data-test="aws-cloudwatch-logs-docs-btn"]');
diff --git a/web/src/components/ingestion/recommended/AWSIntegrationTile.vue b/web/src/components/ingestion/recommended/AWSIntegrationTile.vue
index eefce36e44..f7e4f475b8 100644
--- a/web/src/components/ingestion/recommended/AWSIntegrationTile.vue
+++ b/web/src/components/ingestion/recommended/AWSIntegrationTile.vue
@@ -62,7 +62,21 @@ along with this program. If not, see .
>{{ t("ingestion.awsSetup.documentation") }}
+
+
+ {{ t("ingestion.awsSetup.addDashboard") }}
+
+
createDashboardMutation(dashboardOrgId.value));
+ const { locked: dashboardsLocked, lockedReason: dashboardsLockedReason } =
+ useLicenseLock("dashboards");
const showTemplateDialog = ref(false);
const showComponentContent = ref(false);
const selectedComponent = shallowRef(null);
@@ -423,7 +440,11 @@ export default defineComponent({
};
const handleAddDashboard = async () => {
- if (!props.integration.hasDashboard || !props.integration.dashboardGithubUrl) {
+ if (
+ dashboardsLocked.value ||
+ !props.integration.hasDashboard ||
+ !props.integration.dashboardGithubUrl
+ ) {
return;
}
@@ -581,6 +602,8 @@ export default defineComponent({
store,
handleAddSource,
handleAddDashboard,
+ dashboardsLocked,
+ dashboardsLockedReason,
handleDocumentation,
handleTemplateSelection,
handleComponentSelection,
diff --git a/web/src/components/ingestion/setupCard/DataSourceSetupCard.spec.ts b/web/src/components/ingestion/setupCard/DataSourceSetupCard.spec.ts
index bfd45216de..d39bfa408f 100644
--- a/web/src/components/ingestion/setupCard/DataSourceSetupCard.spec.ts
+++ b/web/src/components/ingestion/setupCard/DataSourceSetupCard.spec.ts
@@ -80,9 +80,10 @@ describe("DataSourceSetupCard — host metrics auto-import wiring", () => {
let store: any;
let router: any;
- const mountCard = (slug: string, passcode = "pc") => {
+ const mountCard = (slug: string, passcode = "pc", features: Record = {}) => {
store = createStore({
state: {
+ zoConfig: { features },
selectedOrganization: { identifier: "test-org" },
userInfo: { email: "t@e.com" },
organizationData: { organizationPasscode: passcode },
@@ -147,6 +148,51 @@ describe("DataSourceSetupCard — host metrics auto-import wiring", () => {
expect(unavailableBanner().exists()).toBe(true);
});
+ describe("license locks", () => {
+ const stepAction = (id: string) =>
+ renderer()
+ .props("content")
+ .steps.find((s: any) => s.action?.id === id)?.action;
+
+ it("skips the auto-import and locks the dashboard action when dashboards are locked", async () => {
+ wrapper = mountCard("linux", "pc", { dashboards: "locked" });
+ renderer().vm.$emit("detected", 4);
+ renderer().vm.$emit("step-action", "view-host-dashboard");
+ await flushPromises();
+
+ expect(importHostMetricsDashboard).not.toHaveBeenCalled();
+ expect(toastMock).not.toHaveBeenCalled();
+ expect(stepAction("view-host-dashboard")).toMatchObject({ disabled: true, icon: "lock" });
+ expect(stepAction("view-host-dashboard").disabledReason).toBeTruthy();
+ });
+
+ it("skips the companion dashboard import when dashboards are locked", async () => {
+ wrapper = mountCard("nvidiaDcgm", "pc", { dashboards: "locked" });
+ renderer().vm.$emit("detected", 1);
+ renderer().vm.$emit("step-action", "open-setup-dashboard");
+ await flushPromises();
+
+ expect(importSetupDashboard).not.toHaveBeenCalled();
+ expect(stepAction("open-setup-dashboard")).toMatchObject({ disabled: true, icon: "lock" });
+ });
+
+ it("drops the View hosts toast action when host monitoring is locked", async () => {
+ wrapper = mountCard("linux", "pc", { host_monitoring: "locked" });
+ renderer().vm.$emit("detected", 4);
+ await flushPromises();
+
+ expect(importHostMetricsDashboard).toHaveBeenCalledTimes(1);
+ expect(toastMock.mock.calls[0][0].action).toBeUndefined();
+ });
+
+ it("leaves the dashboard action untouched when dashboards are enabled", () => {
+ wrapper = mountCard("linux", "pc", { dashboards: "enabled" });
+
+ expect(stepAction("view-host-dashboard").disabled).toBeFalsy();
+ expect(stepAction("view-host-dashboard").disabledReason).toBeUndefined();
+ });
+ });
+
it.each(["linux", "windows", "macos"])(
"imports the host dashboard when detection connects on the %s card",
async (slug) => {
diff --git a/web/src/components/ingestion/setupCard/DataSourceSetupCard.vue b/web/src/components/ingestion/setupCard/DataSourceSetupCard.vue
index 46b239de5a..47db73a231 100644
--- a/web/src/components/ingestion/setupCard/DataSourceSetupCard.vue
+++ b/web/src/components/ingestion/setupCard/DataSourceSetupCard.vue
@@ -39,6 +39,7 @@ import {
importSetupDashboard,
} from "@/composables/useSetupDashboardImport";
import { toast } from "@/lib/feedback/Toast/useToast";
+import { useLicenseLock } from "@/composables/useFeatureLock";
import CredentialCodeBlock from "@/components/ingestion/CredentialCodeBlock.vue";
import IngestionDocLink from "@/components/ingestion/IngestionDocLink.vue";
import SetupCardRenderer from "./SetupCardRenderer.vue";
@@ -84,7 +85,31 @@ const tokenState = computed(() => {
return store.state.organizationData?.organizationPasscode ? "ready" : "unavailable";
});
-const content = computed(() => getDataSourceCard(props.slug, subs.value, t));
+const { locked: dashboardsLocked, lockedReason: dashboardsLockedReason } =
+ useLicenseLock("dashboards");
+const { locked: hostsLocked } = useLicenseLock("host_monitoring");
+const DASHBOARD_ACTIONS = new Set(["view-host-dashboard", "open-setup-dashboard"]);
+
+const content = computed(() => {
+ const card = getDataSourceCard(props.slug, subs.value, t);
+ if (!card || !dashboardsLocked.value) return card;
+ return {
+ ...card,
+ steps: card.steps.map((step) =>
+ step.action && DASHBOARD_ACTIONS.has(step.action.id)
+ ? {
+ ...step,
+ action: {
+ ...step.action,
+ icon: "lock" as const,
+ disabled: true,
+ disabledReason: dashboardsLockedReason.value,
+ },
+ }
+ : step,
+ ),
+ };
+});
const passcodeForbidden = computed(
() => !!store.state.organizationData?.organizationPasscodeForbidden,
@@ -163,6 +188,7 @@ const onOpenSetupDashboard = async () => {
const onDetected = async (count: number) => {
emit("detected", count);
+ if (dashboardsLocked.value) return;
if (setupDashboard.value) return onSetupDashboardDetected();
if (!isHostAgentSlug.value) return;
// Captured at detect time — an org switch before the toast click must not retarget.
@@ -179,14 +205,17 @@ const onDetected = async (count: number) => {
: "ingestion.setupCard.hostDashboardExists",
),
timeout: 5000,
- action: {
- label: t("ingestion.setupCard.viewHosts"),
- handler: () => router.push({ path: "/infra/hosts", query: { org_identifier: org } }),
- },
+ action: hostsLocked.value
+ ? undefined
+ : {
+ label: t("ingestion.setupCard.viewHosts"),
+ handler: () => router.push({ path: "/infra/hosts", query: { org_identifier: org } }),
+ },
});
};
const onStepAction = async (actionId: string) => {
+ if (dashboardsLocked.value && DASHBOARD_ACTIONS.has(actionId)) return;
if (actionId === "open-setup-dashboard") return onOpenSetupDashboard();
if (actionId !== "view-host-dashboard" || !isHostAgentSlug.value) return;
const org = store.state.selectedOrganization?.identifier ?? "";
diff --git a/web/src/components/ingestion/setupCard/SetupCardRenderer.spec.ts b/web/src/components/ingestion/setupCard/SetupCardRenderer.spec.ts
index 98d82b5464..c949703983 100644
--- a/web/src/components/ingestion/setupCard/SetupCardRenderer.spec.ts
+++ b/web/src/components/ingestion/setupCard/SetupCardRenderer.spec.ts
@@ -459,6 +459,32 @@ describe("SetupCardRenderer — first-event bar, detected emit & showOnDetect ac
expect(wrapper.find('[data-test="ai-step-action-launch-console"]').exists()).toBe(true);
});
+ it("renders an action with a disabledReason disabled and emits nothing on click", async () => {
+ const content: RichCardContent = {
+ ...CONTENT,
+ steps: [
+ {
+ ...CONTENT.steps[0],
+ action: {
+ id: "launch-console",
+ label: "Launch",
+ icon: "lock",
+ disabled: true,
+ disabledReason: "Locked" as never,
+ },
+ },
+ CONTENT.steps[1],
+ ],
+ };
+ wrapper = mountCard(content);
+ const btn = wrapper
+ .findAllComponents({ name: "OButton" })
+ .find((b) => b.attributes("data-test") === "ai-step-action-launch-console")!;
+ expect(btn.props("disabled")).toBe(true);
+ await btn.trigger("click");
+ expect(wrapper.emitted("step-action")).toBeUndefined();
+ });
+
it("shows the most-likely-fix box only once the bar has a diagnosis, and rechecks through the bar", async () => {
const content: RichCardContent = {
...CONTENT,
diff --git a/web/src/components/ingestion/setupCard/SetupCardRenderer.vue b/web/src/components/ingestion/setupCard/SetupCardRenderer.vue
index 6ff065c3b8..16adad3ecb 100644
--- a/web/src/components/ingestion/setupCard/SetupCardRenderer.vue
+++ b/web/src/components/ingestion/setupCard/SetupCardRenderer.vue
@@ -845,7 +845,21 @@ function fireConfetti() {
+
+
+
+ {{ step.action.label }}
+
+
+
+const makeStore = (features: Record = {}) =>
createStore({
state: {
+ zoConfig: { features },
selectedOrganization: { identifier: ORG, name: "Test Organization" },
theme: "light",
userInfo: { email: EMAIL },
@@ -117,11 +118,11 @@ describe("GetStarted.vue", () => {
let wrapper: VueWrapper | undefined;
let router: Router;
- const mountAt = async (path = "/") => {
+ const mountAt = async (path = "/", features: Record = {}) => {
router = makeRouter();
await router.push(path);
await router.isReady();
- wrapper = mount(GetStarted, { global: { plugins: [i18n, makeStore(), router] } });
+ wrapper = mount(GetStarted, { global: { plugins: [i18n, makeStore(features), router] } });
await flush();
return wrapper;
};
@@ -249,6 +250,41 @@ describe("GetStarted.vue", () => {
});
});
+ describe("license-locked guides", () => {
+ const radio = (id: string) =>
+ wrapper!.findAllComponents({ name: "ORadio" }).find((r) => r.props("value") === id)!;
+
+ it("disables the agent and RUM cards with a lock, and a locked pick is neither sent nor followed", async () => {
+ await mountAt("/", { mcp: "locked", rum: "locked" });
+ expect(radio("agent").props("disabled")).toBe(true);
+ expect(radio("rum").props("disabled")).toBe(true);
+ expect(radio("linux").props("disabled")).toBe(false);
+ expect(byTest("onboarding-get-started-source-agent-lock").exists()).toBe(true);
+ expect(byTest("onboarding-get-started-source-rum-lock").exists()).toBe(true);
+
+ const push = pushSpy();
+ await pick("agent");
+ await tickTerms();
+ await byTest("onboarding-get-started-skip-btn").trigger("click");
+ await flush();
+ expect(submitNewUserInfo).toHaveBeenCalledWith(ORG, {
+ from: "",
+ company: "",
+ first_source: undefined,
+ skipped: true,
+ });
+ expect(push).not.toHaveBeenCalledWith(expect.objectContaining({ name: "recommendedMcp" }));
+ expect(localStorage.getItem(`o2.onboarding.firstSource.${ORG}`)).toBeNull();
+ });
+
+ it("keeps the agent and RUM cards selectable when their features are enabled", async () => {
+ await mountAt("/", { mcp: "enabled", rum: "enabled" });
+ expect(radio("agent").props("disabled")).toBe(false);
+ expect(radio("rum").props("disabled")).toBe(false);
+ expect(byTest("onboarding-get-started-source-agent-lock").exists()).toBe(false);
+ });
+ });
+
describe("Terms gate, Skip and the pick", () => {
it("disables Continue and Skip until Terms is ticked", async () => {
await mountAt();
diff --git a/web/src/components/login/GetStarted.vue b/web/src/components/login/GetStarted.vue
index 9ed0f1f5b7..26a40e456a 100644
--- a/web/src/components/login/GetStarted.vue
+++ b/web/src/components/login/GetStarted.vue
@@ -69,28 +69,57 @@ along with this program. If not, see .
data-test="onboarding-get-started-source-grid"
>
-
-
-
-
-
- {{ t(option.labelKey) }}
+
+
+
+
+
+
+
+ {{ t(option.labelKey) }}
+
+
+
-
-
+
+
+
+
+
+
+ {{ t(option.labelKey) }}
+
+
+
+
@@ -177,11 +206,15 @@ import {
FIRST_SOURCE_OPTIONS,
clearPrefill,
firstSourceOption,
+ isFirstSourceLocked,
prefillFirstSource,
readPrefill,
writeFirstSource,
type FirstSourceId,
+ type FirstSourceOption,
} from "./firstSourceOptions";
+import { useLicenseLock } from "@/composables/useFeatureLock";
+import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import billings from "@/services/billings";
import analytics from "@/services/product_analytics";
import { toast } from "@/lib/feedback/Toast/useToast";
@@ -206,6 +239,21 @@ const emit = defineEmits(["removeFirstTimeLogin"]);
const orgId = (): string => store.state.selectedOrganization?.identifier ?? "";
+const optionLocks = Object.fromEntries(
+ [...new Set(FIRST_SOURCE_OPTIONS.flatMap((o) => (o.feature ? [o.feature] : [])))].map((key) => [
+ key,
+ useLicenseLock(key),
+ ]),
+);
+const isLocked = (option: FirstSourceOption): boolean =>
+ isFirstSourceLocked(option, store.state.zoConfig?.features);
+const lockedReason = (option: FirstSourceOption) =>
+ optionLocks[option.feature ?? ""]?.lockedReason.value;
+const unlockedPick = (id: FirstSourceId | undefined): FirstSourceId | undefined => {
+ const option = firstSourceOption(id);
+ return option && isLocked(option) ? undefined : id;
+};
+
// Login has already followed a redirectURI or marketplace link by now, so landing anywhere but Home means one was pending.
const redirectPending = (): boolean => {
if (route?.name !== "home") return true;
@@ -254,7 +302,8 @@ const finish = (answers: GetStartedAnswers) => {
if (guideRoute) router.push({ name: guideRoute, query: { org_identifier: org } });
};
-const sendAnswers = async (answers: GetStartedAnswers): Promise => {
+const sendAnswers = async (picked: GetStartedAnswers): Promise => {
+ const answers = { ...picked, firstSource: unlockedPick(picked.firstSource) };
try {
const res = await billings.submit_new_user_info(orgId(), {
from: answers.from,
@@ -282,7 +331,7 @@ const doSubmit = (value: GetStartedForm) =>
});
const form = useOForm({
- defaultValues: getStartedDefaults(prefillFirstSource(readPrefill())),
+ defaultValues: getStartedDefaults(unlockedPick(prefillFirstSource(readPrefill()))),
schema: makeGetStartedSchema(t),
onSubmit: doSubmit,
});
diff --git a/web/src/components/login/firstSourceOptions.spec.ts b/web/src/components/login/firstSourceOptions.spec.ts
index efe14f7887..46643acb6d 100644
--- a/web/src/components/login/firstSourceOptions.spec.ts
+++ b/web/src/components/login/firstSourceOptions.spec.ts
@@ -24,6 +24,7 @@ import {
docsReferrerSource,
firstSourceOption,
firstSourcePickRoute,
+ isFirstSourceLocked,
prefillFirstSource,
readFirstSource,
readPrefill,
@@ -274,4 +275,17 @@ describe("firstSourceOptions", () => {
expect(firstSourceOption("missing")).toBeUndefined();
expect(firstSourceOption(null)).toBeUndefined();
});
+
+ it("locks the agent and RUM options only while their feature is license-locked", () => {
+ const agent = firstSourceOption("agent")!;
+ const rum = firstSourceOption("rum")!;
+ const linux = firstSourceOption("linux")!;
+ const locked = { mcp: "locked", rum: "locked" } as const;
+
+ expect(isFirstSourceLocked(agent, locked)).toBe(true);
+ expect(isFirstSourceLocked(rum, locked)).toBe(true);
+ expect(isFirstSourceLocked(linux, locked)).toBe(false);
+ expect(isFirstSourceLocked(agent, { mcp: "enabled" })).toBe(false);
+ expect(isFirstSourceLocked(agent, undefined)).toBe(false);
+ });
});
diff --git a/web/src/components/login/firstSourceOptions.ts b/web/src/components/login/firstSourceOptions.ts
index 97e27b1852..a566ad7a86 100644
--- a/web/src/components/login/firstSourceOptions.ts
+++ b/web/src/components/login/firstSourceOptions.ts
@@ -16,6 +16,7 @@
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import type { I18nKey } from "@/types/i18n";
import type { UserDataStreamType } from "@/utils/internalStreams";
+import { featureState, type FeatureStates } from "@/utils/featureGate";
export type FirstSourceId =
| "kubernetes"
@@ -43,6 +44,8 @@ export interface FirstSourceOption {
icon?: IconName;
/** Signals the picked guide sends. */
signals: UserDataStreamType[];
+ /** License feature whose locked guide this option opens. */
+ feature?: string;
}
export interface FirstSourcePrefill {
@@ -108,6 +111,7 @@ export const FIRST_SOURCE_OPTIONS: readonly FirstSourceOption[] = Object.freeze(
route: "frontendMonitoring",
icon: "public",
signals: ["logs"],
+ feature: "rum",
},
{
id: "llm",
@@ -123,6 +127,7 @@ export const FIRST_SOURCE_OPTIONS: readonly FirstSourceOption[] = Object.freeze(
route: "recommendedMcp",
icon: "auto-awesome",
signals: [],
+ feature: "mcp",
},
{
id: "unsure",
@@ -162,6 +167,13 @@ export function firstSourceOption(id: string | null | undefined): FirstSourceOpt
return FIRST_SOURCE_OPTIONS.find((option) => option.id === id);
}
+export function isFirstSourceLocked(
+ option: FirstSourceOption,
+ features: FeatureStates | undefined,
+): boolean {
+ return !!option.feature && featureState(features, option.feature) === "locked";
+}
+
/** Maps an openobserve.ai docs URL to the option its guide belongs to. */
export function docsReferrerSource(referrer: string | null | undefined): FirstSourceId | undefined {
if (!referrer) return undefined;
diff --git a/web/src/components/logstream/schema.vue b/web/src/components/logstream/schema.vue
index 3ebe98a1d8..0067af8394 100644
--- a/web/src/components/logstream/schema.vue
+++ b/web/src/components/logstream/schema.vue
@@ -454,7 +454,7 @@ along with this program. If not, see .
diff --git a/web/src/components/oncall/OnCallOwnership.spec.ts b/web/src/components/oncall/OnCallOwnership.spec.ts
index 98fff90f69..4c3f4f760d 100644
--- a/web/src/components/oncall/OnCallOwnership.spec.ts
+++ b/web/src/components/oncall/OnCallOwnership.spec.ts
@@ -420,6 +420,51 @@ describe("OnCallOwnership", () => {
expect(correlation.getDimensionAnalytics).toHaveBeenCalledTimes(1);
});
+ it("skips the correlation reads while correlation is license-locked", async () => {
+ store.state.zoConfig.features = { correlation: "locked" };
+ try {
+ render();
+ await flushPromises();
+ expect(service.ownershipStats).toHaveBeenCalled();
+ expect(correlation.getServicesList).not.toHaveBeenCalled();
+ expect(correlation.getIdentityConfig).not.toHaveBeenCalled();
+ expect(correlation.getDimensionAnalytics).not.toHaveBeenCalled();
+ } finally {
+ delete store.state.zoConfig.features;
+ }
+ });
+
+ it("waits for a late config and makes no correlation read when it says locked", async () => {
+ const saved = store.state.zoConfig;
+ store.state.zoConfig = {} as any;
+ try {
+ render();
+ await flushPromises();
+ expect(correlation.getServicesList).not.toHaveBeenCalled();
+ store.state.zoConfig = { ...saved, features: { correlation: "locked" } } as any;
+ await flushPromises();
+ expect(correlation.getServicesList).not.toHaveBeenCalled();
+ expect(correlation.getIdentityConfig).not.toHaveBeenCalled();
+ expect(correlation.getDimensionAnalytics).not.toHaveBeenCalled();
+ } finally {
+ store.state.zoConfig = saved;
+ }
+ });
+
+ it("reads correlation once the late config lands unlocked", async () => {
+ const saved = store.state.zoConfig;
+ store.state.zoConfig = {} as any;
+ try {
+ render();
+ await flushPromises();
+ store.state.zoConfig = { ...saved } as any;
+ await flushPromises();
+ expect(correlation.getServicesList).toHaveBeenCalledTimes(1);
+ } finally {
+ store.state.zoConfig = saved;
+ }
+ });
+
/// A team with no unrouted traffic and a server without the endpoint look
/// identical from here, and neither deserves an error.
it("survives an unrouted endpoint that is not there", async () => {
diff --git a/web/src/components/oncall/OnCallOwnership.vue b/web/src/components/oncall/OnCallOwnership.vue
index df731499f2..c115449489 100644
--- a/web/src/components/oncall/OnCallOwnership.vue
+++ b/web/src/components/oncall/OnCallOwnership.vue
@@ -73,7 +73,7 @@ along with this program. If not, see
.
diff --git a/web/src/components/oncall/OnCallPhoneCard.spec.ts b/web/src/components/oncall/OnCallPhoneCard.spec.ts
index 614c4db795..505dfa9afa 100644
--- a/web/src/components/oncall/OnCallPhoneCard.spec.ts
+++ b/web/src/components/oncall/OnCallPhoneCard.spec.ts
@@ -18,6 +18,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import OnCallPhoneCard from "@/components/oncall/OnCallPhoneCard.vue";
import i18n from "@/locales";
+import { queryClient } from "@/composables/query/queryClient";
import oncallService from "@/services/oncall";
import store from "@/test/unit/helpers/store";
import type { Contact, RefusalReason, VerificationRefusalBody } from "@/ts/interfaces/oncall";
@@ -228,6 +229,23 @@ describe("OnCallPhoneCard", () => {
expect(service.getContact).toHaveBeenCalledWith({ org_identifier: ORG, user_email: EMAIL });
});
+ it("says nothing about a read the license refused, and still reports any other failure", async () => {
+ service.getContact.mockRejectedValue({
+ response: { status: 403, data: { code: 40001, error_detail: "oncall" } },
+ });
+ const denied = mount(OnCallPhoneCard, { global: { plugins: [i18n, store], stubs } });
+ await flushPromises();
+ expect(find(denied, "load-failed").exists()).toBe(false);
+ denied.unmount();
+
+ queryClient.clear();
+ service.getContact.mockRejectedValue({ response: { status: 403, data: {} } });
+ const failed = mount(OnCallPhoneCard, { global: { plugins: [i18n, store], stubs } });
+ await flushPromises();
+ expect(find(failed, "load-failed").exists()).toBe(true);
+ failed.unmount();
+ });
+
it("empty: offers a number box and Send code", async () => {
const w = await render(contact());
expect(find(w, "input").exists()).toBe(true);
diff --git a/web/src/components/oncall/OnCallPhoneCard.vue b/web/src/components/oncall/OnCallPhoneCard.vue
index 625ebcc776..8ef753a642 100644
--- a/web/src/components/oncall/OnCallPhoneCard.vue
+++ b/web/src/components/oncall/OnCallPhoneCard.vue
@@ -246,6 +246,7 @@ import {
refusalOf,
samePhone,
} from "@/utils/oncall";
+import { licenseDeniedFeatures } from "@/utils/featureGate";
// The server's minimum gap between two codes for one number (R17).
const RESEND_SECS = 60;
@@ -270,7 +271,10 @@ const contactRead = useQuery(() =>
}),
);
const contact = computed(() => contactRead.data.value ?? null);
-const loadFailed = computed(() => contactRead.isError.value);
+// A license denial is not a failed read: the license bar already says why.
+const loadFailed = computed(
+ () => contactRead.isError.value && !licenseDeniedFeatures(contactRead.error.value),
+);
const saveContact = useMutation(() => setContactMutation(orgId.value, email.value));
const sendCodeWrite = useMutation(() => sendCodeMutation(orgId.value, email.value));
diff --git a/web/src/components/oncall/OnCallRuleEditor.vue b/web/src/components/oncall/OnCallRuleEditor.vue
index 2ff6560138..5b14b4e812 100644
--- a/web/src/components/oncall/OnCallRuleEditor.vue
+++ b/web/src/components/oncall/OnCallRuleEditor.vue
@@ -63,6 +63,15 @@ along with this program. If not, see
.
+
+
+
-
-
+
+
-
- {{ s.label }}
- {{ s.count }}
-
-
+
+
{{ s.label }}
+
{{ s.count }}
+
+
+
+
diff --git a/web/src/components/settings/LlmProvidersSettings.vue b/web/src/components/settings/LlmProvidersSettings.vue
index 2e70cca695..93b61a2205 100644
--- a/web/src/components/settings/LlmProvidersSettings.vue
+++ b/web/src/components/settings/LlmProvidersSettings.vue
@@ -30,7 +30,12 @@
@@ -212,11 +217,13 @@ import {
import { showError } from "@/enterprise/components/onlineEvals/utils/evalFormat";
import ProviderFormPage from "@/enterprise/components/onlineEvals/forms/ProviderFormPage.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
+import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import { COL } from "@/lib/core/Table/OTable.types";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
+import { forbiddenState } from "@/utils/featureGate";
import { useListViewState } from "@/composables/useListViewState";
const { t } = useI18nTyped();
@@ -273,7 +280,7 @@ const isFetching = providersList.isFetching;
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
const forbidden = computed(() => {
const e: any = providersList.error.value;
- return e?.status === 403 || e?.response?.status === 403;
+ return forbiddenState(e) || e?.status === 403;
});
const lastUpdatedAt = providersList.dataUpdatedAt;
watch(providersList.error, (err: any) => {
diff --git a/web/src/components/settings/ModelPricingList.vue b/web/src/components/settings/ModelPricingList.vue
index 0b92a02406..b4711c0a8c 100644
--- a/web/src/components/settings/ModelPricingList.vue
+++ b/web/src/components/settings/ModelPricingList.vue
@@ -752,6 +752,7 @@ import {
import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
+import { forbiddenState } from "@/utils/featureGate";
import { useListViewState } from "@/composables/useListViewState";
const { t } = useI18nTyped();
@@ -842,7 +843,7 @@ const lastUpdatedAt = modelsQuery.dataUpdatedAt;
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
const forbidden = computed(() => {
const e: any = modelsQuery.error.value;
- return e?.status === 403 || e?.response?.status === 403;
+ return forbiddenState(e) || e?.status === 403;
});
const refreshing = ref(false);
diff --git a/web/src/components/settings/Nodes.spec.ts b/web/src/components/settings/Nodes.spec.ts
index 9612a6cbd2..649acaeba5 100644
--- a/web/src/components/settings/Nodes.spec.ts
+++ b/web/src/components/settings/Nodes.spec.ts
@@ -397,6 +397,42 @@ describe("Nodes", () => {
const ids = wrapper.vm.computedOTableColumns.map((c: any) => c.id);
expect(ids).not.toContain("region");
});
+
+ it("drops the region column and shows locked region/cluster filters under a super_cluster lock", async () => {
+ const lockedStore = createStore({
+ state: {
+ selectedOrganization: { identifier: "test-org" },
+ zoConfig: { super_cluster_enabled: true, features: { super_cluster: "locked" } },
+ theme: "light",
+ },
+ });
+ wrapper = mount(Nodes, {
+ global: {
+ plugins: [mockI18n],
+ provide: { store: lockedStore },
+ stubs: {
+ Pagination: { template: "
" },
+ NoData: { template: "
" },
+ OTooltip: { template: "
" },
+ },
+ },
+ });
+ await flushPromises();
+ const ids = wrapper.vm.computedOTableColumns.map((c: any) => c.id);
+ expect(ids).not.toContain("region");
+ for (const key of ["region", "cluster"]) {
+ const btn = wrapper.find(`[data-test="nodes-${key}-filter-locked"]`);
+ expect(btn.exists()).toBe(true);
+ expect(btn.attributes("disabled")).toBeDefined();
+ }
+ expect(wrapper.find('[data-test="nodes-region-table"]').exists()).toBe(false);
+ });
+
+ it("shows no locked filters when the license leaves super_cluster enabled", async () => {
+ wrapper = mountComponent();
+ await flushPromises();
+ expect(wrapper.find('[data-test="nodes-region-filter-locked"]').exists()).toBe(false);
+ });
});
describe("error handling", () => {
diff --git a/web/src/components/settings/Nodes.vue b/web/src/components/settings/Nodes.vue
index cb3a84f7cd..3ebb869c63 100644
--- a/web/src/components/settings/Nodes.vue
+++ b/web/src/components/settings/Nodes.vue
@@ -52,8 +52,29 @@ along with this program. If not, see
.
+
+
+
+
+
+
+ {{ t(`nodes.${key}`) }}
+
+
+
(sectionOpen.region = v)"
@@ -87,16 +108,12 @@ along with this program. If not, see .
(sectionOpen.cluster = v)"
@@ -130,11 +147,7 @@ along with this program. If not, see .
@@ -501,7 +514,7 @@ along with this program. If not, see
.
-
+
{{ row.region }}
@@ -577,6 +590,7 @@ import OStatStrip from "@/lib/data/StatStrip/OStatStrip.vue";
import type { StatItem } from "@/lib/data/StatStrip/OStatStrip.types";
import type { ProgressBarVariant } from "@/lib/data/ProgressBar/OProgressBar.types";
import useIsMetaOrg from "@/composables/useIsMetaOrg";
+import { useLicenseLock } from "@/composables/useFeatureLock";
import OTag from "@/lib/core/Badge/OTag.vue";
import OCollapsible from "@/lib/core/Collapsible/OCollapsible.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
@@ -611,6 +625,11 @@ export default defineComponent({
const store = useStore();
const router = useRouter();
const { t } = useI18nTyped();
+ const { locked: superClusterLocked, lockedReason: superClusterLockedReason } =
+ useLicenseLock("super_cluster");
+ const superClusterOn = computed(
+ () => !!store.state.zoConfig.super_cluster_enabled && !superClusterLocked.value,
+ );
const sectionOpen = reactive({
region: false,
@@ -755,7 +774,7 @@ export default defineComponent({
];
// Region/cluster only mean something in a super-cluster. Filter by id rather
// than by position so adding a column can't silently drop the wrong one.
- if (!store.state.zoConfig.super_cluster_enabled) {
+ if (!superClusterOn.value) {
return columns.filter((col) => col.id !== "region");
}
return columns;
@@ -1305,6 +1324,9 @@ export default defineComponent({
cpuUsage,
memoryUsage,
regionRows,
+ superClusterOn,
+ superClusterLocked,
+ superClusterLockedReason,
clusterRows,
nodetypeRows,
statusesRows,
diff --git a/web/src/components/settings/OrganizationManagement.spec.ts b/web/src/components/settings/OrganizationManagement.spec.ts
index 410e817ec7..6300099445 100644
--- a/web/src/components/settings/OrganizationManagement.spec.ts
+++ b/web/src/components/settings/OrganizationManagement.spec.ts
@@ -260,7 +260,7 @@ describe("OrganizationManagement.vue", () => {
ODialog: ODialogStub,
OTable: OTableStub,
// Stub heavy sub-components that are not under test
- OTooltip: { template: "" },
+ OTooltip: { template: "" },
OIcon: { template: "" },
// Keep OInput functional but lightweight — tests use data-test to find it
OInput: {
@@ -865,6 +865,44 @@ describe("OrganizationManagement.vue", () => {
const storageEnableBtn = wrapper.find('[data-test="org-management-storage-enable-btn"]');
expect(storageEnableBtn.exists()).toBe(false);
});
+
+ it.each([
+ ["locked", "lock", true],
+ ["enabled", "cloud-upload", false],
+ ])(
+ "renders Enable BYOB with the %s license as icon %s, disabled=%s",
+ async (state, icon, disabled) => {
+ store.state.zoConfig.meta_org = "default";
+ store.state.zoConfig.features = { byob: state };
+ mockGetAdminOrg.mockResolvedValue({
+ data: {
+ data: [
+ {
+ id: 1,
+ name: "Test Org",
+ identifier: "test-org",
+ plan: "0",
+ created_at: 123456789,
+ trial_expires_at: 987654321,
+ billing_provider: "-",
+ org_storage_enabled: false,
+ },
+ ],
+ },
+ });
+
+ wrapper = createWrapper();
+ await flushPromises();
+ await nextTick();
+
+ const btn = wrapper
+ .findAllComponents({ name: "OButton" })
+ .find((b) => b.attributes("data-test") === "org-management-storage-enable-btn")!;
+ expect(btn.props("iconLeft")).toBe(icon);
+ expect(Boolean(btn.props("disabled"))).toBe(disabled);
+ store.state.zoConfig.features = {};
+ },
+ );
});
describe("Organization Deletion Status", () => {
diff --git a/web/src/components/settings/OrganizationManagement.vue b/web/src/components/settings/OrganizationManagement.vue
index 81d6e1348b..9c6c55e4ae 100644
--- a/web/src/components/settings/OrganizationManagement.vue
+++ b/web/src/components/settings/OrganizationManagement.vue
@@ -168,8 +168,20 @@ along with this program. If not, see .
>
+
+
+
+
+
.
{
+ if (byobLocked.value) return;
const ok = await confirm({
title: t("settings.organizationManagementPage.enableByobTitle"),
message: t("settings.organizationManagementPage.enableByobMessage", { name: row.name }),
@@ -1347,6 +1365,8 @@ export default defineComponent({
submitContract,
confirmRevokeContract,
toggleOrgStorage,
+ byobLocked,
+ byobLockedReason,
formatMicrosToDate,
formatCredits,
filterQuery,
diff --git a/web/src/components/settings/RegexPatternList.vue b/web/src/components/settings/RegexPatternList.vue
index 2078cc619c..46d68901b7 100644
--- a/web/src/components/settings/RegexPatternList.vue
+++ b/web/src/components/settings/RegexPatternList.vue
@@ -290,6 +290,7 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import { COL } from "@/lib/core/Table/OTable.types";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
+import { forbiddenState } from "@/utils/featureGate";
import { useListViewState } from "@/composables/useListViewState";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
@@ -430,7 +431,7 @@ export default defineComponent({
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
const forbidden = computed(() => {
const e: any = regexPatternsList.error.value;
- return e?.status === 403 || e?.response?.status === 403;
+ return forbiddenState(e) || e?.status === 403;
});
// Request in flight, with rows still on screen — the refresh button's
// spinner. `listLoading` stays for the skeleton, which only a cold read wants.
diff --git a/web/src/components/settings/index.spec.ts b/web/src/components/settings/index.spec.ts
index 78f37d0512..c28807c331 100644
--- a/web/src/components/settings/index.spec.ts
+++ b/web/src/components/settings/index.spec.ts
@@ -250,6 +250,84 @@ describe("SettingsIndex", () => {
expect(orgManagementItem?.visible).toBe(true);
expect(syntheticsItem?.visible).toBe(true);
});
+
+ describe("license-gated items", () => {
+ const byKeyUnder = (zoConfig: Record) => {
+ mockStore.state.zoConfig = { service_streams_enabled: true, ...zoConfig } as any;
+ const items = getAllItems(createWrapper());
+ return (key: string) => items.find((i: any) => i.key === key);
+ };
+
+ it.each([
+ ["synthetics_locations", "synthetics"],
+ ["queryManagement", "query_management"],
+ ["storageSettings", "byob"],
+ ])("marks %s license-locked under a locked %s license", (key, feature) => {
+ const byKey = byKeyUnder({ features: { [feature]: "locked" } });
+ expect(byKey(key)?.licenseLocked).toBe(true);
+ });
+
+ it("locks Correlation only when the alerts feature it also runs on is locked", () => {
+ const only = byKeyUnder({ features: { correlation: "locked" } });
+ expect(only("correlation_settings")?.licenseLocked).toBe(false);
+ const both = byKeyUnder({ features: { correlation: "locked", alerts: "locked" } });
+ expect(both("correlation_settings")?.licenseLocked).toBe(true);
+ });
+
+ it.each(["model_pricing", "llm_providers", "gen_ai_agent_mapping"])(
+ "locks %s and keeps it visible under a locked ai_observability license",
+ (key) => {
+ const byKey = byKeyUnder({
+ online_evals_enabled: false,
+ features: { ai_observability: "locked" },
+ });
+ expect(byKey(key)?.licenseLocked).toBe(true);
+ if (key !== "model_pricing") expect(byKey(key)?.visible).toBe(true);
+ },
+ );
+
+ it("keeps Telephony visible and locked under a locked oncall license", () => {
+ const byKey = byKeyUnder({ oncall_enabled: false, features: { oncall: "locked" } });
+ expect(byKey("telephony")).toMatchObject({ visible: true, licenseLocked: true });
+ });
+
+ it.each([
+ ["synthetics_locations", "synthetics"],
+ ["queryManagement", "query_management"],
+ ["storageSettings", "byob"],
+ ["correlation_settings", "correlation"],
+ ])("hides %s under a hidden %s license", (key, feature) => {
+ const byKey = byKeyUnder({ features: { [feature]: "hidden" } });
+ expect(byKey(key)?.visible).toBe(false);
+ });
+
+ it("keeps locked synthetics locations and correlation visible though their flags are off", () => {
+ const byKey = byKeyUnder({
+ synthetics_enabled: false,
+ service_streams_enabled: false,
+ features: { synthetics: "locked", correlation: "locked", alerts: "locked" },
+ });
+ expect(byKey("synthetics_locations")).toMatchObject({ visible: true, licenseLocked: true });
+ expect(byKey("correlation_settings")).toMatchObject({ visible: true, licenseLocked: true });
+ });
+
+ it("leaves every item unlocked when the license enables it", () => {
+ const byKey = byKeyUnder({});
+ for (const key of [
+ "synthetics_locations",
+ "queryManagement",
+ "storageSettings",
+ "correlation_settings",
+ "telephony",
+ "llm_providers",
+ ]) {
+ expect(byKey(key)?.licenseLocked).toBe(false);
+ }
+ expect(byKey("synthetics_locations")?.visible).toBe(true);
+ expect(byKey("queryManagement")?.visible).toBe(true);
+ expect(byKey("correlation_settings")?.visible).toBe(true);
+ });
+ });
});
describe("Router integration", () => {
diff --git a/web/src/components/settings/index.vue b/web/src/components/settings/index.vue
index 1a098cae17..fa9838ede8 100644
--- a/web/src/components/settings/index.vue
+++ b/web/src/components/settings/index.vue
@@ -39,7 +39,7 @@ along with this program. If not, see .
class="border-border-default shrink-0 border-b"
/>
{
expect(listSpy).not.toHaveBeenCalled();
});
+ it("fetches the SLO list without a supplied SLO", async () => {
+ await mountWith(emptyCondition(), { slo: null });
+ expect(listSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips the SLO list while anomaly_detection is license-locked", async () => {
+ store.state.zoConfig.features = { anomaly_detection: "locked" };
+ try {
+ await mountWith(emptyCondition(), { slo: null });
+ expect(listSpy).not.toHaveBeenCalled();
+ } finally {
+ delete store.state.zoConfig.features;
+ }
+ });
+
it("does not render an SLO selector when the SLO is supplied", async () => {
const wrapper = await mountWith(emptyCondition());
expect(wrapper.find('[data-test="slos-sloalertcondition-slo-trigger"]').exists()).toBe(false);
diff --git a/web/src/components/slos/SloAlertCondition.vue b/web/src/components/slos/SloAlertCondition.vue
index 9ba976538d..261588e2c2 100644
--- a/web/src/components/slos/SloAlertCondition.vue
+++ b/web/src/components/slos/SloAlertCondition.vue
@@ -235,6 +235,7 @@ import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import sloService from "@/services/slos";
+import { useLicenseLock } from "@/composables/useFeatureLock";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import type { Slo } from "@/ts/interfaces/slo";
import { formatTimeToExhaust, formatWindow } from "@/composables/useSloFormat";
@@ -253,6 +254,7 @@ const props = defineProps<{
const { t } = useI18nTyped();
const store = useStore();
+const { locked: anomalyLocked } = useLicenseLock("anomaly_detection");
const slos = ref([]);
const org = computed(() => store.state.selectedOrganization?.identifier);
@@ -454,7 +456,7 @@ onMounted(async () => {
model.value.slo_id = props.slo.id;
return;
}
- if (!org.value) return;
+ if (!org.value || anomalyLocked.value) return;
const res = await sloService.list(org.value);
slos.value = res.data?.list ?? [];
});
diff --git a/web/src/components/slos/SloAlertPreview.spec.ts b/web/src/components/slos/SloAlertPreview.spec.ts
index e4183309bd..771e8079a2 100644
--- a/web/src/components/slos/SloAlertPreview.spec.ts
+++ b/web/src/components/slos/SloAlertPreview.spec.ts
@@ -61,6 +61,16 @@ describe("SloAlertPreview", () => {
alertPreviewSpy.mockResolvedValue({ data: preview() });
});
+ it("makes no preview call while anomaly_detection is license-locked", async () => {
+ store.state.zoConfig.features = { anomaly_detection: "locked" };
+ try {
+ await mountPreview();
+ expect(alertPreviewSpy).not.toHaveBeenCalled();
+ } finally {
+ delete store.state.zoConfig.features;
+ }
+ });
+
it("asks the API for this alert over the SLO's window and slice", async () => {
await mountPreview({ windowSecs: 30 * 86400, sliceIntervalSecs: 60 });
expect(alertPreviewSpy).toHaveBeenCalledTimes(1);
diff --git a/web/src/components/slos/SloAlertPreview.vue b/web/src/components/slos/SloAlertPreview.vue
index ec0333cb64..3096bfea60 100644
--- a/web/src/components/slos/SloAlertPreview.vue
+++ b/web/src/components/slos/SloAlertPreview.vue
@@ -109,6 +109,7 @@ import { useStore } from "vuex";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import PanelBar from "@/components/common/PanelBar.vue";
import sloService from "@/services/slos";
+import { useLicenseLock } from "@/composables/useFeatureLock";
import { formatCoverage } from "@/composables/useSloFormat";
import { type UptimeBandState, uptimeBands } from "@/utils/slos/alertSource";
import type { AlertSliPreview } from "@/ts/interfaces/slo";
@@ -122,6 +123,7 @@ const props = defineProps<{
const { t } = useI18nTyped();
const store = useStore();
+const { locked: anomalyLocked } = useLicenseLock("anomaly_detection");
const preview = ref(null);
const loading = ref(false);
@@ -172,7 +174,7 @@ let latestRequest = 0;
async function load() {
const mine = ++latestRequest;
const org = store.state.selectedOrganization?.identifier;
- if (!org || !props.alertId) {
+ if (!org || !props.alertId || anomalyLocked.value) {
preview.value = null;
loading.value = false;
return;
diff --git a/web/src/components/synthetic-monitoring/MonitorTable.vue b/web/src/components/synthetic-monitoring/MonitorTable.vue
index 661377d5dd..2709a1fbd6 100644
--- a/web/src/components/synthetic-monitoring/MonitorTable.vue
+++ b/web/src/components/synthetic-monitoring/MonitorTable.vue
@@ -611,6 +611,7 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import { resolveBadge, resolveBadgeLabel } from "@/lib/core/Badge/badgeGroups";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
+import type { ForbiddenState } from "@/utils/featureGate";
import MutedChip from "@/components/alerts/downtimes/MutedChip.vue";
import MuteMenuItems from "@/components/alerts/downtimes/MuteMenuItems.vue";
import QuickMuteDialog from "@/components/alerts/downtimes/QuickMuteDialog.vue";
@@ -655,7 +656,7 @@ const props = withDefaults(
mode: Mode;
data: any[];
loading?: boolean;
- forbidden?: boolean;
+ forbidden?: ForbiddenState;
/** IANA zone for the Last Check tooltip. Passed in: this table is a leaf
* component and must not reach into the store for it. */
timezone?: string;
diff --git a/web/src/components/synthetics/variables/SyntheticsVariablesList.vue b/web/src/components/synthetics/variables/SyntheticsVariablesList.vue
index 174883263c..77e1762eed 100644
--- a/web/src/components/synthetics/variables/SyntheticsVariablesList.vue
+++ b/web/src/components/synthetics/variables/SyntheticsVariablesList.vue
@@ -34,6 +34,7 @@ along with this program. If not, see .
table-id="synthetics-variables"
:show-global-filter="false"
:loading="loading"
+ :forbidden="forbidden"
>
@@ -270,6 +271,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
+import type { ForbiddenState } from "@/utils/featureGate";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { COL } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
@@ -303,6 +305,7 @@ export default defineComponent({
props: {
variables: { type: Array as PropType, default: () => [] },
loading: { type: Boolean, default: false },
+ forbidden: { type: [Boolean, Array] as PropType, default: false },
/** Environment NAME, or null for the unscoped tier. */
environment: { type: String as PropType, default: null },
/** Split destinations. Empty on the global tab means there is nowhere to split to. */
diff --git a/web/src/components/synthetics/variables/SyntheticsVariablesTab.spec.ts b/web/src/components/synthetics/variables/SyntheticsVariablesTab.spec.ts
index 5bb277e908..ee5e29ee94 100644
--- a/web/src/components/synthetics/variables/SyntheticsVariablesTab.spec.ts
+++ b/web/src/components/synthetics/variables/SyntheticsVariablesTab.spec.ts
@@ -82,6 +82,17 @@ describe("SyntheticsVariablesTab — landing on a new environment", () => {
vi.clearAllMocks();
});
+ it("passes a license denial to the list as the forbidden state", async () => {
+ listEnvironments.mockRejectedValue({
+ response: { status: 403, data: { code: 40001, error_detail: "synthetics" } },
+ });
+ wrapper = await mountTab();
+
+ expect(wrapper.findComponent(SyntheticsVariablesList).props("forbidden")).toEqual([
+ "synthetics",
+ ]);
+ });
+
it("starts on Global", async () => {
wrapper = await mountTab();
diff --git a/web/src/components/synthetics/variables/SyntheticsVariablesTab.vue b/web/src/components/synthetics/variables/SyntheticsVariablesTab.vue
index 70fd5b9abd..0c66504f42 100644
--- a/web/src/components/synthetics/variables/SyntheticsVariablesTab.vue
+++ b/web/src/components/synthetics/variables/SyntheticsVariablesTab.vue
@@ -36,6 +36,7 @@ along with this program. If not, see .
ref="listRef"
:variables="scope.variables"
:loading="loading"
+ :forbidden="forbidden"
:environment="scope.isGlobal ? null : (scope.environment?.name ?? null)"
:environments="overridingEnvironments"
:globals="globals"
@@ -108,6 +109,7 @@ import SyntheticsEnvironmentForm from "./SyntheticsEnvironmentForm.vue";
import SyntheticsDuplicateEnvironmentDialog from "./SyntheticsDuplicateEnvironmentDialog.vue";
import { namedEnvironments, resolveScope } from "./scope";
import { environmentDeleteBlock } from "./usage";
+import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { serverMessage } from "./serverMessage";
const { t } = useI18nTyped();
@@ -119,6 +121,7 @@ const deleteShown = ref({ name: "", variables: 0 });
const environments = ref([]);
const globals = ref([]);
const loading = ref(false);
+const forbidden = ref(false);
const selectedScope = ref("");
const environmentDrawer = ref({
show: false,
@@ -144,6 +147,7 @@ const scopeSummary = computed(() => {
async function refresh() {
loading.value = true;
+ forbidden.value = false;
try {
const org = store.state.selectedOrganization.identifier;
const [envRes, globalRes] = await Promise.all([
@@ -153,6 +157,7 @@ async function refresh() {
environments.value = envRes.data ?? [];
globals.value = globalRes.data ?? [];
} catch (error) {
+ forbidden.value = forbiddenState(error);
toast({
variant: "error",
message: serverMessage(error) ?? t("synthetics.variables.loadFailed"),
diff --git a/web/src/components/workflows/WorkflowEditor.vue b/web/src/components/workflows/WorkflowEditor.vue
index d77d8e078c..4202223e2f 100644
--- a/web/src/components/workflows/WorkflowEditor.vue
+++ b/web/src/components/workflows/WorkflowEditor.vue
@@ -230,28 +230,30 @@ along with this program. If not, see .
-
-
+
@@ -337,6 +339,7 @@ along with this program. If not, see