Merge remote-tracking branch 'origin/main' into feat/unified-notification-templates

This commit is contained in:
Loakesh Indiran
2026-11-04 16:38:11 +05:30
470 changed files with 15650 additions and 2219 deletions
@@ -252,6 +252,22 @@
"feature_traces": "998741a58ed2b411",
"feature_vrl_functions": "4484dd94e9355480",
"feature_workload_management": "8004f04c30d8bddc",
"features": "5697d03daef4de9c",
"features_all_unlocked": "0791a3840eb6061a",
"features_filter_all": "a52ace420f2175d0",
"features_filter_free": "f411a1fb62758b4c",
"features_filter_new": "18fdd549b2ed367a",
"features_free_heading": "5892004a2a606ee5",
"features_group_heading": "98bb11ae86a0c557",
"features_missing": "694943b3db420a2e",
"features_new": "d9fe8aa562864fe0",
"features_no_match": "c7655586749bb9b3",
"features_none": "6b474f2c0cba33e7",
"features_paid_ratio": "3257b4364ee32149",
"features_request_access": "22d6edf4b21d5e25",
"features_search": "55e327604647ce88",
"features_summary": "5d2957d8f8c9d8e7",
"features_unlocked": "531c7b28abf27600",
"foreverNoLimits": "c4e4f35814b1022b",
"fullyManaged": "49964f86a467ca7b",
"gbPerDay": "dd9d1b63afeb0945",
@@ -13059,6 +13075,19 @@
"tabLabel": "cb702378f31507ef",
"title": "2a932f90172e99ea"
},
"featureLock": {
"bannerMessage": "2ad54d7dcf939937",
"bannerMessageGeneric": "9e4736ff1b60331d",
"contactSales": "604abea32b499a4c",
"description": "052f7504abd2c39a",
"headerLocked": "ce0f1697c8a3a6d5",
"routeTitle": "694943b3db420a2e",
"thisFeature": "2598604c06f0d223",
"title": "84027eb92891f1fd",
"toastMessage": "b17241a153486b9c",
"toastMessageGeneric": "4cf1f345d3caeb96",
"toastTitle": "feff10da75dad3ed"
},
"fleet": {
"addCollector": "6d30a69cb01ed55a",
"audit": {
@@ -20331,8 +20360,14 @@
"allClear": "22d352f617758292",
"allClearDesc": "bfe3044f55b21533",
"allClearDescScoped": "7c66a0cc083a98ba",
"allClearPartial": "f5a637211cc64316",
"allClearPartialDesc": "e5ed97f9af61f860",
"anomalyDetectedDescription": "67a554a9305c968d",
"anomalyDetectedTitle": "42b3e5f78e8b2967",
"areaAlerts": "f62e9267d196c761",
"areaAnomalies": "a0a660cc9daa76b9",
"areaIncidents": "bfe868931580fb3b",
"areaServices": "604dce445e326d95",
"colErrorRate": "33a224722fc7b1e4",
"colLatency": "e0e7d293043c8cf6",
"colReqs": "61985debc6417c53",
+20
View File
@@ -17,6 +17,7 @@ import { afterEach, describe, expect, it, beforeEach, vi } from "vitest";
import { mount, shallowMount } from "@vue/test-utils";
import { computed, nextTick } from "vue";
import i18n from "@/locales";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import Header from "@/components/Header.vue";
import * as cookies from "@/utils/cookies";
import { chartColor } from "@/utils/chartTheme";
@@ -521,6 +522,25 @@ describe("Header Component", () => {
aiWrapper.unmount();
});
it("keeps the AI logo with a lock badge and under a locked license", async () => {
const aiWrapper = createWrapper({
mountType: "mount",
storeOverrides: {
state: { zoConfig: { ai_enabled: true, features: { ai_assistant: "locked" } } },
},
configOverrides: { isEnterprise: "true" },
stubsOverrides: {},
});
await aiWrapper.vm.$nextTick();
const aiButton = aiWrapper.find('[data-test="menu-link-ai-locked"]');
expect(aiButton.exists()).toBe(true);
expect(aiButton.find("img").exists()).toBe(true);
expect(aiButton.findComponent(OLockBadge).exists()).toBe(true);
expect(aiButton.attributes("aria-label")).toContain("Locked: needs");
aiWrapper.unmount();
});
it("should not display AI button if ai_enabled is false", async () => {
const aiWrapper = createWrapper({
mountType: "mount",
+48 -15
View File
@@ -230,15 +230,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
Leads the utility-icon cluster, set off by a separator from the
org selector so it reads as the primary action in this group.
Shown locked (not hidden) in builds that don't unlock it. -->
<template v-if="aiButtonVisible">
<template v-if="aiButtonVisible || aiLicenseLocked">
<OButton
variant="ghost"
size="icon-toolbar"
@click="toggleAIChat"
data-test="menu-link-ai-item"
:aria-label="t('menu.aiAssistant')"
@click="aiLicenseLocked ? openLockedAiAssistant() : toggleAIChat()"
:data-test="aiLicenseLocked ? 'menu-link-ai-locked' : 'menu-link-ai-item'"
:aria-label="
aiLicenseLocked
? t('featureLock.headerLocked', {
feature: featureName('ai_assistant') ?? t('featureLock.thisFeature'),
})
: t('menu.aiAssistant')
"
class="group text-ai-accent! hover:shadow-ai-accent/35 dark:shadow-ai-accent/20 dark:hover:shadow-ai-accent/35 [background:var(--color-gradient-ai-subtle)]! [transition:background_0.3s_ease,box-shadow_0.3s_ease,color_0.3s_ease] hover:text-white! hover:shadow-md hover:[background:var(--color-gradient-ai)]! dark:text-white! dark:shadow-md dark:hover:shadow-md"
:class="store.state.isAiChatEnabled ? 'ai-btn-active' : ''"
:class="store.state.isAiChatEnabled && !aiLicenseLocked ? 'ai-btn-active' : ''"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
@@ -247,19 +253,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:src="getBtnLogo"
class="h-5 w-5 shrink-0 [transition:transform_0.6s_ease] group-hover:rotate-180 group-hover:brightness-0 group-hover:invert group-hover:[transition:filter_0.3s_ease]"
/>
<span
v-if="!aiAccess.allowed"
aria-hidden="true"
class="bg-surface-overlay border-surface-base absolute -top-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border shadow-sm"
>
<OIcon name="lock" size="xs" class="text-warning-600 size-2.5!" />
</span>
<OLockBadge v-if="!aiAccess.allowed || aiLicenseLocked" />
<OTooltip
v-if="aiAccess.allowed"
side="bottom"
align="center"
:content="t('menu.aiAssistant')"
shortcut-id="aiChatToggle"
:content="
aiLicenseLocked
? t('featureLock.headerLocked', {
feature: featureName('ai_assistant') ?? t('featureLock.thisFeature'),
})
: t('menu.aiAssistant')
"
:shortcut-id="aiLicenseLocked ? undefined : 'aiChatToggle'"
/>
</OButton>
</template>
@@ -505,6 +511,7 @@ import LinkedAccountsDialog from "./settings/LinkedAccountsDialog.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OPageHeaderStatus from "@/lib/core/PageHeader/OPageHeaderStatus.vue";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { checkFeatureAccess } from "@/utils/enterpriseFeatures";
import { readSlackOrgConfig } from "@/utils/slackSettings";
@@ -514,6 +521,8 @@ import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import { getImageURL } from "@/utils/zincutils";
import { featureLockedPath, menuState } from "@/utils/featureGate";
import { useFeatureNames } from "@/composables/useFeatureLock";
import { chartColor } from "@/utils/chartTheme";
export default defineComponent({
@@ -526,6 +535,7 @@ export default defineComponent({
LinkedAccountsDialog,
OButton,
OIcon,
OLockBadge,
OTooltip,
ODropdown,
ODropdownItem,
@@ -617,6 +627,7 @@ export default defineComponent({
],
setup(props, { emit }) {
const { t } = useI18nTyped();
const { featureName } = useFeatureNames();
const router = useRouter();
const { isDark } = useTheme();
@@ -720,12 +731,23 @@ export default defineComponent({
rbac: props.store.state.zoConfig?.rbac_enabled !== false,
}),
);
// The license map applies once the edition allows the assistant.
const aiAssistantState = computed(() =>
menuState(
props.store.state.zoConfig?.features,
"ai_assistant",
Boolean(props.store.state.zoConfig.ai_enabled),
),
);
const aiButtonVisible = computed(
() => !aiAccess.value.allowed || Boolean(props.store.state.zoConfig.ai_enabled),
() => !aiAccess.value.allowed || aiAssistantState.value === "enabled",
);
const aiSlotPending = computed(
() => aiAccess.value.allowed && !props.store.state.zoConfig?.version,
);
const aiLicenseLocked = computed(
() => aiAccess.value.allowed && aiAssistantState.value === "locked",
);
// The edition gate and the org's runtime switch both have to pass, as for Settings › Slack.
const linkedAccountsVisible = computed(
@@ -785,6 +807,13 @@ export default defineComponent({
emit("toggleAIChat");
};
const openLockedAiAssistant = () => {
router.push({
path: featureLockedPath("ai_assistant"),
query: { org_identifier: props.store.state.selectedOrganization?.identifier },
});
};
const openSlack = () => {
emit("openSlack");
};
@@ -832,6 +861,7 @@ export default defineComponent({
isDark,
t,
raw,
featureName,
getImageURL,
enterpriseButtonText,
aiSlotPending,
@@ -848,8 +878,11 @@ export default defineComponent({
homeUrl,
goToAbout,
toggleAIChat,
aiAssistantState,
openLockedAiAssistant,
aiAccess,
aiButtonVisible,
aiLicenseLocked,
linkedAccountsVisible,
showLinkedAccounts,
openSlack,
+32
View File
@@ -387,6 +387,38 @@ describe("MenuLink", async () => {
});
});
describe("license-locked tile", () => {
it("keeps a real link to the page and adds a lock badge", () => {
const w = mount(MenuLink, {
props: { title: "Workflows", link: "/workflows", licenseLocked: true },
global: { plugins: [i18n, mockRouter], provide: { store } },
});
const tile = w.get('[data-test="menu-link-/workflows-item"]');
expect(tile.element.tagName).toBe("A");
expect(tile.attributes("href")).toContain("/workflows");
expect(w.find('[data-test="menu-link-/workflows-license-lock"]').exists()).toBe(true);
w.unmount();
});
});
describe("license-locked tile screen-reader text", () => {
it("names the lock with the supplied label and paints it gold", () => {
const w = mount(MenuLink, {
props: {
title: "Workflows",
link: "/workflows",
licenseLocked: true,
licenseLockedLabel: "Locked: needs Workflows",
},
global: { plugins: [i18n, mockRouter], provide: { store } },
});
const badge = w.get('[data-test="menu-link-/workflows-license-lock"]');
expect(badge.get(".sr-only").text()).toBe("Locked: needs Workflows");
expect(badge.find(".text-icon-locked").exists()).toBe(true);
w.unmount();
});
});
describe("trial-paywalled tile (AC-14)", () => {
function mountTile(props: Record<string, unknown> = {}) {
return mount(MenuLink, {
+16 -7
View File
@@ -36,12 +36,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Rail icons are a hair smaller than the md (24px) default. -->
<OIcon v-if="icon" :name="icon" size="md" class="size-5.5!" />
<component v-else-if="hasIconComponent" :is="iconComponent" class="o-icon size-5.5" />
<div
v-if="locked"
class="bg-surface-base absolute -top-1 -right-1 z-1 flex h-3.5 w-3.5 items-center justify-center rounded-full"
>
<OIcon name="lock" size="xs" class="text-warning-600" />
</div>
<OLockBadge
v-if="locked || licenseLocked"
:data-test="licenseLocked ? `menu-link-${link}-license-lock` : undefined"
:label="licenseLocked ? licenseLockedLabel : undefined"
/>
<div
v-if="badge && badge > 0"
class="menu-badge text-3xs text-text-inverse shadow-error-500/50 border-menu-badge-ring bg-gradient-notification absolute -top-1 -right-2 z-1 flex h-4 min-w-4 animate-pulse items-center justify-center rounded-full border-2 px-1 leading-none font-bold shadow-md"
@@ -104,6 +103,7 @@ import { injectTooltipRootContext } from "reka-ui";
import { useTheme } from "@/composables/useTheme";
import { raw, type I18nText, useI18nTyped } from "@/types/i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useIsTruncated } from "@/lib/overlay/Tooltip/useIsTruncated";
import { RailIndicatorActiveKey } from "@/lib/core/Navbar/ONavbar.types";
@@ -131,7 +131,7 @@ const TooltipSilencer = defineComponent({
export default defineComponent({
name: "MenuLink",
components: { OIcon, OTooltip, TooltipSilencer },
components: { OIcon, OLockBadge, OTooltip, TooltipSilencer },
// Attrs are re-bound onto the tile element by hand: the tooltip wrapper is a fragment.
inheritAttrs: false,
props: {
@@ -218,6 +218,15 @@ export default defineComponent({
type: String as unknown as PropType<I18nText>,
default: raw(""),
},
// The license locks the feature: badge only, since the real page renders its own locked state.
licenseLocked: {
type: Boolean,
default: false,
},
licenseLockedLabel: {
type: String as unknown as PropType<I18nText>,
default: undefined,
},
},
emits: ["trigger"],
setup(props, { emit }) {
@@ -18,6 +18,7 @@ import { describe, expect, it, vi } from "vitest";
import {
buildNavigationRoute,
generateNavigationFromToolResult,
navigationFeature,
navigationPageName,
} from "@/components/O2AIChat.navigation";
import type { NavigationAction } from "@/ts/interfaces/chat";
@@ -355,3 +356,26 @@ describe("buildNavigationRoute", () => {
).toBeNull();
});
});
describe("navigationFeature", () => {
it.each([
["logs", "logs"],
["metrics", "metrics"],
["traces", "traces"],
["enrichment_tables", null],
])("keys a %s query to %s", (type, key) => {
expect(
navigationFeature({ action: "load_query", resource_type: type, target: {} } as any),
).toBe(key);
});
it.each([
["alert", "alerts"],
["dashboard", "dashboards"],
["pipeline", null],
])("keys a direct %s navigation to %s", (type, key) => {
expect(
navigationFeature({ action: "navigate_direct", resource_type: type, target: {} } as any),
).toBe(key);
});
});
+10
View File
@@ -15,6 +15,7 @@
import type { NavigationAction } from "@/ts/interfaces/chat";
import { raw, type TranslateFn } from "@/types/i18n";
import { streamTypeFeature } from "@/utils/featureGate";
export interface NavigationRoute {
path: string;
@@ -175,6 +176,15 @@ export function generateNavigationFromToolResult(
};
}
const RESOURCE_FEATURES: Record<string, string> = { alert: "alerts", dashboard: "dashboards" };
/** License key of the page a navigation action opens; null when that page has no key. */
export function navigationFeature(action: NavigationAction): string | null {
return action.action === "load_query"
? streamTypeFeature(action.resource_type)
: (RESOURCE_FEATURES[action.resource_type] ?? null);
}
export function navigationPageName(action: NavigationAction): string {
return (
action.label ||
+1 -1
View File
@@ -135,7 +135,7 @@
aria-hidden="true"
class="bg-surface-overlay border-surface-base absolute -top-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border shadow-sm"
>
<OIcon name="lock" size="xs" class="text-warning-600 size-2.5!" />
<OIcon name="lock" size="xs" class="text-icon-locked size-2.5!" />
</span>
<OTooltip
v-if="aiAccess.allowed"
@@ -15,6 +15,7 @@
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { createStore } from "vuex";
import i18n from "@/locales";
vi.mock("dompurify", () => ({
@@ -93,6 +94,49 @@ describe("O2AIChatMessage", () => {
]);
});
it("locks View in Logs, in the block and the tool-call icon, while logs is locked", async () => {
const action = {
action: "load_query",
resource_type: "logs",
label: "View in Logs",
target: {},
};
const message = assistant({
contentBlocks: [
{
type: "tool_call",
tool: "SearchSQL",
message: "m",
success: true,
navigationAction: action,
},
{ type: "navigation", navigationAction: action },
],
});
const lockedStore = createStore({ state: { zoConfig: { features: { logs: "locked" } } } });
const locked = mount(O2AIChatMessage, {
global: { plugins: [i18n, lockedStore] },
props: {
message,
index: 0,
isLoading: false,
currentAnalyzingMessage: "",
expandedToolCalls: new Set<string>(),
expandedLogEntries: new Set<string>(),
},
});
const btn = locked.find('[data-test="o2-ai-chat-navigate-locked"]');
expect(btn.attributes("disabled")).toBeDefined();
await btn.trigger("click");
await locked.find('[data-test="o2-ai-chat-tool-navigate-locked"]').trigger("click");
expect(locked.emitted("navigate")).toBeUndefined();
const open = mountMessage(message);
expect(open.find('[data-test="o2-ai-chat-navigate-locked"]').exists()).toBe(false);
await open.find(".navigation-block-btn").trigger("click");
expect(open.emitted("navigate")).toEqual([[action]]);
});
it("emits retry with the message and like/dislike without arguments", async () => {
const message = assistant();
const w = mountMessage(message);
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import type { ChatMessage, ContentBlock, NavigationAction } from "@/ts/interfaces/chat";
import {
formatLogEntryContent,
@@ -16,6 +16,8 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { navigationFeature } from "@/components/O2AIChat.navigation";
import { useLicenseLocks } from "@/composables/useFeatureLock";
// ContentBlock has no log_entry member, but parseLogEntries puts them into user messages.
type LogEntryBlock = { type: "log_entry"; preview: string; content: string };
@@ -48,6 +50,11 @@ const emit = defineEmits<{
}>();
const { t } = useI18nTyped();
const { isLocked, lockedReason } = useLicenseLocks();
const navLock = (action: NavigationAction): I18nText | null => {
const key = navigationFeature(action);
return key && isLocked(key) ? lockedReason(key) : null;
};
// Positional key shape must match the shell's toggleLogEntryExpanded.
const isLogEntryExpanded = (blockIndex: number) =>
@@ -165,8 +172,27 @@ const isStopped = computed(() =>
>
{{ t("aiAssistant.errorMayBeTemporary") }}
</div>
<OTooltip
v-if="block.navigationAction && navLock(block.navigationAction)"
:content="navLock(block.navigationAction) ?? undefined"
>
<span class="ms-6 mt-2 inline-flex self-start">
<OButton
variant="primary"
size="xs"
class="stream-error-action text-compact"
disabled
data-test="o2-ai-chat-error-navigate-locked"
>
<template #icon-left
><OIcon name="lock" class="text-icon-locked" size="sm"
/></template>
{{ block.navigationAction.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="block.navigationAction"
v-else-if="block.navigationAction"
variant="primary"
size="xs"
class="stream-error-action text-compact ms-6 mt-2 self-start"
@@ -215,7 +241,27 @@ const isStopped = computed(() =>
v-else-if="block.type === 'navigation' && block.navigationAction && !readonly"
class="navigation-block my-1 [background:color-mix(in_srgb,var(--color-info)_8%,transparent)] dark:[background:color-mix(in_srgb,var(--color-info)_12%,transparent)]"
>
<OTooltip
v-if="navLock(block.navigationAction)"
:content="navLock(block.navigationAction) ?? undefined"
>
<span class="inline-flex">
<OButton
variant="primary"
size="xs"
class="navigation-block-btn text-compact"
disabled
data-test="o2-ai-chat-navigate-locked"
>
<template #icon-left
><OIcon name="lock" class="text-icon-locked" size="sm"
/></template>
{{ block.navigationAction.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-else
variant="primary"
size="xs"
class="navigation-block-btn text-compact"
@@ -13,6 +13,8 @@ import { copyToClipboard } from "@/utils/clipboard";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { navigationFeature } from "@/components/O2AIChat.navigation";
import { useLicenseLocks } from "@/composables/useFeatureLock";
const props = defineProps<{
block: ToolCallBlock;
@@ -37,6 +39,12 @@ const isExpanded = computed(() =>
const formatToolCallMessage = (block: ToolCallBlock) => formatToolCall(block, t);
const formatTimestamp = (timestamp: number) => formatToolCallTimestamp(timestamp, t);
const { isLocked, lockedReason } = useLicenseLocks();
const navLockReason = computed(() => {
const key = props.block.navigationAction ? navigationFeature(props.block.navigationAction) : null;
return key && isLocked(key) ? lockedReason(key) : null;
});
</script>
<template>
@@ -86,8 +94,23 @@ const formatTimestamp = (timestamp: number) => formatToolCallTimestamp(timestamp
>{{ formatToolCallMessage(block).suffix }}
</span>
<!-- Navigation icon -->
<OTooltip
v-if="block.navigationAction && !block.pendingConfirmation && !readonly && navLockReason"
:content="navLockReason"
>
<span class="ms-auto inline-flex" @click.stop>
<OButton
variant="ghost"
size="icon-xs"
disabled
icon-left="lock"
:aria-label="block.navigationAction.label"
data-test="o2-ai-chat-tool-navigate-locked"
/>
</span>
</OTooltip>
<OIcon
v-if="block.navigationAction && !block.pendingConfirmation && !readonly"
v-else-if="block.navigationAction && !block.pendingConfirmation && !readonly"
name="open-in-new"
size="xs"
class="navigation-icon ms-auto cursor-pointer opacity-70 transition-opacity duration-200 hover:opacity-100"
@@ -361,6 +361,18 @@ describe("LibraryCard", () => {
expect(scheduled.find('[data-test="alert-library-card-anomaly"]').exists()).toBe(false);
});
it("disables the card, its checkbox and its title button when the license locks it", async () => {
const wrapper = mountCard({ entry: anomaly, locked: true, licenseLockedReason: "Locked" });
const card = wrapper.find(`[data-test="alert-library-card-${anomaly.id}"]`);
expect(card.attributes("aria-disabled")).toBe("true");
expect(card.classes()).toContain("cursor-not-allowed");
expect(
wrapper.find(`[data-test="alert-library-card-title-${anomaly.id}"]`).attributes("disabled"),
).toBeDefined();
await card.trigger("click");
expect(wrapper.emitted("open")).toBeUndefined();
});
it("shows the Enterprise badge only when locked", () => {
const locked = mountCard({ entry: anomaly, locked: true });
expect(locked.find('[data-test="alert-library-card-enterprise"]').text()).toBe("Enterprise");
@@ -43,20 +43,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<!-- Not a button any more: a control nested in role="button" is invalid for keyboard and AT. -->
<article
class="rounded-default bg-surface-base flex h-full cursor-pointer flex-col gap-2 border p-3"
:class="
class="rounded-default bg-surface-base flex h-full flex-col gap-2 border p-3"
:class="[
licenseLockedReason ? 'cursor-not-allowed' : 'cursor-pointer',
selected
? 'border-accent ring-accent/40 ring-2'
: 'border-border-default hover:border-border-strong'
"
: licenseLockedReason
? 'border-border-default'
: 'border-border-default hover:border-border-strong',
]"
:aria-disabled="licenseLockedReason ? 'true' : undefined"
:data-test="`alert-library-card-${entry.id}`"
:data-selected="selected ? 'true' : 'false'"
@click="emit('open')"
@click="!licenseLockedReason && emit('open')"
>
<div class="flex items-start gap-2">
<OCheckbox
size="sm"
:model-value="selected"
:disabled="!!licenseLockedReason"
:aria-label="t('alert_library.selectAlert', { title: entry.title })"
class="mt-0.5 shrink-0"
:data-test="`alert-library-select-${entry.id}`"
@@ -66,7 +71,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<button
:id="titleId"
type="button"
class="text-text-heading focus-visible:ring-accent/40 rounded-default cursor-pointer text-left outline-none focus-visible:ring-2"
:disabled="!!licenseLockedReason"
class="text-text-heading focus-visible:ring-accent/40 rounded-default text-left outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-not-allowed"
:data-test="`alert-library-card-title-${entry.id}`"
@click.stop="emit('open')"
>
@@ -133,8 +139,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="t('alert_library.alertTypeAnomaly')"
data-test="alert-library-card-anomaly"
/>
<OTooltip v-if="licenseLockedReason" :content="licenseLockedReason">
<span class="inline-flex" data-test="alert-library-card-license-locked">
<OIcon name="lock" size="xs" class="lock-chip shrink-0" />
</span>
</OTooltip>
<OTag
v-if="locked"
v-else-if="locked"
variant="default-soft"
icon="lock"
:label="t('alert_library.enterprise')"
@@ -143,7 +154,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<!-- Locked cards open the upsell instead, so they offer nothing to act on here. -->
<div v-if="!locked" class="ms-auto flex shrink-0 items-center gap-2">
<div v-if="!locked && !licenseLockedReason" class="ms-auto flex shrink-0 items-center gap-2">
<!-- The editor prefill speaks the scheduled alert shape only. -->
<OButton
v-if="!isAnomaly"
@@ -183,7 +194,7 @@ import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { AlertLibraryEntry } from "@/types/alertLibrary";
import { useI18nTyped } from "@/types/i18n";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { alertTypeOf, queryTypeLabel, severityBadgeValue, severityLabel } from "./libraryFacets";
@@ -195,6 +206,8 @@ const props = defineProps<{
selected?: boolean;
/** An anomaly alert this build is not entitled to; the gallery turns its clicks into the upsell. */
locked?: boolean;
/** Set when the license, not the edition, locks this alert; the card shows a lock with this reason. */
licenseLockedReason?: I18nText;
}>();
const emit = defineEmits<{
@@ -1271,6 +1271,23 @@ describe("LibraryInstallDialog", () => {
expect(mocks.createAlert).toHaveBeenCalledTimes(1);
});
it("keeps anomaly alerts out of the batch while anomaly_detection is license-locked", async () => {
store.state.zoConfig = {
...store.state.zoConfig,
features: { anomaly_detection: "locked" },
} as typeof original.zoConfig;
const wrapper = await mountDialog({
seed: null,
preselect: [seedEntry(), anomalyEntry()],
});
await advanceToInstall(wrapper);
expect(wrapper.find('[data-test="alert-library-install-run"]').text()).toContain("1");
await click(wrapper, "alert-library-install-run");
expect(mocks.createAnomaly).not.toHaveBeenCalled();
expect(mocks.createAlert).toHaveBeenCalledTimes(1);
});
it("disables Run when the feature turns off under an anomaly-only batch", async () => {
const wrapper = await mountDialog({
seed: { entry: anomalyEntry(), file: anomalyFile() },
@@ -0,0 +1,112 @@
// 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 { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import { ref } from "vue";
import { createMemoryHistory, createRouter } from "vue-router";
import store from "@/test/unit/helpers/store";
import AlertCalibrationDigestDetail from "./AlertCalibrationDigestDetail.vue";
import type { RunRow } from "./digests";
const DAY_MICROS = 86_400_000_000;
const NOW = Date.now() * 1000;
const RUN: RunRow = {
window_start: NOW - 30 * DAY_MICROS,
window_end: NOW,
emitted_at: NOW,
finding_count: 0,
skip_count: 0,
alerts_in_scope: 8,
coverage_gap: false,
coverage_unreadable_definitions: 0,
coverage_facts_without_definition: 0,
coverage_definitions_without_facts: 0,
coverage_transitions_truncated: false,
coverage_counters_truncated: false,
coverage_error_clusters_truncated: false,
coverage_error_clusters_unattributed: 0,
coverage_stream_substrate_empty: false,
};
vi.mock("@/composables/alertCalibration/useAlertCalibrationEnabled", () => ({
useLeaveIfAlertCalibrationOff: () => {},
}));
vi.mock("./useDigestSearch", () => ({
useDigestSearch: () => ({ search: vi.fn(), orgId: ref("default") }),
useLatestRead: () => ({
data: ref({ hits: 1, run: RUN, findings: [] }),
error: ref(null),
loading: ref(false),
load: vi.fn(),
}),
}));
const mountPage = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div />" } },
{ path: "/logs", name: "logs", component: { template: "<div />" } },
{ path: "/runs", name: "alertCalibrationRuns", component: { template: "<div />" } },
],
});
await router.push("/");
const push = vi.spyOn(router, "push");
const wrapper = mount(AlertCalibrationDigestDetail, {
props: { windowStart: RUN.window_start, windowEnd: RUN.window_end },
global: { plugins: [store, router] },
});
await flushPromises();
return { wrapper, push };
};
const VIEW_IN_LOGS = '[data-test="alert-calibration-digest-view-in-logs"]';
describe("AlertCalibrationDigestDetail view in logs", () => {
afterEach(() => {
delete (store.state.zoConfig as Record<string, unknown>).features;
});
it("logs locked: View in logs is disabled with a lock and opens nothing", async () => {
(store.state.zoConfig as Record<string, unknown>).features = { logs: "locked" };
const { wrapper, push } = await mountPage();
const button = wrapper.find(VIEW_IN_LOGS);
expect(button.attributes("disabled")).toBeDefined();
expect(wrapper.find('[data-test="alert-calibration-digest-view-in-logs-lock"]').exists()).toBe(
true,
);
await button.trigger("click");
expect(push).not.toHaveBeenCalled();
wrapper.unmount();
});
it("logs enabled: View in logs opens Logs on the digest stream", async () => {
const { wrapper, push } = await mountPage();
expect(wrapper.find('[data-test="alert-calibration-digest-view-in-logs-lock"]').exists()).toBe(
false,
);
await wrapper.find(VIEW_IN_LOGS).trigger("click");
expect(push).toHaveBeenCalledWith(
expect.objectContaining({
path: "/logs",
query: expect.objectContaining({ stream_type: "logs" }),
}),
);
wrapper.unmount();
});
});
@@ -37,8 +37,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-calibration-digest-page"
>
<template #actions>
<OTooltip v-if="run && logsLock.locked.value" :content="logsLock.lockedReason.value">
<span class="inline-flex">
<OButton
variant="outline"
size="sm"
disabled
data-test="alert-calibration-digest-view-in-logs"
>
<template #icon-left>
<OIcon
name="lock"
class="text-icon-locked"
size="sm"
data-test="alert-calibration-digest-view-in-logs-lock"
/>
</template>
{{ t("alertCalibration.detail.viewInLogs") }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="run"
v-else-if="run"
variant="outline"
size="sm"
icon-left="open-in-new"
@@ -174,6 +194,8 @@ import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OStatStrip from "@/lib/data/StatStrip/OStatStrip.vue";
@@ -202,6 +224,7 @@ const props = defineProps<{ windowStart: string | number; windowEnd: string | nu
const { t } = useI18nTyped();
useLeaveIfAlertCalibrationOff("alertList");
const router = useRouter();
const logsLock = useLicenseLock("logs");
const route = useRoute();
const { search, orgId } = useDigestSearch();
@@ -1358,6 +1358,23 @@ describe("AddAlert (OForm owner)", () => {
expect(document.querySelector('[data-test="alert-save-mode-dialog"]')).not.toBeNull();
});
it("locks the anomaly alert type while anomaly_detection is license-locked", async () => {
store.state.zoConfig.features = { anomaly_detection: "locked" };
try {
wrapper = mountAlert();
await flushPromises();
const anomaly = wrapper.vm.alertTypeOptions.find((o: any) => o.value === "anomaly");
expect(anomaly?.locked).toBe(true);
const item = wrapper.find('[data-test="add-alert-type-tab-anomaly"]');
expect(item.attributes("disabled")).toBeDefined();
wrapper.vm.onAlertTypeChange("anomaly");
await flushPromises();
expect(wrapper.vm.form.state.values.is_real_time).not.toBe("anomaly");
} finally {
delete store.state.zoConfig.features;
}
});
it("falls back to Builder and keeps the SQL when Alert Type switches to Realtime", async () => {
wrapper = mountAlert();
await flushPromises();
+15 -2
View File
@@ -198,10 +198,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="option.value"
:value="option.value"
size="sm"
:disabled="option.locked"
:focusable-unavailable="option.locked"
:tooltip="option.locked ? anomalyLockedReason : undefined"
:data-test="`add-alert-type-tab-${option.value}`"
>
<template #icon-left>
<OIcon :name="option.icon" size="sm" />
<OIcon
:class="option.locked ? 'lock-chip' : undefined"
:name="option.locked ? 'lock' : option.icon"
size="sm"
/>
</template>
{{ option.label }}
</OToggleGroupItem>
@@ -668,6 +675,7 @@ import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import CompositeAlertForm from "./composite/CompositeAlertForm.vue";
import alertsService from "@/services/alerts";
import { useLicenseLock } from "@/composables/useFeatureLock";
export default defineComponent({
name: "ComponentAddUpdateAlert",
@@ -767,6 +775,8 @@ export default defineComponent({
const isAnomalyDetectionEnabled = computed(
() => alertForm.store.state.zoConfig.anomaly_detection_enabled === true,
);
const { locked: anomalyLicenseLocked, lockedReason: anomalyLockedReason } =
useLicenseLock("anomaly_detection");
// §4.8: keyed off the config API's notice_class field alone.
const anomalyNoticeBadge = computed(() => {
@@ -872,12 +882,13 @@ export default defineComponent({
const alertTypeOptions = computed(() => [
{ label: alertForm.t("alerts.scheduled"), value: "false", icon: "schedule" },
{ label: alertForm.t("alerts.realTime"), value: "true", icon: "bolt" },
...(isAnomalyDetectionEnabled.value
...(isAnomalyDetectionEnabled.value || anomalyLicenseLocked.value
? [
{
label: alertForm.t("alerts.anomalyDetection"),
value: "anomaly",
icon: "query-stats",
locked: anomalyLicenseLocked.value,
},
]
: []),
@@ -958,6 +969,7 @@ export default defineComponent({
// the ONE form directly. The existing watch on is_real_time still drives the
// anomaly/composite tab switch.
const onAlertTypeChange = (value: unknown) => {
if (value === "anomaly" && anomalyLicenseLocked.value) return;
alertForm.setF("is_real_time", value);
// Realtime runs Builder only. The query text and Compare-with-Past windows
// stay in the form for a switch back; the payload never sends windows here.
@@ -1047,6 +1059,7 @@ export default defineComponent({
anomalyNoticeBadge,
anomalyNoticeTooltip,
alertTypeOptions,
anomalyLockedReason,
alertTabs,
activeFolderName,
goBackToAlertsList,
@@ -43,7 +43,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:workflow-options="workflowOptions"
:workflows-enabled="workflowsEnabled"
:workflows-locked="workflowsLocked"
:workflows-lock-message="workflowsAccess.message"
:workflows-license-locked="workflowsLicenseLocked"
:workflows-lock-message="
workflowsLicenseLocked ? workflowsLicenseReason : workflowsAccess.message
"
:placeholder="placeholder"
:error="!!error"
@update:destinations="emit('update:destinations', $event)"
@@ -90,6 +93,7 @@ import AlertTargetsSelect from "@/components/alerts/AlertTargetsSelect.vue";
import { workflowsQuery } from "@/services/workflows.queries";
import { queryClient } from "@/composables/query/queryClient";
import { useLockedAffordance } from "@/composables/useLockedAffordance";
import { useLicenseLock } from "@/composables/useFeatureLock";
type RawOption = string | { label: I18nText; value: string };
@@ -143,13 +147,24 @@ const tooltipText = computed<I18nText>(() =>
// it); a deployment that simply hasn't turned the runtime flag on stays
// HIDDEN (upgrading wouldn't change that).
const workflowsAccess = useLockedAffordance("workflows");
const { locked: workflowsLicenseDenied, lockedReason: workflowsLicenseReason } =
useLicenseLock("workflows");
const workflowsRuntimeEnabled = computed(() => store.state.zoConfig?.workflows_enabled === true);
const workflowsEnabled = computed(
() => props.supportsWorkflows && workflowsAccess.value.allowed && workflowsRuntimeEnabled.value,
() =>
props.supportsWorkflows &&
workflowsAccess.value.allowed &&
!workflowsLicenseDenied.value &&
workflowsRuntimeEnabled.value,
);
// Shown (disabled) instead of hidden outright when the family supports
// workflows but this edition doesn't unlock them.
const workflowsLocked = computed(() => props.supportsWorkflows && !workflowsAccess.value.allowed);
const workflowsLicenseLocked = computed(
() => props.supportsWorkflows && workflowsLicenseDenied.value,
);
const workflowsLocked = computed(
() => props.supportsWorkflows && (!workflowsAccess.value.allowed || workflowsLicenseDenied.value),
);
const workflowOptions = ref<{ label: I18nText; value: string }[]>([]);
const fetchWorkflows = async (force = false) => {
@@ -1468,6 +1468,78 @@ describe("AlertList - isAnomalyDetectionEnabled", () => {
// The computed initialises activeTab to 'all' when the feature is disabled
expect(wrapper.vm.activeTab).not.toBe("anomalyDetection");
});
describe("anomaly_detection license lock", () => {
afterEach(() => {
delete (store.state as any).zoConfig.features;
});
const lockedList = async () => {
(config as any).isEnterprise = "true";
(store.state as any).zoConfig.build_type = "enterprise";
(store.state as any).zoConfig.anomaly_detection_enabled = true;
(store.state as any).zoConfig.features = { anomaly_detection: "locked" };
router.currentRoute.value.query = {} as any;
const wrapper: any = await mountAlertList();
await waitData(wrapper);
return wrapper;
};
it("opens the locked tab on the license empty state without an anomaly list call", async () => {
const wrapper = await lockedList();
expect(wrapper.vm.alertTabs.find((t: any) => t.value === "anomalyDetection").locked).toBe(
true,
);
(alertsSvc.listByFolderId as any).mockClear();
await wrapper.vm.onAlertTabChange("anomalyDetection");
await flushPromises();
expect(wrapper.vm.activeTab).toBe("anomalyDetection");
expect(alertsSvc.listByFolderId).not.toHaveBeenCalled();
expect(wrapper.vm.displayedAlerts).toEqual([]);
expect(wrapper.vm.anomalyTabLicenseLocked).toBe(true);
// An earlier spec leaves an edit route behind, which opens the editor instead of the list.
wrapper.vm.showAddAlertDialog = false;
await flushPromises();
const add = wrapper.find('[data-test="alert-list-add-alert-btn"]');
expect(add.attributes("disabled")).toBeDefined();
expect(wrapper.find('[data-test="alert-list-add-anomaly-lock"]').exists()).toBe(true);
});
it("locks editing anomaly rows but not scheduled ones", async () => {
const wrapper = await lockedList();
expect(wrapper.vm.rowAnomalyLocked({ type: "anomaly" })).toBe(true);
expect(wrapper.vm.rowAnomalyLocked({ type: "alert", is_real_time: false })).toBe(false);
});
it("guards every action on an anomaly row: no dialogs, no state toggle", async () => {
const wrapper = await lockedList();
const row = { type: "anomaly", alert_id: "an-1", uuid: "u-an-1", name: "an", enabled: true };
wrapper.vm.showDeleteDialogFn({ row });
wrapper.vm.moveAlertToAnotherFolder(row);
await wrapper.vm.duplicateAlert(row);
wrapper.vm.toggleAlertState(row);
await flushPromises();
expect(wrapper.vm.confirmDelete).toBe(false);
expect(wrapper.vm.showMoveAlertDialog).toBe(false);
expect(wrapper.vm.showForm).toBe(false);
expect(wrapper.vm.alertStateLoadingMap["u-an-1"]).toBeUndefined();
expect(wrapper.vm.anomalyRowLocked(row)).toBe(true);
expect(wrapper.vm.anomalyRowLocked({ type: "alert" })).toBe(false);
});
it("leaves the anomaly tab unlocked while anomaly_detection is enabled", async () => {
(config as any).isEnterprise = "true";
(store.state as any).zoConfig.build_type = "enterprise";
(store.state as any).zoConfig.anomaly_detection_enabled = true;
(store.state as any).zoConfig.features = { anomaly_detection: "enabled" };
const wrapper: any = await mountAlertList();
const tab = wrapper.vm.alertTabs.find((t: any) => t.value === "anomalyDetection");
expect(tab.locked).toBe(false);
expect(wrapper.vm.anomalyTabLicenseLocked).toBe(false);
});
});
});
// 12. ODialog / ODrawer migration coverage
@@ -2077,6 +2149,12 @@ describe("AlertList - trigger", () => {
expect(calls).toEqual([{ variant: "warning", message }]);
});
it("warns with the reason when the trigger ran but something was not delivered", async () => {
const warning = "not paged: On-Call is not included in your license";
const calls = await triggerWith({ message: "Alert triggered", warning });
expect(calls).toEqual([{ variant: "warning", message: warning }]);
});
it("warns instead of claiming success when a detection run already holds the claim", async () => {
const calls = await triggerWith({ claim_lost: true, message: "already running" });
expect(calls).toEqual([
+177 -28
View File
@@ -38,8 +38,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #actions>
<!-- The provider behind the Terraform export tab, which is otherwise
only discoverable once the export dialog is already open. -->
<OTooltip v-if="anomalyTabLicenseLocked" :content="anomalyLockedReason">
<span class="inline-flex">
<OButton data-test="alert-list-add-alert-btn" variant="primary" size="sm" disabled>
<template #icon-left>
<OIcon
name="lock"
class="lock-chip"
size="sm"
data-test="alert-list-add-anomaly-lock"
/>
</template>
{{ t(`alerts.add`) }}
</OButton>
</span>
</OTooltip>
<!-- Add button — routes to anomaly creation on anomaly tab, alert creation otherwise -->
<OButton
v-else
data-test="alert-list-add-alert-btn"
variant="primary"
size="sm"
@@ -120,6 +136,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="oTableRef"
:frame="false"
v-model:selected-ids="selectedAlertIds"
:is-row-selectable="(row: any) => !anomalyRowLocked(row)"
selection="multiple"
data-test="alert-list-table"
:data="displayedAlerts"
@@ -128,7 +145,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
row-key="alert_id"
:loading="loading"
:loading-reset-key="`${activeFolderId}:${activeTab}`"
:forbidden="forbidden"
:forbidden="anomalyTabLicenseLocked ? ['anomaly_detection'] : forbidden"
forbidden-resource="alert_folders"
pagination="client"
:page-size="pageSize"
@@ -188,13 +205,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:value="tab.value"
size="sm"
:icon-left="tab.icon"
:title="lgUp && !tab.locked ? undefined : tab.label"
:icon-right="tab.locked ? 'lock' : undefined"
:tooltip="tab.locked ? anomalyLockedReason : undefined"
:title="lgUp || tab.locked ? undefined : tab.label"
:data-test="`alert-list-tab-${tab.value}`"
>
<span class="@max-[42rem]/alert-toolbar:hidden">{{ tab.label }}</span>
<template v-if="tab.locked" #icon-right>
<OIcon name="lock" size="xs" class="text-warning-600 shrink-0" />
</template>
</OToggleGroupItem>
</template>
</OToggleGroup>
@@ -586,6 +602,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OSpinner size="xs" />
</div>
<OTooltip
v-else-if="anomalyRowLocked(row)"
side="bottom"
:content="anomalyLockedReason"
>
<span class="ms-1 inline-flex max-md:hidden">
<OButton
:data-row-action="row.enabled ? 'pause' : 'resume'"
:data-test="`alert-list-${row.name}-pause-start-alert`"
:aria-label="row.enabled ? t('alerts.pause') : t('alerts.start')"
variant="ghost"
size="icon-sm"
icon-left="lock"
disabled
/>
</span>
</OTooltip>
<OButton
v-else
:data-row-action="row.enabled ? 'pause' : 'resume'"
@@ -602,7 +635,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:shortcut-id="row.enabled ? 'alertsRowPause' : undefined"
/>
</OButton>
<OTooltip
v-if="rowAnomalyLocked(row)"
side="bottom"
:content="anomalyLockedReason"
>
<span class="inline-flex max-md:hidden">
<OButton
data-row-action="edit"
:data-test="`alert-list-${row.name}-update-alert`"
:aria-label="t('alerts.edit')"
variant="ghost"
size="icon-sm"
icon-left="lock"
disabled
/>
</span>
</OTooltip>
<OButton
v-else
data-row-action="edit"
:data-test="`alert-list-${row.name}-update-alert`"
variant="ghost"
@@ -624,7 +675,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
with no way to find out why. -->
<span class="inline-flex max-md:hidden">
<OTooltip
v-if="isSloRow(row)"
v-if="anomalyRowLocked(row)"
side="bottom"
:content="anomalyLockedReason"
/>
<OTooltip
v-else-if="isSloRow(row)"
side="bottom"
:content="t('alerts.sloCloneDisabled')"
/>
@@ -632,14 +688,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-row-action="duplicate"
variant="ghost"
size="icon-sm"
icon-left="content-copy"
:disabled="isSloRow(row)"
:icon-left="anomalyRowLocked(row) ? 'lock' : 'content-copy'"
:disabled="isSloRow(row) || anomalyRowLocked(row)"
:aria-label="isSloRow(row) ? t('alerts.sloCloneDisabled') : t('alerts.clone')"
@click.stop="duplicateAlert(row)"
:data-test="`alert-list-${row.name}-clone-alert`"
>
<OTooltip
v-if="!isSloRow(row)"
v-if="!isSloRow(row) && !anomalyRowLocked(row)"
side="bottom"
:content="t('alerts.clone')"
shortcut-id="alertsRowDuplicate"
@@ -678,43 +734,71 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<ODropdownItem
class="md:hidden"
:disabled="!!alertStateLoadingMap[row.uuid]"
:disabled="!!alertStateLoadingMap[row.uuid] || anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-pause-start-alert-menu`"
@select="toggleAlertState(row)"
>
<template #icon-left>
<OIcon :name="row.enabled ? 'pause' : 'play-arrow'" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="
anomalyRowLocked(row) ? 'lock' : row.enabled ? 'pause' : 'play-arrow'
"
size="sm"
/>
</template>
{{ row.enabled ? t("alerts.pause") : t("alerts.start") }}
</ODropdownItem>
<ODropdownItem
class="md:hidden"
:disabled="rowAnomalyLocked(row)"
:focusable-unavailable="rowAnomalyLocked(row)"
:description="rowAnomalyLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-update-alert-menu`"
@select="editAlert(row)"
>
<template #icon-left>
<OIcon name="edit" size="sm" />
<OIcon
:class="rowAnomalyLocked(row) ? 'lock-chip' : undefined"
:name="rowAnomalyLocked(row) ? 'lock' : 'edit'"
size="sm"
/>
</template>
{{ t("alerts.edit") }}
</ODropdownItem>
<ODropdownItem
class="md:hidden"
:disabled="isSloRow(row)"
:disabled="isSloRow(row) || anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-clone-alert-menu`"
@select="duplicateAlert(row)"
>
<template #icon-left>
<OIcon name="content-copy" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'content-copy'"
size="sm"
/>
</template>
{{ t("alerts.clone") }}
</ODropdownItem>
<ODropdownSeparator class="md:hidden" />
<ODropdownItem
:disabled="anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-move-alert`"
@select="moveAlertToAnotherFolder(row)"
>
<template #icon-left>
<OIcon name="drive-file-move" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'drive-file-move'"
size="sm"
/>
</template>
{{ t("common.move") }}
</ODropdownItem>
@@ -730,7 +814,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("alerts.ack.acknowledge") }}
</ODropdownItem>
</template>
<template v-if="downtimesEnabled">
<template v-if="downtimesEnabled && !anomalyRowLocked(row)">
<ODropdownSeparator />
<template v-if="isAlertScopedMute(row) && row.active_downtime">
<MuteMenuItems
@@ -763,24 +847,38 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<ODropdownSeparator />
<ODropdownItem
:disabled="anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-delete-alert`"
variant="destructive"
shortcut-id="alertsRowDelete"
@select="showDeleteDialogFn({ row })"
>
<template #icon-left>
<OIcon name="delete" size="sm" />
<OIcon
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'delete'"
size="sm"
/>
</template>
{{ t("alerts.delete") }}
</ODropdownItem>
<ODropdownSeparator />
<ODropdownItem
:disabled="anomalyRowLocked(row)"
:focusable-unavailable="anomalyRowLocked(row)"
:description="anomalyRowLocked(row) ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-export-alert`"
shortcut-id="alertsRowExport"
@select="exportAlert(row)"
>
<template #icon-left>
<OIcon size="sm" name="download" />
<OIcon
size="sm"
:class="anomalyRowLocked(row) ? 'lock-chip' : undefined"
:name="anomalyRowLocked(row) ? 'lock' : 'download'"
/>
</template>
{{ t("common.export") }}
</ODropdownItem>
@@ -788,20 +886,34 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Anomaly Detection: Trigger Detection + Re-train -->
<template v-if="row.type === 'anomaly'">
<ODropdownItem
:disabled="anomalyLicenseLocked"
:focusable-unavailable="anomalyLicenseLocked"
:description="anomalyLicenseLocked ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-trigger-detection`"
@select="triggerAlert(row)"
>
<template #icon-left>
<OIcon size="sm" name="sound-sampler" />
<OIcon
size="sm"
:class="anomalyLicenseLocked ? 'lock-chip' : undefined"
:name="anomalyLicenseLocked ? 'lock' : 'sound-sampler'"
/>
</template>
{{ t("alerts.triggerDetection") }}
</ODropdownItem>
<ODropdownItem
:disabled="anomalyLicenseLocked"
:focusable-unavailable="anomalyLicenseLocked"
:description="anomalyLicenseLocked ? anomalyLockedReason : undefined"
:data-test="`alert-list-${row.name}-retrain-anomaly`"
@select="retrainAnomaly(row)"
>
<template #icon-left>
<OIcon size="sm" name="replay" />
<OIcon
size="sm"
:class="anomalyLicenseLocked ? 'lock-chip' : undefined"
:name="anomalyLicenseLocked ? 'lock' : 'replay'"
/>
</template>
{{ t("alerts.retrain") }}
</ODropdownItem>
@@ -1202,6 +1314,8 @@ import type { StatItem } from "@/lib/data/StatStrip/OStatStrip.types";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { COL } from "@/lib/core/Table/OTable.types";
@@ -1355,7 +1469,7 @@ export default defineComponent({
// spinner. `loading` is the skeleton, which only a cold read wants.
const fetching = ref(false);
const lastUpdatedAt = ref<number | null>(null);
const forbidden = ref(false);
const forbidden = ref<ForbiddenState>(false);
const oTableRef: any = ref(null);
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
watch(
@@ -1484,9 +1598,13 @@ export default defineComponent({
const anomalyDetectionEntitled = computed(
() => store.state.zoConfig.build_type !== "opensource" && config.isEnterprise === "true",
);
const { locked: anomalyLicenseLocked, lockedReason: anomalyLockedReason } =
useLicenseLock("anomaly_detection");
const anomalyDetectionTabVisible = computed(
() =>
!anomalyDetectionEntitled.value || store.state.zoConfig.anomaly_detection_enabled === true,
!anomalyDetectionEntitled.value ||
store.state.zoConfig.anomaly_detection_enabled === true ||
anomalyLicenseLocked.value,
);
const isAnomalyDetectionEnabled = computed(
() =>
@@ -1498,7 +1616,11 @@ export default defineComponent({
// Prevent forcing anomalyDetection tab when the feature is not available.
const rawTab = (router.currentRoute.value.query.tab as string) || "all";
const activeTab = ref(
rawTab === "anomalyDetection" && !isAnomalyDetectionEnabled.value ? "all" : rawTab,
rawTab === "anomalyDetection" &&
!isAnomalyDetectionEnabled.value &&
!anomalyLicenseLocked.value
? "all"
: rawTab,
);
const filteredResults: Ref<any[]> = ref([]);
@@ -1514,6 +1636,9 @@ export default defineComponent({
? alertDrafts.savedAtVariant(ANOMALY_DRAFT_VARIANT)
: alertDrafts.savedAt(null),
);
const anomalyTabLicenseLocked = computed(
() => activeTab.value === "anomalyDetection" && anomalyLicenseLocked.value,
);
// ── "Calm Signal" table helpers ─────────────────────────────────────────
// Keyed on firing time, not run time; raw µs keeps it timezone-independent.
@@ -1538,6 +1663,11 @@ export default defineComponent({
// instead, and links there — which is also where it is edited (D1).
const isSloRow = (row: any): boolean => isSloAlert(row);
const rowAnomalyLocked = (row: any): boolean =>
anomalyLicenseLocked.value && (row?.type === "anomaly" || isSloRow(row));
// Every action on an anomaly row calls the anomaly detection API; SLO alerts only edit through it.
const anomalyRowLocked = (row: any): boolean =>
anomalyLicenseLocked.value && row?.type === "anomaly";
/** id -> name for every SLO in the org. `list_slos` is unpaginated and
* folder-agnostic when asked without a folder, so ONE call resolves every
@@ -1551,7 +1681,7 @@ export default defineComponent({
let sloNamesLoaded = false;
const ensureSloNames = (rows: any[]): void => {
if (sloNamesInFlight || sloNamesLoaded) return;
if (sloNamesInFlight || sloNamesLoaded || anomalyLicenseLocked.value) return;
if (!rows?.some((row: any) => isSloRow(row))) return;
// The org can legitimately be unset on the very first tick; throwing here
// would surface as an unhandled error inside a watcher, from a lookup
@@ -1794,6 +1924,7 @@ export default defineComponent({
"active" | "paused" | "failed" | "firing" | "acknowledged" | "recent" | null
>(savedAlertListFilters.stateFilter);
const displayedAlerts = computed(() => {
if (anomalyTabLicenseLocked.value) return [];
const rows = filteredResults.value || [];
const f = stateFilter.value;
if (!f) return rows;
@@ -1835,7 +1966,7 @@ export default defineComponent({
label: t("alerts.anomalyDetection"),
value: "anomalyDetection",
icon: "query-stats",
locked: !isAnomalyDetectionEnabled.value,
locked: anomalyLicenseLocked.value || !isAnomalyDetectionEnabled.value,
});
}
return tabs;
@@ -2429,7 +2560,7 @@ export default defineComponent({
} catch (error: any) {
console.error(error);
dismiss();
forbidden.value = error?.response?.status === 403;
forbidden.value = forbiddenState(error);
if (!forbidden.value) {
toast({
variant: "error",
@@ -2538,6 +2669,10 @@ export default defineComponent({
// OToggleGroup is controlled (`:model-value`), so refusing to advance
// `activeTab` here is what keeps a locked tab from becoming active —
// clicking it opens the upgrade dialog instead of switching.
if (tab === "anomalyDetection" && anomalyLicenseLocked.value) {
activeTab.value = tab;
return;
}
if (tab === "anomalyDetection" && !isAnomalyDetectionEnabled.value) {
openUpgradeDialog();
return;
@@ -2908,6 +3043,7 @@ export default defineComponent({
};
const duplicateAlert = async (row: any) => {
if (anomalyRowLocked(row)) return;
// SLO alerts are not clonable (D1). The dialog's first act is to demand a
// stream type and name, which this family has none of — and a clone that
// did succeed would be a creation path outside the SLO page that silently
@@ -3361,6 +3497,7 @@ export default defineComponent({
});
};
const showDeleteDialogFn = (props: any) => {
if (anomalyRowLocked(props?.row)) return;
selectedDelete.value = props.row;
confirmDelete.value = true;
};
@@ -3414,6 +3551,7 @@ export default defineComponent({
};
const toggleAlertState = (row: any) => {
if (anomalyRowLocked(row)) return;
alertStateLoadingMap.value[row.uuid] = true;
// Two mutations, not one conditional invalidation: an anomaly row is also a config Overview renders.
@@ -3536,7 +3674,7 @@ export default defineComponent({
};
const exportAlert = async (row: any) => {
if (exportLoading.value) return;
if (exportLoading.value || anomalyRowLocked(row)) return;
exportLoading.value = true;
try {
const alert = await fetchAlertForExport(row.alert_id);
@@ -3566,6 +3704,7 @@ export default defineComponent({
};
const triggerAlert = async (row: any) => {
if (anomalyLicenseLocked.value && row?.type === "anomaly") return;
try {
const res = await alertsService.trigger_alert(
store.state.selectedOrganization.identifier,
@@ -3581,7 +3720,9 @@ export default defineComponent({
variant: "warning",
message: res.data.message || t("alerts.messages.triggerAlertFailed"),
}
: { variant: "success", message: t("alerts.alertTriggeredSuccess") },
: res?.data?.warning
? { variant: "warning", message: raw(res.data.warning) }
: { variant: "success", message: t("alerts.alertTriggeredSuccess") },
);
if (row.type === "anomaly") {
await getAlertsFn(store, activeFolderId.value, "", true, "", true);
@@ -3596,6 +3737,7 @@ export default defineComponent({
};
const retrainAnomaly = async (row: any) => {
if (anomalyLicenseLocked.value) return;
try {
await retrainAnomalyAlert.mutateAsync(row.alert_id);
row.status = "training";
@@ -3665,6 +3807,7 @@ export default defineComponent({
};
const editAlert = async (row: any) => {
if (rowAnomalyLocked(row)) return;
// SLO alerts are authored on the SLO page: the generic editor has no way
// to represent one (no stream, no query, a different condition), and
// saving from it would either fail forever or strip the SLO wiring.
@@ -3708,6 +3851,7 @@ export default defineComponent({
};
const moveAlertToAnotherFolder = (row: any) => {
if (anomalyRowLocked(row)) return;
showMoveAlertDialog.value = true;
if (row.type === "anomaly") {
selectedAlertToMove.value = [];
@@ -4386,6 +4530,11 @@ export default defineComponent({
config,
isCompactToolbar,
isAnomalyDetectionEnabled,
anomalyLicenseLocked,
anomalyLockedReason,
anomalyTabLicenseLocked,
rowAnomalyLocked,
anomalyRowLocked,
refreshAlerts,
onAlertTabChange,
referenceDrawerOpen,
@@ -13,8 +13,10 @@
// 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 { describe, expect, it, beforeEach, vi } from "vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { defineComponent, h, ref } from "vue";
import { provideFeatureLock } from "@/composables/useFeatureLock";
import AlertSectionTabs from "./AlertSectionTabs.vue";
import i18n from "@/locales";
@@ -102,4 +104,56 @@ describe("AlertSectionTabs", () => {
const wrapper = mountTabs();
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toContain("Alert Library");
});
describe("license", () => {
afterEach(() => {
delete store.state.zoConfig.features;
});
const lockOf = (wrapper: ReturnType<typeof mountTabs>, key: string) =>
wrapper.find(`[data-test="alert-section-tab-${key}-lock"]`).exists();
it("puts a lock on every tab when alerts is locked", () => {
store.state.zoConfig.features = { alerts: "locked" };
const wrapper = mountTabs();
expect(lockOf(wrapper, "alertList")).toBe(true);
expect(lockOf(wrapper, "alertLibrary")).toBe(true);
expect(lockOf(wrapper, "alertDestinations")).toBe(true);
expect(lockOf(wrapper, "alertTemplates")).toBe(true);
});
it("shows no lock when alerts is enabled", () => {
store.state.zoConfig.features = { alerts: "enabled" };
const wrapper = mountTabs();
expect(wrapper.find('[data-test$="-lock"]').exists()).toBe(false);
});
it("drops the alert-owned tabs when alerts is hidden, keeping destinations and templates", () => {
store.state.zoConfig.features = { alerts: "hidden" };
const wrapper = mountTabs();
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).toEqual([
"Notification Destinations",
"Destination Templates",
]);
});
it("still navigates to the free destinations from a license-locked alert page", async () => {
store.state.zoConfig.features = { alerts: "locked" };
const push = vi.spyOn(router, "push");
const LockedPage = defineComponent({
setup() {
provideFeatureLock(ref("alerts"));
return () => h(AlertSectionTabs);
},
});
const wrapper = mount(LockedPage, {
global: { provide: { store }, plugins: [i18n, router] },
});
await wrapper.find('[data-test="alert-section-tab-alertDestinations"]').trigger("mousedown");
expect(push).toHaveBeenCalledWith({
name: "alertDestinations",
query: { org_identifier: store.state.selectedOrganization.identifier },
});
});
});
});
+49 -24
View File
@@ -25,24 +25,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ships this pattern for Stream Pipelines / Functions.
-->
<template>
<OTabs
:model-value="activeSectionKey"
align="left"
data-test="alert-section-tabs"
@change="navigateToSection"
>
<OTab
v-for="section in sections"
:key="section.key"
:name="section.key"
:data-test="`alert-section-tab-${section.key}`"
<!-- The tabs only navigate, so a license-locked alert page still reaches the free destinations and templates. -->
<FeatureLockExempt class="contents">
<OTabs
:model-value="activeSectionKey"
align="left"
data-test="alert-section-tabs"
@change="navigateToSection"
>
<OIcon :name="section.icon" size="sm" class="shrink-0" />
<span :class="section.key === activeSectionKey ? '' : 'max-md:hidden'">{{
section.label
}}</span>
</OTab>
</OTabs>
<OTab
v-for="section in sections"
:key="section.key"
:name="section.key"
:data-test="`alert-section-tab-${section.key}`"
>
<OIcon :name="section.icon" size="sm" class="shrink-0" />
<span :class="section.key === activeSectionKey ? '' : 'max-md:hidden'">{{
section.label
}}</span>
<OIcon
v-if="section.licenseLocked"
name="lock"
size="xs"
class="lock-chip shrink-0"
:data-test="`alert-section-tab-${section.key}-lock`"
aria-hidden="true"
/>
</OTab>
</OTabs>
</FeatureLockExempt>
</template>
<script setup lang="ts">
@@ -54,7 +65,9 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { featureState } from "@/utils/featureGate";
const { t } = useI18nTyped();
const store = useStore();
@@ -68,6 +81,7 @@ interface Section {
label: I18nText;
icon: IconName;
to: RouteLocationRaw;
licenseLocked?: boolean;
}
const sections = computed<Section[]>(() => {
@@ -76,31 +90,41 @@ const sections = computed<Section[]>(() => {
const hideLibrary = (store.state.zoConfig?.custom_hide_menus ?? "")
.split(",")
.includes("alertLibrary");
const alerts = featureState(store.state.zoConfig?.features, "alerts");
const alertsHidden = alerts === "hidden";
const licenseLocked = alerts === "locked";
return [
// "All Alerts", not "Alerts": the page title above this strip already says
// "Alerts", so a tab repeating it names nothing. The rail's Alerts
// subsection uses the same string for the same reason.
{
key: "alertList",
label: t("alerts.allAlerts"),
icon: "shield-alert-outline",
to: { name: "alertList", query },
},
...(alertsHidden
? []
: [
{
key: "alertList",
label: t("alerts.allAlerts"),
icon: "shield-alert-outline" as IconName,
to: { name: "alertList", query },
licenseLocked,
},
]),
{
key: "alertDestinations",
label: t("alert_destinations.header"),
icon: "location-on",
to: { name: "alertDestinations", query },
licenseLocked,
},
{
key: "alertTemplates",
label: t("alert_templates.header"),
icon: "description",
to: { name: "alertTemplates", query },
licenseLocked,
},
// Last: the catalog is where you go once to fetch an alert, not where you
// work. The rail's Reliability flyout lists these four in this same order.
...(hideLibrary
...(hideLibrary || alertsHidden
? []
: [
{
@@ -108,6 +132,7 @@ const sections = computed<Section[]>(() => {
label: t("alert_library.header"),
icon: "menu-book" as IconName,
to: { name: "alertLibrary", query },
licenseLocked,
},
]),
];
@@ -370,6 +370,23 @@ describe("AlertTargetsSelect", () => {
});
});
it("shows a disabled Create Workflow button for a license lock and never opens the upgrade dialog", async () => {
const wrapper = createWrapper({
workflowsEnabled: false,
workflowsLocked: true,
workflowsLicenseLocked: true,
workflowsLockMessage: "Locked: needs Workflows",
});
const btn = wrapper.find('[data-test="create-workflow-btn"]');
expect(btn.exists()).toBe(true);
expect(btn.attributes("disabled")).toBeDefined();
const { isOpen, close } = useEnterpriseUpgradeDialog();
close();
await btn.trigger("click");
expect(isOpen.value).toBe(false);
});
// ── Malformed / partially-loaded lists ─────────────────────────────────────
// REGRESSION: with the backend unreachable, `getFormattedDestinations` maps
// `destination.name` over rows that have no `name`, producing [undefined].
@@ -85,6 +85,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Locked (not hidden): the family supports workflow routing, but this
edition doesn't unlock it — kept as the discovery point since the
dropdown's own "Workflows" header has no icon/tooltip slot to attach. -->
<OTooltip v-else-if="workflowsLicenseLocked" :content="workflowsLockMessage">
<span class="ms-2 inline-flex">
<OButton
data-test="create-workflow-btn"
variant="outline"
size="sm"
icon-left="lock"
disabled
>
{{ t("workflow.create") }}
</OButton>
</span>
</OTooltip>
<template v-else-if="workflowsLocked">
<OButton
data-test="create-workflow-btn"
@@ -93,7 +106,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="ms-2"
@click="openUpgradeDialog"
>
<template #icon-left><OIcon name="lock" size="sm" class="text-warning-600" /></template>
<template #icon-left><OIcon name="lock" size="sm" class="lock-chip" /></template>
{{ t("workflow.create") }}
</OButton>
</template>
@@ -106,6 +119,7 @@ import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useEnterpriseUpgradeDialog } from "@/composables/useEnterpriseUpgradeDialog";
const { open: openUpgradeDialog } = useEnterpriseUpgradeDialog();
@@ -125,6 +139,7 @@ const props = defineProps<{
workflowsEnabled: boolean;
/** Family supports workflows but this edition doesn't unlock them — show the group locked instead of hiding it. */
workflowsLocked?: boolean;
workflowsLicenseLocked?: boolean;
workflowsLockMessage?: I18nText;
error?: boolean;
placeholder?: I18nText;
@@ -465,6 +465,7 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import AlertSectionTabs from "@/components/alerts/AlertSectionTabs.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { COL } from "@/lib/core/Table/OTable.types";
@@ -752,7 +753,7 @@ export default defineComponent({
};
const loading = ref(false);
const forbidden = ref(false);
const forbidden = ref<ForbiddenState>(false);
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
watch(
loading,
@@ -829,7 +830,7 @@ export default defineComponent({
return true;
})
.catch((err: any) => {
forbidden.value = err?.response?.status === 403;
forbidden.value = forbiddenState(err);
if (!forbidden.value) {
toast({
variant: "error",
@@ -16,6 +16,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import { createStore } from "vuex";
import OButton from "@/lib/core/Button/OButton.vue";
import CreateAlertAction from "@/components/alerts/CreateAlertAction.vue";
@@ -53,7 +54,11 @@ const ButtonStub = {
const stubs = {
ODropdownItem: DropdownItemStub,
OButton: ButtonStub,
OTooltip: { name: "OTooltip", props: ["content"], template: `<span>{{ content }}</span>` },
OTooltip: {
name: "OTooltip",
props: ["content"],
template: `<span>{{ content }}<slot /></span>`,
},
};
const prefill = (): AlertPrefill => ({
@@ -327,4 +332,54 @@ describe("CreateAlertAction", () => {
wrapper = mountAction({ source: "logs" });
expect(wrapper.text()).toContain("Create Alert");
});
describe("license lock", () => {
const mountWithFeatures = (features: Record<string, string>, props = {}) =>
mount(CreateAlertAction, {
props: { source: "logs", build: vi.fn(needsDialog), variant: "button", ...props },
global: {
plugins: [
i18n,
createStore({ state: { zoConfig: { features, feature_names: { alerts: "Alerts" } } } }),
],
stubs,
},
});
it.each(["button", "toolbar", "icon"])(
"wraps the disabled %s in a hover tooltip with the license reason",
(variant) => {
wrapper = mountWithFeatures({ alerts: "locked" }, { variant });
const tooltip = wrapper.findComponent({ name: "OTooltip" });
expect(tooltip.props("content")).toBe("Locked: needs Alerts");
const button = tooltip.find("span.inline-flex").findComponent({ name: "OButton" });
expect(button.find("button").attributes("disabled")).toBeDefined();
expect(button.props("iconLeft")).toBe("lock");
},
);
it("opens nothing while locked", async () => {
wrapper = mountWithFeatures({ alerts: "locked" });
(wrapper.vm as any).$.setupState.onActivate();
expect(alertCreationDialog.value).toBeNull();
expect(mockOpenAlertCreation).not.toHaveBeenCalled();
});
it("disables the menu item with the license reason", () => {
wrapper = mountWithFeatures({ alerts: "locked" }, { variant: "menu-item" });
const item = wrapper.findComponent(DropdownItemStub);
expect(item.props("disabled")).toBe(true);
expect(item.props("description")).toBe("Locked: needs Alerts");
});
it("is unchanged while alerts is enabled", async () => {
wrapper = mountWithFeatures({ alerts: "enabled" });
const button = wrapper.findComponent({ name: "OButton" });
expect(button.props("disabled")).toBe(false);
expect(button.props("iconLeft")).not.toBe("lock");
await wrapper.find("button").trigger("click");
expect(alertCreationDialog.value).not.toBeNull();
});
});
});
+46 -18
View File
@@ -31,9 +31,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<ODropdownItem
v-if="variant === 'menu-item'"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:description="disabledReason || note"
:description="blockedReason || note"
:data-test="dataTest"
@select="onActivate"
>
@@ -41,28 +41,46 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
the dashboards panel menu does not — so the presentation is the host's
call while the icon NAME stays the registry's. -->
<template #icon-left>
<slot name="icon-left" :icon="source_.icon">
<OIcon :name="source_.icon" size="sm" />
<slot name="icon-left" :icon="icon">
<OIcon :name="icon" size="sm" />
</slot>
</template>
<span>{{ label }}</span>
</ODropdownItem>
<!-- A disabled button fires no pointer events, so the span is the tooltip's hover target. -->
<OTooltip v-else-if="locked" :content="lockedReason">
<span class="inline-flex">
<OButton
:variant="lockedButton.variant"
:size="lockedButton.size"
disabled
icon-left="lock"
:aria-label="label"
:data-test="dataTest"
>
<span v-if="variant !== 'icon'" :class="{ 'max-md:hidden': variant === 'toolbar' }">{{
label
}}</span>
</OButton>
</span>
</OTooltip>
<OButton
v-else-if="variant === 'button'"
:variant="buttonVariant"
size="sm-action"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:icon-left="source_.icon"
:icon-left="icon"
:data-test="dataTest"
@click="onActivate"
>
<template #unavailable-reason>{{ disabledReason }}</template>
<template #unavailable-reason>{{ blockedReason }}</template>
{{ label }}
<OTooltip
v-if="disabledReason || note"
:content="disabledReason || note || undefined"
v-if="blockedReason || note"
:content="blockedReason || note || undefined"
side="top"
/>
</OButton>
@@ -71,31 +89,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-else-if="variant === 'toolbar'"
variant="outline"
size="sm-toolbar"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:icon-left="source_.icon"
:icon-left="icon"
:aria-label="label"
:data-test="dataTest"
@click="onActivate"
>
<template #unavailable-reason>{{ disabledReason }}</template>
<template #unavailable-reason>{{ blockedReason }}</template>
<span class="max-md:hidden">{{ label }}</span>
<OTooltip v-if="disabledReason || isMobile" :content="disabledReason || label" />
<OTooltip v-if="blockedReason || isMobile" :content="blockedReason || label" />
</OButton>
<OButton
v-else
variant="ghost"
size="sm"
:disabled="!!disabledReason"
:disabled="!!blockedReason"
focusable-unavailable
:icon-left="source_.icon"
:icon-left="icon"
:aria-label="label"
:data-test="dataTest"
@click="onActivate"
>
<template #unavailable-reason>{{ disabledReason }}</template>
<OTooltip :content="disabledReason || label" side="top" />
<template #unavailable-reason>{{ blockedReason }}</template>
<OTooltip :content="blockedReason || label" side="top" />
</OButton>
</template>
@@ -111,6 +129,7 @@ import { getAlertSource } from "@/utils/alerts/alertSourceRegistry";
import { needsConfirmation, normalizePrefill } from "@/utils/alerts/alertPrefill";
import { requestAlertCreation, useAlertCreation } from "@/composables/alerts/useAlertCreation";
import useBreakpoint from "@/composables/useBreakpoint";
import { useLicenseLock } from "@/composables/useFeatureLock";
const props = withDefaults(
defineProps<{
@@ -147,8 +166,17 @@ const { isMobile } = useBreakpoint();
const source_ = computed(() => getAlertSource(props.source));
const label = computed(() => t(source_.value.labelKey));
const { locked, lockedReason } = useLicenseLock("alerts");
const blockedReason = computed(() => (locked.value ? lockedReason.value : props.disabledReason));
const icon = computed(() => (locked.value ? "lock" : source_.value.icon));
const lockedButton = computed(() => {
if (props.variant === "toolbar") return { variant: "outline", size: "sm-toolbar" } as const;
if (props.variant === "icon") return { variant: "ghost", size: "sm" } as const;
return { variant: props.buttonVariant, size: "sm-action" } as const;
});
const onActivate = () => {
if (props.disabledReason) return;
if (blockedReason.value) return;
const prefill = normalizePrefill(props.build());
@@ -16,6 +16,7 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import i18n from "@/locales";
import { createStore } from "vuex";
import CreateAlertDialogProvider from "@/components/alerts/CreateAlertDialogProvider.vue";
import { ALERT_PREFILL_VERSION, type AlertPrefill } from "@/ts/interfaces/alertPrefill";
@@ -155,4 +156,18 @@ describe("CreateAlertDialogProvider", () => {
expect(alertCreationDialog.value).toBeNull();
});
it("shows no dialog and drops the request while alerts is license-locked", async () => {
wrapper = mount(CreateAlertDialogProvider, {
global: {
plugins: [i18n, createStore({ state: { zoConfig: { features: { alerts: "locked" } } } })],
stubs: { CreateAlertFromSourceDialog: DialogStub },
},
});
requestAlertCreation(prefill());
await wrapper.vm.$nextTick();
expect(wrapper.findComponent(DialogStub).exists()).toBe(false);
expect(alertCreationDialog.value).toBeNull();
});
});
@@ -27,7 +27,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<CreateAlertFromSourceDialog
v-if="dialog"
v-if="dialog && !locked"
:open="dialog.open"
:prefill="dialog.prefill"
@update:open="onOpenChange"
@@ -38,8 +38,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { defineAsyncComponent, watch } from "vue";
import type { AlertPrefill } from "@/ts/interfaces/alertPrefill";
import { useLicenseLock } from "@/composables/useFeatureLock";
import {
alertCreationDialog as dialog,
closeAlertCreationDialog,
@@ -58,6 +59,14 @@ const CreateAlertFromSourceDialog = defineAsyncComponent({
});
const { openAlertCreation } = useAlertCreation();
const { locked } = useLicenseLock("alerts");
// Surfaces that request the dialog without CreateAlertAction must not leave a request that pops up after an unlock.
watch(
() => !!dialog.value && locked.value,
(blocked) => blocked && closeAlertCreationDialog(),
{ immediate: true },
);
const onConfirm = (prefill: AlertPrefill) => {
openAlertCreation(prefill, dialog.value?.options ?? {});
@@ -65,6 +65,19 @@ describe("DependencyUsageCell", () => {
wrapper.unmount();
});
it("shows an em dash and no badges or dialog while alerts is license-locked", async () => {
store.state.zoConfig.features = { alerts: "locked" };
try {
const wrapper = mountCell(graph, { kind: "destination", name: "slack" });
expect(wrapper.find('[data-test="used-by-slack-locked"]').text()).toBe("—");
expect(wrapper.find('[data-test="used-by-slack"]').exists()).toBe(false);
expect(wrapper.findComponent({ name: "DependencyImpactDialog" }).exists()).toBe(false);
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("shows the Unused chip for an orphan entity", async () => {
const wrapper = mountCell(graph, { kind: "destination", name: "lonely" });
await flushPromises();
@@ -21,7 +21,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
cell opens the impact dialog.
-->
<template>
<!-- Usage is counted from the alerts list, which a license without alerts cannot read. -->
<span v-if="alertsLocked" class="text-text-muted" :data-test="`used-by-${key}-locked`">{{
raw("—")
}}</span>
<OButton
v-else
variant="ghost"
size="sm"
class="h-auto! gap-1! px-1.5! py-1! font-normal!"
@@ -58,13 +63,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</OButton>
<DependencyImpactDialog v-model:open="open" :focus="focus" @deleted="emit('deleted', $event)" />
<DependencyImpactDialog
v-if="!alertsLocked"
v-model:open="open"
:focus="focus"
@deleted="emit('deleted', $event)"
/>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped } from "@/types/i18n";
import type { I18nText } from "@/types/i18n";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
@@ -82,6 +93,7 @@ const props = defineProps<{ graph: DepGraph; focus: DepFocus }>();
const emit = defineEmits<{ (e: "deleted", kind: DepNodeKind): void }>();
const { t } = useI18nTyped();
const { locked: alertsLocked } = useLicenseLock("alerts");
const open = ref(false);
@@ -349,6 +349,7 @@ import { getAlertSourceStatus } from "@/utils/alertSourceStatus";
import { formatTimeAgoUs } from "@/utils/synthetics/format";
import { copyToClipboard } from "@/utils/clipboard";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { getEndPoint, getIngestionURL } from "@/utils/zincutils";
import { COL } from "@/lib/core/Table/OTable.types";
import type { AlertSourceIntegration } from "@/ts/interfaces/alertSources";
@@ -428,7 +429,7 @@ export default defineComponent({
// Request in flight with rows still on screen — the refresh button's
// spinner. `loading` is the skeleton, for a cold read only.
fetching: false,
forbidden: false,
forbidden: false as ForbiddenState,
lastUpdatedAt: null as number | null,
filterQuery: "",
showAddDrawer: false,
@@ -627,7 +628,7 @@ export default defineComponent({
this.lastUpdatedAt =
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
} catch (e: any) {
this.forbidden = e?.response?.status === 403;
this.forbidden = forbiddenState(e);
if (!this.forbidden) {
toast({ variant: "error", message: this.t("alert_sources.error") });
}
@@ -341,6 +341,17 @@ describe("IncidentDetailDrawer.vue", () => {
expect(wrapper.vm.incidentDetails.title).toBe("Test Incident");
});
it("shows the license empty state when the load is license-denied", async () => {
(incidentsService.get as any).mockRejectedValue({
response: { status: 403, data: { code: 40001, error_detail: "incidents" } },
});
wrapper = await createWrapper({}, {}, "test-123");
await flushPromises();
expect(wrapper.find('[data-test="incident-detail-license-denied"]').text()).toContain(
"incidents",
);
});
it("shows no Alert Triggers count until the incident has loaded", async () => {
let resolveIncident: (value: any) => void = () => {};
(incidentsService.get as any).mockReturnValue(
@@ -1030,6 +1041,46 @@ describe("IncidentDetailDrawer.vue", () => {
});
});
describe("correlation license lock", () => {
afterEach(() => {
delete store.state.zoConfig.features;
});
it("locks the telemetry tabs and never calls the correlation API", async () => {
store.state.zoConfig.features = { correlation: "locked" };
wrapper = await createWrapper({}, {}, "1");
await flushPromises();
(incidentsService.getCorrelatedStreams as any).mockClear();
wrapper.vm.activeTab = "logs";
await flushPromises();
await wrapper.vm.refreshCorrelation();
expect(wrapper.find('[data-test="incident-correlation-license-denied"]').exists()).toBe(true);
expect(wrapper.findComponent({ name: "CorrelatedLogsTable" }).exists()).toBe(false);
expect(incidentsService.getCorrelatedStreams).not.toHaveBeenCalled();
const logsTab = wrapper
.findAllComponents({ name: "OTab" })
.find((tab) => tab.props("name") === "logs");
expect(logsTab?.props("suffixIcon")).toBe("lock");
});
it("loads correlated streams when correlation is enabled", async () => {
store.state.zoConfig.features = { correlation: "enabled" };
(incidentsService.getCorrelatedStreams as any).mockResolvedValue(null);
wrapper = await createWrapper({}, {}, "1");
await flushPromises();
wrapper.vm.activeTab = "logs";
await flushPromises();
expect(wrapper.find('[data-test="incident-correlation-license-denied"]').exists()).toBe(
false,
);
expect(incidentsService.getCorrelatedStreams).toHaveBeenCalled();
});
});
// Note: Loading State Centering tests removed
// Testing loading state UI presentation is better handled through visual regression tests
// Component logic tests should focus on loading state management, not CSS/layout
@@ -147,6 +147,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:name="tab"
:label="tab === 'alertTriggers' ? undefined : tabLabels[tab]"
:data-test="TAB_TEST_IDS[tab]"
:suffix-icon="isCorrelationTab(tab) && correlationLocked ? 'lock' : undefined"
:tooltip="
isCorrelationTab(tab) && correlationLocked ? correlationLockedReason : undefined
"
>
<template v-if="tab === 'alertTriggers'" #default>
<div class="flex items-center gap-1.5">
@@ -1203,7 +1207,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Logs Tab Content -->
<div v-if="activeTab === 'logs'" class="flex h-full flex-1 flex-col overflow-hidden">
<LicenseDeniedState
v-if="correlationLocked && isCorrelationTab(activeTab)"
:features="['correlation']"
data-test="incident-correlation-license-denied"
/>
<div
v-else-if="activeTab === 'logs'"
class="flex h-full flex-1 flex-col overflow-hidden"
>
<!-- Loading State -->
<div
v-if="correlationLoading"
@@ -1284,7 +1296,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Metrics Tab Content -->
<div
v-if="activeTab === 'metrics'"
v-else-if="activeTab === 'metrics'"
class="flex h-full flex-1 flex-col overflow-hidden"
>
<!-- Loading State -->
@@ -1365,7 +1377,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Traces Tab Content -->
<div
v-if="activeTab === 'traces'"
v-else-if="activeTab === 'traces'"
class="flex h-full flex-1 flex-col overflow-hidden"
>
<!-- Refresh Button (shown when traces data is loaded) -->
@@ -1470,6 +1482,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div v-if="loading" class="flex flex-1 items-center justify-center">
<OSpinner size="md" />
</div>
<LicenseDeniedState
v-else-if="licenseDenied"
:features="licenseDenied"
data-test="incident-detail-license-denied"
/>
</OPageLayout>
</div>
</template>
@@ -1539,6 +1556,9 @@ import { useIncidentRca } from "@/composables/useIncidentRca";
import type { RcaAskPayload } from "@/utils/incidents/rcaAskAttachment";
import BillingService, { type AiCreditCounts } from "@/services/billings";
import config from "@/aws-exports";
import { licenseDeniedFeatures } from "@/utils/featureGate";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
import { useLicenseLock } from "@/composables/useFeatureLock";
const LS_INCIDENT_TAB_ORDER_KEY = "o2_incident_detail_tab_order";
const DEFAULT_INCIDENT_TAB_ORDER = [
@@ -1563,6 +1583,12 @@ const TAB_TEST_IDS: Record<IncidentTab, string> = {
traces: "incident-traces-tab",
};
const CORRELATION_TABS: readonly string[] = ["logs", "metrics", "traces"];
function isCorrelationTab(name: unknown): boolean {
return CORRELATION_TABS.includes(name as string);
}
function isIncidentTab(name: unknown): name is IncidentTab {
return DEFAULT_INCIDENT_TAB_ORDER.includes(name as IncidentTab);
}
@@ -1603,6 +1629,7 @@ export default defineComponent({
OTruncatedText,
OInlineEdit,
OBanner,
LicenseDeniedState,
},
emits: ["close", "status-updated", "sendToAiChat"],
setup(props, { emit }) {
@@ -1637,6 +1664,7 @@ export default defineComponent({
};
const loading = ref(false);
const licenseDenied = ref<string[] | null>(null);
const updating = ref(false);
// Backend also returns a top-level correlation_reason not declared on IncidentWithAlerts.
const incidentDetails = ref<(IncidentWithAlerts & { correlation_reason?: string }) | null>(
@@ -1810,6 +1838,8 @@ export default defineComponent({
{ label: t("alerts.incidents.severityP4"), value: "P4" },
];
const { locked: correlationLocked, lockedReason: correlationLockedReason } =
useLicenseLock("correlation");
// Telemetry correlation state
const correlationData = ref<IncidentCorrelatedStreams | null>(null);
const correlationLoading = ref(false);
@@ -2095,7 +2125,7 @@ export default defineComponent({
// Fetch correlated telemetry streams
const fetchCorrelatedStreams = async (force: boolean = false) => {
if (!incidentDetails.value) return;
if (!incidentDetails.value || correlationLocked.value) return;
// Skip if already loaded and not forcing refresh
if (!force && correlationData.value) return;
@@ -2508,6 +2538,7 @@ export default defineComponent({
const loadDetails = async (incidentId: string) => {
loading.value = true;
licenseDenied.value = null;
// Reset correlation state when loading new incident
correlationData.value = null;
@@ -2539,6 +2570,7 @@ export default defineComponent({
}
} catch (error) {
console.error("Failed to load incident details:", error);
licenseDenied.value = licenseDeniedFeatures(error);
toast({
variant: "error",
message: t("toastMessages.alerts.failedToLoadIncidentDetails"),
@@ -3099,6 +3131,7 @@ export default defineComponent({
};
return {
licenseDenied,
mutedBannerText,
openMutingDowntime,
raw,
@@ -3181,6 +3214,9 @@ export default defineComponent({
alertActivityChartData,
getSemanticGroupDisplayName,
refreshCorrelation,
correlationLocked,
correlationLockedReason,
isCorrelationTab,
close,
handleSendToAiChat,
acknowledgeIncident,
@@ -311,6 +311,18 @@ describe("IncidentList.vue", () => {
await (wrapper.vm as any).refreshIncidents();
await flushPromises();
expect(incidentsService.list).toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "success" }));
});
it("does not report success when the refresh fails", async () => {
wrapper = createWrapper();
await flushPromises();
vi.clearAllMocks();
(incidentsService.list as any).mockRejectedValue({ response: { status: 403 } });
await (wrapper.vm as any).refreshIncidents();
await flushPromises();
expect(incidentsService.list).toHaveBeenCalled();
expect(toast).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -397,6 +397,7 @@ import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
import { forbiddenState } from "@/utils/featureGate";
import OTable from "@/lib/core/Table/OTable.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import ODimensionChip from "@/lib/core/Badge/ODimensionChip.vue";
@@ -466,7 +467,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 = incidentsList.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// The first real load lands after mount: land on the saved page, or page 1 when it no longer exists. setTimeout(0) runs after the table has taken the new rows.
watch(
@@ -1120,8 +1121,7 @@ export default defineComponent({
);
const refreshIncidents = async () => {
// Explicit refresh: must reach the server.
await loadIncidents(true);
if ((await incidentsList.refetch()).isError) return;
toast({
variant: "success",
message: t("toastMessages.alerts.incidentsRefreshed"),
@@ -241,6 +241,7 @@ import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { toast } from "@/lib/feedback/Toast/useToast";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { COL } from "@/lib/core/Table/OTable.types";
interface ConformDelete {
@@ -414,7 +415,7 @@ export default defineComponent({
// spinner. `loading` is the skeleton, for a cold read only.
const fetching = ref(false);
const lastUpdatedAt = ref<number | null>(null);
const forbidden = ref(false);
const forbidden = ref<ForbiddenState>(false);
// Bound to refresh / post-write reloads: always reaches the server.
const refreshDestinations = () => getDestinations(true);
@@ -457,7 +458,7 @@ export default defineComponent({
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
})
.catch((err: any) => {
forbidden.value = err?.response?.status === 403;
forbidden.value = forbiddenState(err);
if (!forbidden.value) {
toast({
variant: "error",
+2 -1
View File
@@ -475,6 +475,7 @@ import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import { useDraftLabel } from "@/components/common/drafts/useDraftLabel";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { forbiddenState } from "@/utils/featureGate";
const AddTemplate = defineAsyncComponent(() => import("@/components/alerts/AddTemplate.vue"));
@@ -623,7 +624,7 @@ const refreshTemplates = () => getTemplates(true);
// 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 = templatesList.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// The first real load lands after mount and races TanStack's own auto-reset-on-data-change, which resolves through its own deferred microtask queue — setTimeout(0) runs strictly after that queue drains, so the restored page reliably wins.
watch(
@@ -133,6 +133,58 @@ describe("AddDowntime", () => {
await flushPromises();
};
it("locks the license-locked target modules and reads no synthetics folders", async () => {
vi.mocked(common.list_Folders).mockClear();
store.state.zoConfig.features = { synthetics: "locked", anomaly_detection: "locked" };
try {
const { wrapper } = await mountPage();
for (const module of ["synthetics", "anomaly_detections", "slos"]) {
const item = wrapper.get(`[data-test="add-downtime-module-${module}"]`);
expect(item.attributes("disabled")).toBeDefined();
}
expect(
wrapper.get('[data-test="add-downtime-module-alerts"]').attributes("disabled"),
).toBeUndefined();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).not.toContain("synthetics");
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("drops a prefilled module when a late config locks it", async () => {
const router = makeRouter();
await router.push({
name: "addDowntime",
query: { org_identifier: "default", module: "synthetics" },
});
await router.isReady();
const wrapper = mount(AddDowntime, {
global: { plugins: [store, router] },
attachTo: document.body,
});
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics"]').exists()).toBe(true);
try {
store.state.zoConfig.features = { synthetics: "locked" };
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics"]').exists()).toBe(false);
} finally {
delete store.state.zoConfig.features;
wrapper.unmount();
}
});
it("leaves every target module open while the features are enabled", async () => {
const { wrapper } = await mountPage();
for (const module of ["alerts", "synthetics", "anomaly_detections", "slos"]) {
const item = wrapper.get(`[data-test="add-downtime-module-${module}"]`);
expect(item.attributes("disabled")).toBeUndefined();
}
wrapper.unmount();
});
it("counts the reason against the server's 1,000-character cap", async () => {
const { wrapper } = await mountPage();
const field = wrapper.get('[data-test="add-downtime-reason"]');
@@ -162,7 +162,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:key="m"
:value="m"
size="sm"
:icon-left="MODULE_ICONS[m]"
:icon-left="moduleLocks[m].locked.value ? 'lock' : MODULE_ICONS[m]"
:disabled="moduleLocks[m].locked.value"
:focusable-unavailable="moduleLocks[m].locked.value"
:tooltip="
moduleLocks[m].locked.value ? moduleLocks[m].lockedReason.value : undefined
"
:data-test="`add-downtime-module-${m}`"
>
{{ t(MODULE_LABEL_KEYS[m]) }}
@@ -378,7 +383,7 @@ import { useMutation, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
import { useOrgId } from "@/composables/query";
import { useAutoName } from "@/composables/useAutoName";
import { useDowntimeItems } from "@/composables/downtimes/useDowntimeItems";
import { useDowntimeItems, useTargetModuleLocks } from "@/composables/downtimes/useDowntimeItems";
import { useDefaultDowntimeFolder } from "@/composables/downtimes/useDefaultDowntimeFolder";
import { foldersQuery, optionalFoldersQuery } from "@/services/common.queries";
import {
@@ -465,7 +470,10 @@ const queryList = (value: unknown): string[] =>
.filter(Boolean);
const browserZone = browserTimezone();
const prefillModule = MODULE_ORDER.find((m) => m === route.query.module);
const moduleLocks = useTargetModuleLocks();
const prefillModule = MODULE_ORDER.find(
(m) => m === route.query.module && !moduleLocks[m].locked.value,
);
// A new downtime without a folder in the URL is filed where the user may create one.
const folderDefault = useDefaultDowntimeFolder();
@@ -493,7 +501,9 @@ const alertFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "alerts"), { enabled: !!orgId.value }),
);
const syntheticFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), {
enabled: !!orgId.value && !moduleLocks.synthetics.locked.value,
}),
);
const detailQuery = useQuery(() =>
Object.assign(downtimeDetailQuery(orgId.value, sourceId.value, sourceFolder), {
@@ -653,6 +663,16 @@ watch(
() => values.value.modules,
(modules) => (listedModules.value = [...modules]),
);
// The org config can land after mount; a new downtime then drops a module its license locks.
watch(
() => MODULE_ORDER.filter((m) => moduleLocks[m].locked.value),
(locked) => {
if (isEdit.value || !locked.length) return;
const kept = values.value.modules.filter((m) => !locked.includes(m));
if (kept.length !== values.value.modules.length) form.setFieldValue("modules", kept);
},
{ immediate: true },
);
const folderId = computed(() => values.value.folder_id);
const setFolder = (value: string) => form.setFieldValue("folder_id", value || "default");
@@ -21,6 +21,7 @@ import DowntimeDetail from "./DowntimeDetail.vue";
import downtimes from "@/services/downtimes";
import type { DowntimeDetail as DowntimeDetailData } from "@/services/downtimes";
import alerts from "@/services/alerts";
import common from "@/services/common";
import { queryClient } from "@/composables/query/queryClient";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OTable from "@/lib/core/Table/OTable.vue";
@@ -256,6 +257,22 @@ describe("DowntimeDetail", () => {
wrapper.unmount();
});
it("reads no synthetics folders while synthetics is license-locked", async () => {
vi.mocked(downtimes.get).mockResolvedValue({ data: detail } as never);
vi.mocked(common.list_Folders).mockClear();
store.state.zoConfig.features = { synthetics: "locked" };
try {
const { wrapper } = await mountDetail();
await flushPromises();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).toContain("alerts");
expect(types).not.toContain("synthetics");
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("shows a not-found state on a 404", async () => {
vi.mocked(downtimes.get).mockRejectedValue({ response: { status: 404 } });
@@ -369,6 +369,7 @@ import { useRoute, useRouter } from "vue-router";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
import { useOrgId } from "@/composables/query";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { foldersQuery, optionalFoldersQuery } from "@/services/common.queries";
import { alertHistoryQuery } from "@/services/alerts.queries";
import {
@@ -479,8 +480,11 @@ const downtimeFolders = useQuery(() =>
const alertFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "alerts"), { enabled: !!orgId.value }),
);
const { locked: syntheticsLocked } = useLicenseLock("synthetics");
const syntheticFolders = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), {
enabled: !!orgId.value && !syntheticsLocked.value,
}),
);
const historyQuery = useQuery(() =>
@@ -208,6 +208,28 @@ describe("DowntimeList", () => {
},
} as never);
it("reads no synthetics folders while synthetics is license-locked", async () => {
cutList();
store.state.zoConfig.features = { synthetics: "locked" };
try {
const wrapper = await mountList();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).toContain("alerts");
expect(types).not.toContain("synthetics");
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("reads the synthetics folders while synthetics is enabled", async () => {
cutList();
const wrapper = await mountList();
const types = vi.mocked(common.list_Folders).mock.calls.map((call) => call[1]);
expect(types).toContain("synthetics");
wrapper.unmount();
});
it("says when the list is cut and keeps a folder's tiles on the rows it shows", async () => {
cutList();
const wrapper = await mountList({ folder: "team-a" });
@@ -616,7 +616,9 @@ import { useStore } from "vuex";
import { useRoute, useRouter } from "vue-router";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { forbiddenState, type ForbiddenState } from "@/utils/featureGate";
import { useOrgId } from "@/composables/query";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { queryClient } from "@/composables/query/queryClient";
import { foldersQuery, optionalFoldersQuery } from "@/services/common.queries";
import { folderKeys } from "@/services/common.querykeys";
@@ -718,8 +720,11 @@ const listQuery = useQuery(() =>
const alertFoldersList = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "alerts"), { enabled: !!orgId.value }),
);
const { locked: syntheticsLocked } = useLicenseLock("synthetics");
const syntheticFoldersList = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, "synthetics"), {
enabled: !!orgId.value && !syntheticsLocked.value,
}),
);
const downtimeFoldersList = useQuery(() =>
Object.assign(foldersQuery(orgId.value, "downtimes"), { enabled: !!orgId.value }),
@@ -731,9 +736,10 @@ const truncated = computed(() => listQuery.data.value?.truncated === true);
const loading = listQuery.isPending;
const fetching = listQuery.isFetching;
const lastUpdatedAt = listQuery.dataUpdatedAt;
const forbidden = computed(() => {
// A license denial carries its missing keys, so the table shows the license copy instead of the RBAC one.
const forbidden = computed<ForbiddenState>(() => {
const e: any = listQuery.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// A failed load must not read as "no downtimes yet" with a Create button.
@@ -1232,7 +1238,7 @@ const refreshAll = async () => {
void getFoldersListByType(store, "downtimes");
void listQuery.refetch();
void alertFoldersList.refetch();
void syntheticFoldersList.refetch();
if (!syntheticsLocked.value) void syntheticFoldersList.refetch();
};
useShortcuts([
@@ -103,6 +103,19 @@ describe("DowntimeResourcePicker", () => {
expect(keys[keys.length - 1]).toBe("k8s-pod");
});
it("asks the correlation analytics nothing while correlation is license-locked", async () => {
vi.mocked(serviceStreams.getSemanticGroups).mockResolvedValue({
data: [group("aws-ecs-cluster"), group("service"), group("k8s-pod")],
} as any);
store.state.zoConfig.features = { correlation: "locked" };
try {
await mountPicker();
expect(serviceStreams.getDimensionAnalytics).not.toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it("keeps Load resources for the full list with streams", async () => {
vi.mocked(serviceStreams.getSemanticGroups).mockResolvedValue({
data: [group("service"), group("host")],
@@ -125,6 +125,7 @@ import { computed, ref, watch } from "vue";
import { useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped } from "@/types/i18n";
import { useOrgId } from "@/composables/query";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { queryClient } from "@/composables/query/queryClient";
import { dimensionAnalyticsQuery, semanticGroupsQuery } from "@/services/service_streams.queries";
import { downtimeResourcesQuery, downtimeValuesQuery } from "@/services/downtimes.queries";
@@ -164,6 +165,7 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
const { toast } = useToast();
const orgId = useOrgId();
const { locked: correlationLocked } = useLicenseLock("correlation");
const semanticGroups = useQuery(() =>
Object.assign(semanticGroupsQuery(orgId.value), { enabled: !!orgId.value }),
@@ -178,7 +180,8 @@ const hasHost = computed(() => dimensionOptions.value.some((o) => o.value === DE
// The registry's ranking is only needed when the org has no `host` group.
const analytics = useQuery(() =>
Object.assign(dimensionAnalyticsQuery(orgId.value), {
enabled: !!orgId.value && !!semanticGroups.data.value && !hasHost.value,
enabled:
!!orgId.value && !!semanticGroups.data.value && !hasHost.value && !correlationLocked.value,
}),
);
@@ -8,12 +8,14 @@ import { useOForm } from "@/lib/forms/Form/useOForm";
import { ALL_FOLDERS, defaultDowntimeValues } from "@/utils/downtimes/downtimeForm";
import store from "@/test/unit/helpers/store";
import common from "@/services/common";
import { queryClient } from "@/composables/query/queryClient";
import DowntimeTargetCard from "./DowntimeTargetCard.vue";
const toast = vi.fn();
vi.mock("@/lib/feedback/Toast/useToast", () => ({ useToast: () => ({ toast }) }));
vi.mock("@/composables/downtimes/useDowntimeItems", () => ({
vi.mock("@/composables/downtimes/useDowntimeItems", async (importOriginal) => ({
...(await importOriginal<object>()),
useDowntimeItems: () => ({
items: ref([
{ id: "a1", name: "payments-db-replica-lag", folderId: "payments", tags: [] },
@@ -125,4 +127,31 @@ describe("DowntimeTargetCard", () => {
});
wrapper.unmount();
});
it("shows the lock and reads no synthetics folders while synthetics is license-locked", async () => {
queryClient.clear();
vi.mocked(common.list_Folders).mockClear();
store.state.zoConfig.features = { synthetics: "locked" };
try {
const { wrapper } = mountCard("synthetics", false);
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics-locked"]').exists()).toBe(true);
const picker = wrapper.find('[data-test="downtime-target-synthetics-folders"]');
expect(picker.find("[disabled]").exists()).toBe(true);
expect(wrapper.find('[data-test="downtime-target-synthetics-add-items"]').exists()).toBe(
false,
);
expect(common.list_Folders).not.toHaveBeenCalled();
wrapper.unmount();
} finally {
delete store.state.zoConfig.features;
}
});
it("shows no lock while the module's feature is enabled", async () => {
const { wrapper } = mountCard("synthetics", false);
await flushPromises();
expect(wrapper.find('[data-test="downtime-target-synthetics-locked"]').exists()).toBe(false);
wrapper.unmount();
});
});
@@ -26,6 +26,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="text-text-heading text-sm font-semibold">
{{ t(MODULE_LABEL_KEYS[module]) }}
</span>
<OTooltip v-if="locked" :content="lockedReason">
<span class="inline-flex" :data-test="`downtime-target-${module}-locked`">
<OIcon name="lock" size="xs" class="lock-chip shrink-0" />
</span>
</OTooltip>
<span
v-if="matchCount !== null"
class="text-text-secondary ms-auto text-xs"
@@ -43,7 +48,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: t('alerts.downtimes.form.alertFolders')
"
:options="folderOptions"
:disabled="noAccess"
:disabled="noAccess || locked"
:help-text="noAccess ? noAccessHint : undefined"
multiple
searchable
@@ -111,7 +116,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div
v-if="!noAccess && (!idsOpen || (module === 'synthetics' && !tagsOpen))"
v-if="!noAccess && !locked && (!idsOpen || (module === 'synthetics' && !tagsOpen))"
class="flex flex-wrap gap-1"
>
<OButton
@@ -213,7 +218,8 @@ import type { IncidentMode, TargetModule } from "@/services/downtimes";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
import type { SelectOption } from "@/lib/forms/Select/OSelect.types";
import { useToast } from "@/lib/feedback/Toast/useToast";
import { useDowntimeItems } from "@/composables/downtimes/useDowntimeItems";
import { useDowntimeItems, useTargetModuleLocks } from "@/composables/downtimes/useDowntimeItems";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import {
ALL_FOLDERS,
exclusiveAllFolders,
@@ -269,9 +275,15 @@ const tagsOpen = computed(() => !!values.value?.tags_open);
const idsOpen = computed(() => !!values.value?.ids_open);
const sloMode = computed(() => values.value?.slo_mode);
const moduleLocks = useTargetModuleLocks();
const locked = computed(() => moduleLocks[props.module].locked.value);
const lockedReason = computed(() => moduleLocks[props.module].lockedReason.value);
const folderType = computed(() => (props.module === "synthetics" ? "synthetics" : "alerts"));
const foldersList = useQuery(() =>
Object.assign(optionalFoldersQuery(orgId.value, folderType.value), { enabled: !!orgId.value }),
Object.assign(optionalFoldersQuery(orgId.value, folderType.value), {
enabled: !!orgId.value && !locked.value,
}),
);
const folders = computed(() => foldersList.data.value?.folders ?? []);
const noAccess = computed(() => !!foldersList.data.value?.forbidden);
@@ -20,6 +20,7 @@ const { push } = vi.hoisted(() => ({ push: vi.fn() }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push }) }));
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import RcaBlindSpots from "./RcaBlindSpots.vue";
import type { RcaBlindSpot } from "@/services/incidents";
import {
@@ -73,6 +74,26 @@ describe("RcaBlindSpots (03 VE8, VE34, VE37)", () => {
expect(push).toHaveBeenCalledWith({ name, query: { org_identifier: "default" } });
});
it.each([
["connect_toolset", "ai_toolsets"],
["open_pipeline", "pipelines"],
] as const)("%s: disabled with a lock while %s is license-locked", async (fix, feature) => {
store.state.zoConfig.features = { [feature]: "locked" };
try {
const wrapper = mount(RcaBlindSpots, {
props: { blindSpots: [rcaBlindSpot({ fix })], org: "default", live: false },
global: { plugins: [i18n, store] },
});
const button = wrapper.find('[data-test="rca-blind-spot-action"]');
expect(button.attributes("disabled")).toBeDefined();
expect(wrapper.find('[data-test="rca-blind-spot-action-lock"]').exists()).toBe(true);
await button.trigger("click");
expect(push).not.toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it.each(["resume_run", "none"] as const)(
"%s renders no button without canResume (VE37)",
(fix) => {
@@ -52,8 +52,29 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="text-text-heading text-sm font-semibold">{{ spot.title }}</div>
<div class="text-text-secondary text-compact [text-wrap:pretty]">{{ spot.why }}</div>
</div>
<OTooltip v-if="actions[spot.id]?.lockedReason" :content="actions[spot.id]!.lockedReason!">
<span class="inline-flex">
<OButton
data-test="rca-blind-spot-action"
:data-fix="spot.fix"
variant="outline"
size="sm"
disabled
>
<template #icon-left>
<OIcon
name="lock"
class="lock-chip"
size="sm"
data-test="rca-blind-spot-action-lock"
/>
</template>
{{ actions[spot.id]!.label }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="actions[spot.id]"
v-else-if="actions[spot.id]"
data-test="rca-blind-spot-action"
:data-fix="spot.fix"
variant="outline"
@@ -89,7 +110,9 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import type { RcaBlindSpot, RcaBlindSpotFix } from "@/services/incidents";
import { rcaBlindSpotRoute } from "@/utils/incidents/rcaEvidenceLinks";
import { rcaBlindSpotFeature, rcaBlindSpotRoute } from "@/utils/incidents/rcaEvidenceLinks";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
const props = withDefaults(
defineProps<{
@@ -114,6 +137,10 @@ defineEmits<{
const { t } = useI18nTyped();
const router = useRouter();
const headingId = useId();
const featureLocks = {
ai_toolsets: useLicenseLock("ai_toolsets"),
pipelines: useLicenseLock("pipelines"),
};
const actionLabel = (fix: RcaBlindSpotFix): { label: I18nText; icon: IconName } | null => {
switch (fix) {
@@ -132,11 +159,17 @@ const actionLabel = (fix: RcaBlindSpotFix): { label: I18nText; icon: IconName }
/** Blind spot id → its button, for the spots that have one. */
const actions = computed(() => {
const byId: Record<string, { label: I18nText; icon: IconName; route: RouteLocationRaw }> = {};
const byId: Record<
string,
{ label: I18nText; icon: IconName; route: RouteLocationRaw; lockedReason: I18nText | null }
> = {};
for (const spot of props.blindSpots) {
const route = rcaBlindSpotRoute(spot.fix, props.org);
const action = actionLabel(spot.fix);
if (route && action) byId[spot.id] = { ...action, route };
const feature = rcaBlindSpotFeature(spot.fix);
const lock = feature ? featureLocks[feature] : null;
const lockedReason = lock?.locked.value ? lock.lockedReason.value : null;
if (route && action) byId[spot.id] = { ...action, route, lockedReason };
}
return byId;
});
@@ -21,6 +21,7 @@ vi.mock("vue-router", () => ({ useRouter: () => ({ push }) }));
vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: toastMock }));
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import RcaEvidenceCard from "./RcaEvidenceCard.vue";
import type { RcaEvidenceEntry, RcaSummaryHypothesis } from "@/services/incidents";
import { rcaEvidenceLink } from "@/utils/incidents/rcaEvidenceLinks";
@@ -167,6 +168,45 @@ describe("RcaEvidenceCard (03 VE23–VE29)", () => {
expect(push).toHaveBeenCalledWith(rcaEvidenceLink(entry.link, "default")!.route);
});
it.each([
["logs", rcaEvidenceEntry()],
["metrics", rcaEvidenceEntry({ link: { signal: "metrics", streams: [], promql: "up" } })],
[
"traces",
rcaEvidenceEntry({ link: { signal: "traces", streams: ["default"], filter: "a=1" } }),
],
] as const)("%s locked: the open button is disabled with a lock", async (feature, entry) => {
store.state.zoConfig.features = { [feature]: "locked" };
try {
const wrapper = mount(RcaEvidenceCard, {
props: { entry, hypotheses: HYPOTHESES, org: "default" },
global: { plugins: [i18n, store] },
});
const open = byTest(wrapper, "rca-evidence-open");
expect(open.attributes("disabled")).toBeDefined();
expect(byTest(wrapper, "rca-evidence-open-lock").exists()).toBe(true);
await open.trigger("click");
expect(push).not.toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it("another signal locked: the open button stays live", async () => {
store.state.zoConfig.features = { traces: "locked" };
try {
const wrapper = mount(RcaEvidenceCard, {
props: { entry: rcaEvidenceEntry(), hypotheses: HYPOTHESES, org: "default" },
global: { plugins: [i18n, store] },
});
expect(byTest(wrapper, "rca-evidence-open-lock").exists()).toBe(false);
await byTest(wrapper, "rca-evidence-open").trigger("click");
expect(push).toHaveBeenCalled();
} finally {
delete store.state.zoConfig.features;
}
});
it.each([
["change", { signal: "change", streams: [] }],
["infra", { signal: "infra", streams: ["prod"] }],
@@ -92,8 +92,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ entry.result_summary }}
<span v-if="source" class="text-text-secondary">· {{ source }}</span>
</span>
<OTooltip v-if="link && linkLocked" :content="lockedReason(link.page)">
<span class="inline-flex">
<OButton data-test="rca-evidence-open" variant="ghost-primary" size="sm" disabled>
<template #icon-left>
<OIcon
name="lock"
class="text-icon-locked"
size="sm"
data-test="rca-evidence-open-lock"
/>
</template>
{{ openLabel }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="link"
v-else-if="link"
data-test="rca-evidence-open"
variant="ghost-primary"
size="sm"
@@ -134,6 +149,9 @@ import { rcaEvidenceLink, rcaEvidenceQuery } from "@/utils/incidents/rcaEvidence
import { rcaEvidenceKindIcon, rcaEvidenceTitle, rcaUtcRange } from "@/utils/incidents/rcaSummary";
import RcaInlineText from "./RcaInlineText.vue";
import RcaPill from "./RcaPill.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useLicenseLocks } from "@/composables/useFeatureLock";
import { streamTypeFeature } from "@/utils/featureGate";
const props = withDefaults(
defineProps<{
@@ -159,6 +177,9 @@ const router = useRouter();
const link = computed(() => rcaEvidenceLink(props.entry.link, props.org));
const query = computed(() => rcaEvidenceQuery(props.entry.link));
const { isLocked, lockedReason } = useLicenseLocks();
const linkLocked = computed(() => !!link.value && isLocked(streamTypeFeature(link.value.page)));
/** "Logs · checkout_api": the target and its streams. */
const title = computed<I18nText>(() => rcaEvidenceTitle(props.entry));
@@ -18,16 +18,23 @@ import { mount, type VueWrapper } from "@vue/test-utils";
import type * as VueRouter from "vue-router";
import type * as Vuex from "vuex";
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
const { pushMock, storeState } = vi.hoisted(() => ({
pushMock: vi.fn(),
storeState: {
selectedOrganization: { identifier: "acme" },
zoConfig: {} as { features?: Record<string, string> },
},
}));
vi.mock("vue-router", async (importOriginal) => ({
...(await importOriginal<typeof VueRouter>()),
useRouter: () => ({ push: pushMock }),
}));
vi.mock("vuex", async (importOriginal) => ({
...(await importOriginal<typeof Vuex>()),
useStore: () => ({ state: { selectedOrganization: { identifier: "acme" } } }),
useStore: () => ({ state: storeState }),
}));
import { createStore } from "vuex";
import i18n from "@/locales";
import RcaVerdictCard from "./RcaVerdictCard.vue";
import {
@@ -796,6 +803,28 @@ describe("RcaVerdictCard", () => {
expect(byTest(without, "rca-paused-connect-btn").exists()).toBe(false);
});
it("'Connect AI Toolset' is disabled with a lock while ai_toolsets is license-locked", async () => {
storeState.zoConfig.features = { ai_toolsets: "locked" };
try {
// useFeatureLock is loaded by the test setup before this file's vuex mock, so it injects a real store.
const wrapper = mount(RcaVerdictCard, {
props: {
runId: "run-2",
...PAUSED,
summary: rcaRunSummaryLive({ blind_spots: [rcaBlindSpot()] }),
},
global: { plugins: [i18n, createStore({ state: storeState })], stubs },
});
const button = byTest(wrapper, "rca-paused-connect-btn");
expect(button.attributes("disabled")).toBeDefined();
expect(byTest(wrapper, "rca-paused-connect-lock").exists()).toBe(true);
await button.trigger("click");
expect(pushMock).not.toHaveBeenCalled();
} finally {
delete storeState.zoConfig.features;
}
});
it("no paused banner while running", () => {
const wrapper = mountCard({ ...PAUSED, waitingInput: false });
expect(byTest(wrapper, "rca-paused-banner").exists()).toBe(false);
@@ -248,8 +248,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
{{ t("alerts.incidents.rcaSteer.paused.tellAgent") }}
</OButton>
<OTooltip v-if="connectToolsetRoute && toolsetsLocked" :content="toolsetsLockedReason">
<span class="inline-flex">
<OButton data-test="rca-paused-connect-btn" variant="outline" size="sm" disabled>
<template #icon-left>
<OIcon
name="lock"
class="lock-chip"
size="sm"
data-test="rca-paused-connect-lock"
/>
</template>
{{ t("alerts.incidents.rcaSummary.fix.connectToolset") }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="connectToolsetRoute"
v-else-if="connectToolsetRoute"
data-test="rca-paused-connect-btn"
variant="outline"
size="sm"
@@ -444,6 +459,7 @@ import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import type {
RcaBand,
@@ -463,6 +479,7 @@ import {
} from "@/utils/incidents/rcaSummary";
import { formatTimestampInTimezone } from "@/utils/date";
import { rcaBlindSpotRoute } from "@/utils/incidents/rcaEvidenceLinks";
import { useLicenseLock } from "@/composables/useFeatureLock";
import type { RcaCardState } from "@/utils/incidents/rcaThread";
import type { RcaUnblockedLine } from "@/utils/incidents/rcaSteering";
import type { RcaRefusal } from "@/composables/useIncidentRca";
@@ -571,8 +588,11 @@ const connectToolsetRoute = computed(() =>
? rcaBlindSpotRoute("connect_toolset", props.org)
: null,
);
const { locked: toolsetsLocked, lockedReason: toolsetsLockedReason } =
useLicenseLock("ai_toolsets");
const openConnectToolset = () => {
if (connectToolsetRoute.value) void router.push(connectToolsetRoute.value);
if (connectToolsetRoute.value && !toolsetsLocked.value)
void router.push(connectToolsetRoute.value);
};
const unblockedText = computed<I18nText | null>(() => {
@@ -0,0 +1,82 @@
// 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 { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import i18n from "@/locales";
import { raw, type TranslateFn } from "@/types/i18n";
import { coverageFor } from "./fixtures";
import CoverageServiceDrawer from "./CoverageServiceDrawer.vue";
const t = ((key: string) => raw(key)) as unknown as TranslateFn;
const stubs = {
ODrawer: { template: "<div><slot name='header' /><slot /></div>" },
CoverageMetricChart: true,
CoverageSignalDetail: true,
};
const original = { ...store.state.zoConfig };
const mountDrawer = () => {
const row = coverageFor("demo", t)[0];
return mount(CoverageServiceDrawer, {
props: {
row,
serviceStates: {},
scenario: "demo",
ledger: [],
expanded: null,
anchor: "",
fix: null,
message: "",
},
global: { plugins: [i18n, store, router], stubs },
});
};
afterEach(() => {
store.commit("setConfig", original);
vi.restoreAllMocks();
});
describe("CoverageServiceDrawer — traces links", () => {
it("opens Traces and the catalog while traces is enabled", async () => {
const push = vi.spyOn(router, "push").mockResolvedValue(undefined);
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-test="autoobserve-coverage-drawer-traces"]').trigger("click");
await wrapper.find('[data-test="autoobserve-coverage-drawer-catalog"]').trigger("click");
expect(push).toHaveBeenCalledTimes(2);
expect(push).toHaveBeenLastCalledWith(
expect.objectContaining({
name: "traces",
query: expect.objectContaining({ tab: "services-catalog" }),
}),
);
});
it("disables both links while traces is locked", async () => {
store.commit("setConfig", { ...original, features: { traces: "locked" } });
const push = vi.spyOn(router, "push").mockResolvedValue(undefined);
const wrapper = mountDrawer();
await flushPromises();
for (const id of ["traces", "catalog"]) {
const btn = wrapper.find(`[data-test="autoobserve-coverage-drawer-${id}"]`);
expect(btn.attributes("disabled")).toBeDefined();
await btn.trigger("click");
}
expect(push).not.toHaveBeenCalled();
});
});
@@ -39,24 +39,38 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<span class="text-text-secondary block truncate text-xs">{{ subTitle }}</span>
</div>
<OButton
variant="outline"
size="sm-action"
icon-left="timeline"
:aria-label="t('autoobserve.coverage.openTraces')"
data-test="autoobserve-coverage-drawer-traces"
@click="openTraces()"
><span class="max-md:sr-only">{{ t("autoobserve.coverage.openTraces") }}</span></OButton
>
<OButton
variant="outline"
size="sm-action"
icon-left="hub"
:aria-label="t('autoobserve.coverage.openCatalog')"
data-test="autoobserve-coverage-drawer-catalog"
@click="openTraces('services-catalog')"
><span class="max-md:sr-only">{{ t("autoobserve.coverage.openCatalog") }}</span></OButton
>
<OTooltip :content="tracesLock.lockedReason.value" :disabled="!tracesLock.locked.value">
<span class="inline-flex">
<OButton
variant="outline"
size="sm-action"
:icon-left="tracesLock.locked.value ? 'lock' : 'timeline'"
:disabled="tracesLock.locked.value"
:aria-label="t('autoobserve.coverage.openTraces')"
data-test="autoobserve-coverage-drawer-traces"
@click="openTraces()"
><span class="max-md:sr-only">{{
t("autoobserve.coverage.openTraces")
}}</span></OButton
>
</span>
</OTooltip>
<OTooltip :content="tracesLock.lockedReason.value" :disabled="!tracesLock.locked.value">
<span class="inline-flex">
<OButton
variant="outline"
size="sm-action"
:icon-left="tracesLock.locked.value ? 'lock' : 'hub'"
:disabled="tracesLock.locked.value"
:aria-label="t('autoobserve.coverage.openCatalog')"
data-test="autoobserve-coverage-drawer-catalog"
@click="openTraces('services-catalog')"
><span class="max-md:sr-only">{{
t("autoobserve.coverage.openCatalog")
}}</span></OButton
>
</span>
</OTooltip>
</div>
</template>
@@ -243,6 +257,8 @@ import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useLicenseLock } from "@/composables/useFeatureLock";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
@@ -289,6 +305,7 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
const router = useRouter();
const tracesLock = useLicenseLock("traces");
const store = useStore();
const HEX_POINTS = "18.23,14.75 10,19.5 1.77,14.75 1.77,5.25 10,0.5 18.23,5.25";
@@ -0,0 +1,77 @@
<!-- 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/>.
-->
<!-- Not `inert`: Chromium stops wheel-scrolling inert scroll containers, so a locked page could not be scrolled. -->
<!-- pointerdown is left alone: Reka closes header and sidebar popovers on an outside pointerdown. -->
<template>
<div
class="h-full"
:class="locked ? 'feature-locked-content' : ''"
data-test="feature-lock-boundary"
:data-locked-feature="feature ?? undefined"
@click.capture="block"
@dblclick.capture="block"
@submit.capture="block"
@dragstart.capture="block"
@dragover.capture="block"
@drop.capture="block"
@paste.capture="block"
@keydown.capture="onKeydown"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { toRef } from "vue";
import { LOCK_EXEMPT_ATTR, provideFeatureLock } from "@/composables/useFeatureLock";
const props = defineProps<{ feature: string | null }>();
const SCROLL_KEYS = new Set([
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"PageUp",
"PageDown",
"Home",
"End",
" ",
]);
const CONTROL =
'a[href], button, input, select, textarea, [contenteditable="true"], [role="button"], [role="tab"], [role="checkbox"], [role="switch"], [role="radio"], [role="combobox"], [role="menuitem"], [role="option"], [role="slider"], [role="separator"]';
const { locked } = provideFeatureLock(toRef(props, "feature"));
const block = (event: Event) => {
if (!locked.value || (event.target as Element | null)?.closest?.(`[${LOCK_EXEMPT_ATTR}]`)) return;
event.preventDefault();
event.stopPropagation();
};
// Only read-only shortcuts: a page's own Cmd/Ctrl+S or Cmd/Ctrl+Enter would still act.
const READ_SHORTCUTS = new Set(["c", "a", "f", "g"]);
// Tab, copy/select-all/find and scroll keys off a control pass; anything else aimed at the page is swallowed.
const onKeydown = (event: KeyboardEvent) => {
if (!locked.value || event.key === "Tab") return;
if ((event.metaKey || event.ctrlKey) && READ_SHORTCUTS.has(event.key.toLowerCase())) return;
const onControl = (event.target as Element | null)?.closest?.(CONTROL);
if (!onControl && SCROLL_KEYS.has(event.key)) return;
block(event);
};
</script>
@@ -0,0 +1,30 @@
<!-- 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/>.
-->
<!-- Only for controls that navigate: anything inside stays usable on a license-locked page. -->
<template>
<component :is="as" v-bind="{ [LOCK_EXEMPT_ATTR]: '' }">
<slot />
</component>
</template>
<script setup lang="ts">
import { LOCK_EXEMPT_ATTR, provideFeatureLockExempt } from "@/composables/useFeatureLock";
withDefaults(defineProps<{ as?: string }>(), { as: "div" });
provideFeatureLockExempt();
</script>
@@ -0,0 +1,48 @@
// 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 { describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import FeatureLocked from "@/components/common/FeatureLocked.vue";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
vi.mock("vue-router", () => ({ useRoute: () => ({ params: { feature: "ai_assistant" } }) }));
vi.mock("vuex", () => ({
useStore: () => ({ state: { zoConfig: { feature_names: { ai_assistant: "AI Assistant" } } } }),
}));
describe("FeatureLocked", () => {
it("renders the shared license-denied state for the routed feature", () => {
const w = mount(FeatureLocked, {
global: {
plugins: [i18n],
stubs: { OPageLayout: { template: "<div><slot /></div>" } },
},
});
expect(w.findComponent(LicenseDeniedState).props("features")).toEqual(["ai_assistant"]);
});
it("tells the user to ask an administrator and offers no contact-sales action", () => {
const w = mount(FeatureLocked, {
global: {
plugins: [i18n],
stubs: { OPageLayout: { template: "<div><slot /></div>" } },
},
});
expect(w.text()).toContain("Ask your administrator to update the license");
expect(w.find('[data-test="feature-locked-contact-sales"]').exists()).toBe(false);
});
});
@@ -0,0 +1,42 @@
<!-- 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/>.
-->
<template>
<FeatureLockExempt class="h-full">
<OPageLayout :title="label" :icon="meta?.icon ?? 'lock'" data-test="feature-locked-page">
<LicenseDeniedState :features="[key]" />
</OPageLayout>
</FeatureLockExempt>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
import { raw, useI18nTyped } from "@/types/i18n";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
import { useFeatureNames } from "@/composables/useFeatureLock";
import { LOCKED_FEATURES } from "@/utils/featureGate";
const { t } = useI18nTyped();
const route = useRoute();
const { featureName } = useFeatureNames();
const key = computed(() => String(route.params.feature ?? ""));
const meta = computed(() => LOCKED_FEATURES[key.value]);
const label = computed(() => raw(featureName(key.value)) || t("featureLock.thisFeature"));
</script>
@@ -0,0 +1,42 @@
<!-- 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/>.
-->
<template>
<OEmptyState
size="hero"
preset="no-access"
:title="t('featureLock.toastTitle')"
:description="
features.length
? t('featureLock.toastMessage', { features: raw(featureNames(features)) })
: t('featureLock.toastMessageGeneric')
"
>
<template v-if="$slots.actions" #actions><slot name="actions" /></template>
</OEmptyState>
</template>
<script setup lang="ts">
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { useFeatureNames } from "@/composables/useFeatureLock";
/** The missing feature keys of a license denial, from `licenseDeniedFeatures()`. */
defineProps<{ features: readonly string[] }>();
const { t } = useI18nTyped();
const { featureNames } = useFeatureNames();
</script>
@@ -0,0 +1,58 @@
// 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 { describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import SectionHub from "./SectionHub.vue";
const push = vi.fn();
vi.mock("vue-router", () => ({ useRouter: () => ({ push }) }));
const openUpgradeDialog = vi.fn();
vi.mock("@/composables/useEnterpriseUpgradeDialog", () => ({
useEnterpriseUpgradeDialog: () => ({ open: openUpgradeDialog }),
}));
describe("SectionHub", () => {
it("draws the lock on a license-locked card and still opens its page", async () => {
const wrapper = mount(SectionHub, {
props: {
groups: [
{
label: "Access",
items: [
{ key: "roles", label: "Roles", to: { name: "roles" }, licenseLocked: true },
{ key: "users", label: "Users", to: { name: "users" } },
],
},
] as any,
},
global: {
stubs: {
ConstrainedPage: { template: "<div><slot /></div>" },
OIcon: { props: ["name"], template: "<i :data-icon='name' />" },
},
},
});
const card = wrapper.get('[data-test="section-hub-card-roles"]');
expect(card.find('[data-icon="lock"]').exists()).toBe(true);
expect(
wrapper.get('[data-test="section-hub-card-users"]').find('[data-icon="lock"]').exists(),
).toBe(false);
await card.trigger("click");
expect(push).toHaveBeenCalledWith({ name: "roles" });
expect(openUpgradeDialog).not.toHaveBeenCalled();
});
});
+10 -1
View File
@@ -73,7 +73,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span
class="text-text-heading group-hover:text-accent flex items-center gap-1.5 text-sm font-semibold transition-colors"
>{{ item.label }}
<OIcon v-if="item.locked" name="lock" size="xs" class="text-warning-600" />
<OIcon
v-if="item.locked || item.licenseLocked"
name="lock"
size="xs"
class="lock-chip"
/>
</span>
<span
v-if="item.description"
@@ -117,6 +122,10 @@ export interface SectionHubItem {
locked?: boolean;
lockedMessage?: I18nText;
lockedShowUpgradeCta?: boolean;
/** The license lacks this feature: shows the lock but still opens `to`, whose page shows the license banner. */
licenseLocked?: boolean;
/** Screen-reader text for the lock, e.g. "Locked: needs X". */
lockedLabel?: I18nText;
dataTest?: string;
}
+56 -2
View File
@@ -3,7 +3,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { h } from "vue";
import SectionRail from "./SectionRail.vue";
import FeatureLockBoundary from "./FeatureLockBoundary.vue";
const push = vi.fn();
vi.mock("vue-router", () => ({
@@ -30,8 +32,8 @@ const stubs = {
OTabs: { template: "<div><slot /></div>" },
OTab: {
name: "OTab",
props: ["name", "label", "icon", "tooltip"],
template: "<button :data-name='name'>{{ label }}</button>",
props: ["name", "label", "icon", "tooltip", "suffixIcon"],
template: "<button :data-name='name'>{{ label }}<slot name='icon' /></button>",
},
OButton: {
emits: ["click"],
@@ -141,6 +143,58 @@ describe("SectionRail", () => {
expect(push).toHaveBeenCalledWith({ name: "a" });
});
const lockedGroups = (flags: Record<string, boolean>) => [
{
label: "Permissions",
items: [{ key: "roles", label: "Roles", to: { name: "roles" }, ...flags }],
},
];
it("keeps its links enabled inside a license-locked page", async () => {
const wrapper = mount(FeatureLockBoundary, {
props: { feature: "rbac" },
slots: { default: () => h(SectionRail, { groups }) },
global: { stubs: { OTooltip: stubs.OTooltip } },
});
const tab = wrapper.get('[role="tab"]');
expect(tab.attributes("disabled")).toBeUndefined();
expect(tab.attributes("aria-disabled")).toBeUndefined();
await tab.trigger("click");
expect(push).toHaveBeenCalledWith({ name: "a" });
wrapper.unmount();
});
it("routes an edition-locked item to the upgrade page", async () => {
const wrapper = mountRail({ groups: lockedGroups({ locked: true }) });
expect(wrapper.findComponent({ name: "OTab" }).props("suffixIcon")).toBe("lock");
await wrapper.get('[data-name="roles"]').trigger("click");
expect(push).toHaveBeenCalledWith({ name: "enterpriseFeatures" });
});
// The licensed page shows its own banner, so the upgrade page would only hide it.
it("shows the lock on a license-locked item but still opens its page", async () => {
const wrapper = mountRail({ groups: lockedGroups({ licenseLocked: true }) });
expect(wrapper.findComponent({ name: "OTab" }).props("suffixIcon")).toBe("lock");
await wrapper.get('[data-name="roles"]').trigger("click");
expect(push).toHaveBeenCalledWith({ name: "roles" });
});
it("swaps the lock suffix for a corner badge once collapsed", async () => {
const wrapper = mountRail({
groups: lockedGroups({
licenseLocked: true,
lockedLabel: "Locked: needs RBAC",
icon: "lock",
}),
collapsible: true,
icon: "auto-awesome",
});
await wrapper.get('[data-test="section-rail-toggle"]').trigger("click");
expect(wrapper.findComponent({ name: "OTab" }).props("suffixIcon")).toBeUndefined();
expect(wrapper.get(".sr-only").text()).toBe("Locked: needs RBAC");
expect(wrapper.find(".text-icon-locked").exists()).toBe(true);
});
const headings = (wrapper: ReturnType<typeof mountRail>) =>
wrapper.findAll("div.text-text-secondary.font-semibold");
+18 -4
View File
@@ -22,7 +22,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
tinted-active treatment. Sentence-case group headings, soft single-line items.
-->
<template>
<nav class="bg-surface-panel flex h-full min-h-0 flex-col" data-test="section-rail">
<FeatureLockExempt
as="nav"
class="bg-surface-panel flex h-full min-h-0 flex-col"
data-test="section-rail"
>
<!-- Collapsed there is room for one mark, so the module icon doubles as the
expand control rather than sitting inert beside a second button. It is
tinted like MenuLink's active module for the same reason the primary nav
@@ -104,17 +108,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:label="collapsed ? undefined : item.label"
:tooltip="collapsed ? item.label : undefined"
:icon="item.icon"
:suffix-icon="item.locked && !collapsed ? 'lock' : undefined"
:suffix-icon="(item.locked || item.licenseLocked) && !collapsed ? 'lock' : undefined"
:suffix-label="item.lockedLabel"
:data-test="item.locked ? `${item.dataTest}-locked` : item.dataTest"
class="w-full"
:class="collapsed ? 'justify-center!' : undefined"
@click="navigate(item.locked ? { name: 'enterpriseFeatures' } : item.to)"
/>
>
<template v-if="collapsed && (item.locked || item.licenseLocked)" #icon>
<span class="relative inline-flex shrink-0">
<OIcon :name="item.icon as IconName" size="sm" class="o-tab__icon" />
<OLockBadge :label="item.lockedLabel" />
</span>
</template>
</OTab>
</template>
</template>
</OTabs>
</div>
</nav>
</FeatureLockExempt>
</template>
<script setup lang="ts">
@@ -123,12 +135,14 @@ import { computed } from "vue";
import { useRouter, type RouteLocationRaw } from "vue-router";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OLockBadge from "@/lib/core/Badge/OLockBadge.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import type { SectionHubGroup } from "@/components/common/SectionHub.vue";
import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
const router = useRouter();
const { t } = useI18nTyped();
@@ -117,6 +117,7 @@ import { useStore } from "vuex";
import OButton from "@/lib/core/Button/OButton.vue";
import AddFolder from "./AddFolder.vue";
import { getFoldersListByType } from "@/utils/commons";
import { licenseDeniedFeatures } from "@/utils/featureGate";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import { useFolderIcons } from "@/composables/useFolderIcons";
@@ -208,7 +209,10 @@ export default defineComponent({
};
onMounted(async () => {
await getFoldersListByType(store, props.type);
// The top bar already explains a license denial; anything else still surfaces.
await getFoldersListByType(store, props.type).catch((error) => {
if (!licenseDeniedFeatures(error)) throw error;
});
});
return {
@@ -33,6 +33,31 @@ describe("AlertContextMenu Component", () => {
vi.clearAllMocks();
});
describe("locked", () => {
it("shows disabled, focusable items with a gold lock and the reason, and emits nothing", async () => {
wrapper = createWrapper({ locked: true, lockedReason: "Locked: needs Alerts" });
for (const id of ["above", "below"]) {
const item = wrapper.find(`[data-test="alert-context-menu-${id}"]`);
expect(item.attributes("aria-disabled")).toBe("true");
expect(item.attributes("tabindex")).toBe("0");
expect(wrapper.find(`[data-test="alert-context-menu-${id}-lock"]`).classes()).toContain(
"lock-chip",
);
expect(wrapper.find(`[data-test="alert-context-menu-${id}-reason"]`).text()).toBe(
"Locked: needs Alerts",
);
await item.trigger("click");
}
expect(wrapper.emitted("select")).toBeUndefined();
});
it("locks the single forecast item", () => {
wrapper = createWrapper({ locked: true, lockedReason: "x", seriesRole: "forecast" });
expect(wrapper.find('[data-test="alert-context-menu-forecast-lock"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-context-menu-above"]').exists()).toBe(false);
});
});
describe("Component Initialization", () => {
it("should mount successfully", () => {
wrapper = createWrapper();
@@ -24,39 +24,69 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click.stop
data-test="alert-context-menu"
>
<div
v-if="seriesRole === 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('forecast')"
data-test="alert-context-menu-forecast"
>
<OIcon name="trending-up" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.forecastReaches", { value: forecastText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('above')"
data-test="alert-context-menu-above"
>
<OIcon name="arrow-upward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdAbove", { value: valueText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('below')"
data-test="alert-context-menu-below"
>
<OIcon name="arrow-downward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdBelow", { value: valueText })
}}</span>
</div>
<template v-if="locked">
<div
v-for="item in lockedItems"
:key="item.condition"
class="text-dropdown-item-disabled focus-visible:ring-focus-ring-accent flex cursor-not-allowed items-center px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-inset"
role="menuitem"
aria-disabled="true"
tabindex="0"
:data-test="`alert-context-menu-${item.condition}`"
>
<OIcon
name="lock"
size="sm"
class="lock-chip me-2"
:data-test="`alert-context-menu-${item.condition}-lock`"
/>
<span class="flex min-w-0 flex-col">
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
item.label
}}</span>
<span
class="text-text-secondary text-xs select-none"
:data-test="`alert-context-menu-${item.condition}-reason`"
>{{ lockedReason }}</span
>
</span>
</div>
</template>
<template v-else>
<div
v-if="seriesRole === 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('forecast')"
data-test="alert-context-menu-forecast"
>
<OIcon name="trending-up" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.forecastReaches", { value: forecastText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('above')"
data-test="alert-context-menu-above"
>
<OIcon name="arrow-upward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdAbove", { value: valueText })
}}</span>
</div>
<div
v-if="seriesRole !== 'forecast'"
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm [transition:background-color_0.2s]"
@click="handleMenuItemClick('below')"
data-test="alert-context-menu-below"
>
<OIcon name="arrow-downward" size="sm" class="me-2" />
<span class="whitespace-nowrap select-none max-md:whitespace-normal">{{
t("dashboard.alertContextMenu.thresholdBelow", { value: valueText })
}}</span>
</div>
</template>
</div>
</teleport>
</template>
@@ -101,6 +131,8 @@ export default defineComponent({
},
unit: { type: String, default: null },
unitCustom: { type: String, default: null },
locked: { type: Boolean, default: false },
lockedReason: { type: String, default: "" },
},
emits: ["select", "close"],
setup(props, { emit }) {
@@ -153,6 +185,26 @@ export default defineComponent({
);
});
const lockedItems = computed(() =>
props.seriesRole === "forecast"
? [
{
condition: "forecast",
label: t("dashboard.alertContextMenu.forecastReaches", { value: forecastText.value }),
},
]
: [
{
condition: "above",
label: t("dashboard.alertContextMenu.thresholdAbove", { value: valueText.value }),
},
{
condition: "below",
label: t("dashboard.alertContextMenu.thresholdBelow", { value: valueText.value }),
},
],
);
const handleMenuItemClick = (condition: "above" | "below" | "forecast") => {
emit("select", {
condition,
@@ -216,6 +268,7 @@ export default defineComponent({
valueText,
forecastText,
menuStyle,
lockedItems,
handleMenuItemClick,
};
},
@@ -0,0 +1,92 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { flushPromises, shallowMount } from "@vue/test-utils";
import { ref } from "vue";
import { createStore } from "vuex";
import i18n from "@/locales";
const findRelatedTelemetry = vi.fn().mockResolvedValue(null);
vi.mock("@/composables/useServiceCorrelation", () => ({
useServiceCorrelation: () => ({
findRelatedTelemetry,
semanticGroups: ref([]),
noMatch: ref(false),
error: ref(null),
}),
}));
vi.mock("@/services/search", () => ({
default: { search: vi.fn().mockResolvedValue({ data: { hits: [], total: 0 } }) },
}));
vi.mock("@/services/patterns", () => ({ default: {} }));
vi.mock("vue-router", () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
}));
import DashboardLogDrawer from "@/components/dashboards/DashboardLogDrawer.vue";
const mountDrawer = (features: Record<string, string>, streamType?: string) =>
shallowMount(DashboardLogDrawer, {
props: { stream: "app", field: "level", value: "error", startTime: 1, endTime: 2, streamType },
global: {
renderStubDefaultSlot: true,
stubs: { OButton: false },
plugins: [
i18n,
createStore({
state: { selectedOrganization: { identifier: "org" }, zoConfig: { features } },
}),
],
},
});
const openInLogsDisabled = (wrapper: ReturnType<typeof mountDrawer>) =>
wrapper.find('[data-test="log-explorer-open-in-logs"]').attributes("aria-disabled") === "true";
describe("DashboardLogDrawer related telemetry", () => {
beforeEach(() => findRelatedTelemetry.mockClear());
it("makes no correlation call while correlation is license-locked", async () => {
const wrapper = mountDrawer({ correlation: "locked" });
await flushPromises();
await (wrapper.vm as any).$.setupState.openCorrelationFromLog({ _timestamp: 1 });
expect(findRelatedTelemetry).not.toHaveBeenCalled();
});
it.each([
[undefined, "logs"],
["metrics", "metrics"],
["traces", "traces"],
])("stream type %s with %s locked: Open in Logs is disabled with a lock", (streamType, key) => {
const wrapper = mountDrawer({ [key]: "locked" }, streamType);
expect(openInLogsDisabled(wrapper)).toBe(true);
expect(wrapper.find('[data-test="log-explorer-open-in-logs-lock"]').exists()).toBe(true);
});
it("Open in Logs stays live while another signal is locked", () => {
const wrapper = mountDrawer({ traces: "locked" });
expect(openInLogsDisabled(wrapper)).toBe(false);
expect(wrapper.find('[data-test="log-explorer-open-in-logs-lock"]').exists()).toBe(false);
});
it("looks up related telemetry while correlation is enabled", async () => {
const wrapper = mountDrawer({ correlation: "enabled" });
await flushPromises();
await (wrapper.vm as any).$.setupState.openCorrelationFromLog({ _timestamp: 1 });
expect(findRelatedTelemetry).toHaveBeenCalledTimes(1);
});
});
@@ -29,6 +29,8 @@ import { b64EncodeUnicode } from "@/utils/formatters";
import searchService from "@/services/search";
import patternsService from "@/services/patterns";
import { useServiceCorrelation } from "@/composables/useServiceCorrelation";
import { useLicenseLock, useLicenseLocks } from "@/composables/useFeatureLock";
import { streamTypeFeature } from "@/utils/featureGate";
import { buildChipDimensionsFromFilters } from "@/services/service_streams";
import { buildWorkloadChipDimensions } from "@/composables/useMetricSubjectButtons";
import { extractSeverity } from "@/utils/sourceEventSeverity";
@@ -474,6 +476,13 @@ const {
noMatch: correlationNoMatch,
error: serviceCorrelationError,
} = useServiceCorrelation();
const { locked: correlationLocked } = useLicenseLock("correlation");
const licenseLocks = useLicenseLocks();
const logsFeature = computed(() => streamTypeFeature(props.streamType));
const openInLogsLocked = computed(() => licenseLocks.isLocked(logsFeature.value));
const openInLogsLockedReason = computed(() =>
logsFeature.value ? licenseLocks.lockedReason(logsFeature.value) : raw(""),
);
// Fresh correlation per event (mirrors the Logs page reset on row change).
watch(selectedEvent, () => {
@@ -485,7 +494,7 @@ watch(selectedEvent, () => {
// Lazy correlation fetch — replicated from SearchResult.vue's openCorrelationFromLog.
async function openCorrelationFromLog(logData: any) {
if (correlationFetchInProgress.value) return;
if (correlationFetchInProgress.value || correlationLocked.value) return;
try {
correlationFetchInProgress.value = true;
correlationLoading.value = true;
@@ -793,7 +802,23 @@ function openInLogs() {
>
{{ t("panel.logExplorer.queryMode") }}
</OButton>
<OTooltip v-if="openInLogsLocked" :content="openInLogsLockedReason">
<span class="inline-flex">
<OButton size="sm" variant="outline" disabled data-test="log-explorer-open-in-logs">
<template #icon-left>
<OIcon
name="lock"
class="text-icon-locked"
size="sm"
data-test="log-explorer-open-in-logs-lock"
/>
</template>
{{ t("panel.logExplorer.openInLogs") }}
</OButton>
</span>
</OTooltip>
<OButton
v-else
size="sm"
variant="outline"
icon-left="open-in-new"
@@ -1141,7 +1166,28 @@ function openInLogs() {
>{{ p.template }}</OTruncatedText
>
<!-- Open in Logs -->
<OTooltip v-if="openInLogsLocked" :content="openInLogsLockedReason">
<span class="inline-flex shrink-0">
<OButton
:aria-label="t('panel.logExplorer.openInLogs')"
size="sm"
variant="ghost"
disabled
data-test="log-explorer-pattern-open"
>
<template #icon-left>
<OIcon
name="lock"
class="text-icon-locked"
size="sm"
data-test="log-explorer-pattern-open-lock"
/>
</template>
</OButton>
</span>
</OTooltip>
<OButton
v-else
:aria-label="t('panel.logExplorer.openInLogs')"
size="sm"
variant="ghost"
@@ -1299,6 +1299,70 @@ describe("PanelContainer", () => {
consoleErrorSpy.mockRestore();
});
it.each([
["logs", "logs"],
["metrics", "metrics"],
["traces", "traces"],
])("go to logs locks while the panel's %s stream type is license-locked", (streamType, key) => {
(store.state as any).zoConfig.features = { [key]: "locked" };
const panel = {
...mockPanelData,
queries: [
{ ...mockPanelData.queries[0], fields: { stream: "s", stream_type: streamType } },
],
};
wrapper = createWrapper({ data: panel });
expect(wrapper.vm.goToLogsLocked).toBe(true);
expect(wrapper.vm.goToLogsLockedReason).toBeTruthy();
});
it("go to logs shows as a disabled, focusable menu item with the reason as description", async () => {
(store.state as any).zoConfig.features = { logs: "locked" };
wrapper = createWrapper(
{ data: mockPanelData },
{
ODropdown: {
name: "ODropdown",
template: '<div><slot name="trigger" /><slot /></div>',
},
ODropdownItem: {
name: "ODropdownItem",
props: {
disabled: Boolean,
focusableUnavailable: Boolean,
iconLeft: String,
description: String,
},
template: "<div><slot /></div>",
},
},
);
wrapper.vm.metaData = { queries: [{}] };
await flushPromises();
const item = wrapper
.findAllComponents({ name: "ODropdownItem" })
.find((c: any) => c.attributes("data-test") === "dashboard-move-to-logs-module");
expect(item?.props("disabled")).toBe(true);
expect(item?.props("focusableUnavailable")).toBe(true);
expect(item?.props("iconLeft")).toBe("lock");
expect(item?.props("description")).toBe(wrapper.vm.goToLogsLockedReason);
});
it("go to logs stays live while another signal is locked or the stream type is free", () => {
(store.state as any).zoConfig.features = { traces: "locked" };
wrapper = createWrapper({ data: mockPanelData });
expect(wrapper.vm.goToLogsLocked).toBe(false);
wrapper.unmount();
(store.state as any).zoConfig.features = { logs: "locked" };
wrapper = createWrapper({
data: {
...mockPanelData,
queries: [{ ...mockPanelData.queries[0], fields: { stream_type: "enrichment_tables" } }],
},
});
expect(wrapper.vm.goToLogsLocked).toBe(false);
});
it("should show go to logs option only for SQL queries", () => {
const sqlPanel = { ...mockPanelData, queryType: "sql" };
wrapper = createWrapper({ data: sqlPanel });
@@ -481,7 +481,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("panel.downloadAsJSON") }}
</ODropdownItem>
<ODropdownItem
v-if="!simplifiedPanelView && metaData && metaData.queries?.length > 0"
v-if="
!simplifiedPanelView && metaData && metaData.queries?.length > 0 && goToLogsLocked
"
disabled
focusable-unavailable
icon-left="lock"
:description="goToLogsLockedReason"
data-test="dashboard-move-to-logs-module"
>
{{ t("panel.goToLogs") }}
</ODropdownItem>
<ODropdownItem
v-else-if="!simplifiedPanelView && metaData && metaData.queries?.length > 0"
:disabled="props.data.queryType != 'sql'"
data-test="dashboard-move-to-logs-module"
@select="onLogPanel"
@@ -752,6 +764,8 @@ import type { PanelNoticeActionId } from "@/utils/dashboard/panelNotices";
import { usePanelNotices } from "@/composables/dashboard/usePanelNotices";
import { ANNOTATION_SOURCE_ROW_LIMIT } from "@/utils/dashboard/annotationSources";
import { hasPanelDraft } from "@/composables/dashboard/usePanelDraft";
import { useLicenseLocks } from "@/composables/useFeatureLock";
import { streamTypeFeature } from "@/utils/featureGate";
const QueryInspector = defineAsyncComponent(() => {
return import("@/components/dashboards/QueryInspector.vue");
@@ -1181,6 +1195,15 @@ export default defineComponent({
return logsUrl;
};
const licenseLocks = useLicenseLocks();
const goToLogsFeature = computed(() =>
streamTypeFeature(props.data?.queries?.[0]?.fields?.stream_type),
);
const goToLogsLocked = computed(() => licenseLocks.isLocked(goToLogsFeature.value));
const goToLogsLockedReason = computed(() =>
goToLogsFeature.value ? licenseLocks.lockedReason(goToLogsFeature.value) : raw(""),
);
const onLogPanel = async () => {
const showNotification = showPositiveNotification(
t("dashboard.panelContainer.redirectingToLogs"),
@@ -1548,6 +1571,8 @@ export default defineComponent({
return {
props,
goToLogsLocked,
goToLogsLockedReason,
exemplarsEligible,
exemplarOverride,
exemplarsOn,
@@ -31,7 +31,8 @@ const mockStore = {
getters: {},
};
vi.mock("vuex", () => ({
vi.mock("vuex", async (importOriginal) => ({
...((await importOriginal()) as object),
useStore: () => mockStore,
}));
@@ -582,6 +583,28 @@ describe("PanelEditor.vue", () => {
expect(wrapper.emitted()).toBeDefined();
});
const mountWithFeatures = (features: Record<string, string>) =>
shallowMount(PanelEditor, {
props: { pageType: "metrics", showAddToDashboardButton: true },
global: {
...mountGlobal,
provide: {
store: { ...mockStore, state: { ...mockStore.state, zoConfig: { features } } },
},
},
});
it("blocks Add to dashboard with the license reason while dashboards is locked", () => {
wrapper = mountWithFeatures({ dashboards: "locked" });
expect(wrapper.vm.$.setupState.dashboardsLocked).toBe(true);
expect(wrapper.vm.$.setupState.dashboardsLockedReason).toContain("Locked");
});
it("leaves Add to dashboard unblocked while dashboards is enabled", () => {
wrapper = mountWithFeatures({ dashboards: "enabled" });
expect(wrapper.vm.$.setupState.dashboardsLocked).toBe(false);
});
it("should emit customQueryModeChanged on mount (immediate watcher)", () => {
wrapper = shallowMount(PanelEditor, {
props: { pageType: "dashboard" },
@@ -121,8 +121,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<QueryTypeSelector v-if="pageType === 'build'" :showQueryType="false" />
<div v-else />
<div class="ms-auto flex items-center gap-2">
<!-- A disabled button fires no pointer events, so the span is the tooltip's hover target. -->
<OTooltip
v-if="resolvedConfig.showAddToDashboardButton && dashboardsLocked"
:content="dashboardsLockedReason"
>
<span class="inline-flex">
<OButton
data-test="panel-editor-add-to-dashboard-btn"
variant="primary"
size="xs"
disabled
>
<template #icon-left>
<OIcon
name="lock"
class="lock-chip"
size="sm"
data-test="panel-editor-add-to-dashboard-lock"
/>
</template>
{{ t("search.addToDashboard") }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="resolvedConfig.showAddToDashboardButton"
v-else-if="resolvedConfig.showAddToDashboardButton"
data-test="panel-editor-add-to-dashboard-btn"
variant="primary"
size="xs"
@@ -811,6 +835,7 @@ import { resolveConfig } from "./types/panelEditor";
import { withTimeRangeVariables } from "@/utils/dashboard/variables/variablesUtils";
import { usePanelEditor } from "./composables/usePanelEditor";
import useBreakpoint from "@/composables/useBreakpoint";
import { useLicenseLock } from "@/composables/useFeatureLock";
import useDashboardPanelData from "@/composables/dashboard/useDashboardPanel";
import { SERIES_OVERRIDE_CHART_TYPES } from "@/composables/dashboard/useConfigPanel";
import { isCustomSqlPanel } from "@/utils/dashboard/chartTypeGroups";
@@ -905,6 +930,9 @@ const emit = defineEmits<PanelEditorEmits>();
const { t } = useI18nTyped();
const { locked: dashboardsLocked, lockedReason: dashboardsLockedReason } =
useLicenseLock("dashboards");
// Resolve configuration (merge props with presets)
const resolvedConfig = computed<PanelEditorConfig>(() => resolveConfig(props));
@@ -150,6 +150,15 @@ vi.mock("@/components/common/LoadingProgress.vue", () => ({
},
}));
const exemplarLookup = vi.hoisted(() => vi.fn());
vi.mock("@/composables/dashboard/useExemplarTraceLookup", () => ({
useExemplarTraceLookup: () => ({
peek: () => undefined,
lookup: exemplarLookup,
dispose: () => {},
}),
}));
// Mock console methods
global.console = {
...console,
@@ -1468,6 +1477,105 @@ describe("PanelSchemaRenderer", () => {
expect(wrapper.vm.contextMenuValue).toBe(75);
});
it("opens the alert menu locked while alerts is license-locked", async () => {
const zoConfig = store.state.zoConfig as any;
const previous = zoConfig.features;
zoConfig.features = { alerts: "locked" };
try {
wrapper = createWrapper({ allowAlertCreation: true });
wrapper.vm.onChartDomContextMenu({ x: 150, y: 250, value: 75 });
await nextTick();
expect(wrapper.vm.contextMenuVisible).toBe(true);
expect(wrapper.vm.alertsLocked).toBe(true);
expect(wrapper.vm.alertsLockedReason).toContain("Locked: needs");
} finally {
zoConfig.features = previous;
}
});
it.each([
["logs", "logs"],
["metrics", "metrics"],
["traces", "traces"],
])(
"a logs drilldown on a %s panel is locked with a lock while %s is license-locked",
async (streamType, key) => {
const zoConfig = store.state.zoConfig as any;
const previous = zoConfig.features;
zoConfig.features = { [key]: "locked" };
try {
const drilldown = [
{ name: "ToLogs", type: "logs", data: { logsMode: "auto" } },
{ name: "ToUrl", type: "byUrl", data: { url: "https://example.com" } },
];
wrapper = createWrapper({
panelSchema: {
...defaultProps.panelSchema,
queries: [
{ ...defaultProps.panelSchema.queries[0], fields: { stream_type: streamType } },
],
config: { drilldown },
},
});
wrapper.vm.drilldownArray = drilldown;
await nextTick();
expect(wrapper.find('[data-test="drilldown-menu-item-ToLogs-lock"]').exists()).toBe(true);
expect(wrapper.find('[data-test="drilldown-menu-item-ToUrl-lock"]').exists()).toBe(false);
} finally {
zoConfig.features = previous;
}
},
);
it("a logs drilldown stays live while another signal is locked", async () => {
const zoConfig = store.state.zoConfig as any;
const previous = zoConfig.features;
zoConfig.features = { traces: "locked" };
try {
const drilldown = [{ name: "ToLogs", type: "logs", data: { logsMode: "auto" } }];
wrapper = createWrapper({
panelSchema: { ...defaultProps.panelSchema, config: { drilldown } },
});
wrapper.vm.drilldownArray = drilldown;
await nextTick();
expect(wrapper.find('[data-test="drilldown-menu-item-ToLogs"]').exists()).toBe(true);
expect(wrapper.find('[data-test="drilldown-menu-item-ToLogs-lock"]').exists()).toBe(false);
} finally {
zoConfig.features = previous;
}
});
it.each([
["locked", 0],
["enabled", 1],
] as const)("following an exemplar with traces %s navigates %i times", async (state, calls) => {
const zoConfig = store.state.zoConfig as any;
const previous = zoConfig.features;
zoConfig.features = { traces: state };
try {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div />" } },
{ path: "/trace", name: "traceDetails", component: { template: "<div />" } },
],
});
await router.push("/");
const push = vi.spyOn(router, "push");
exemplarLookup.mockResolvedValue({
state: "found",
stream: "default",
startUs: 1,
endUs: 2,
});
wrapper = createWrapper({}, { plugins: [router] });
await wrapper.vm.exemplarInteraction.follow({ id: "m1", traceId: "abc", tsMs: 1 });
expect(push).toHaveBeenCalledTimes(calls);
} finally {
zoConfig.features = previous;
}
});
it("hides the chart tooltip when the alert menu opens, from a series or from empty chart area", () => {
wrapper = createWrapper({ allowAlertCreation: true });
const dispatchAction = vi.fn();
@@ -159,6 +159,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:verdict="exemplarVerdict"
:anchor="exemplarCardAnchor"
:clamped="exemplarActiveClamp"
:locked-reason="tracesLocked ? tracesLockedReason : undefined"
@enter="exemplarInteraction.onCardEnter"
@leave="exemplarInteraction.onCardLeave"
@open-trace="exemplarInteraction.openTrace"
@@ -267,6 +268,29 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
/>
<div
v-if="drilldownLockedReason(drilldown)"
class="text-dropdown-item-disabled flex cursor-not-allowed items-center px-4 py-2 text-sm"
aria-disabled="true"
tabindex="0"
:data-test="`drilldown-menu-item-${drilldown.name}`"
>
<OIcon
size="xs"
class="text-icon-locked me-2"
name="lock"
:data-test="`drilldown-menu-item-${drilldown.name}-lock`"
/>
<span class="flex min-w-0 flex-col">
<span class="select-none">{{ drilldown.name }}</span>
<span
class="text-text-secondary text-xs select-none"
:data-test="`drilldown-menu-item-${drilldown.name}-reason`"
>{{ drilldownLockedReason(drilldown) }}</span
>
</span>
</div>
<div
v-else
class="text-dropdown-item-text hover:bg-dropdown-item-hover-bg active:bg-dropdown-item-active-bg flex cursor-pointer items-center px-4 py-2 text-sm transition-colors duration-200"
:data-test="`drilldown-menu-item-${drilldown.name}`"
@click="openDrilldown(index)"
@@ -303,6 +327,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:series-role="contextMenuData?.seriesRole"
:unit="panelSchema?.config?.unit"
:unit-custom="panelSchema?.config?.unit_custom"
:locked="alertsLocked"
:locked-reason="alertsLockedReason"
@select="handleCreateAlert"
@close="hideContextMenu"
/>
@@ -350,6 +376,7 @@ import {
} from "vue";
import { useStore } from "vuex";
import { useTheme } from "@/composables/useTheme";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { chartColor } from "@/utils/chartTheme";
import { raw, useI18nTyped } from "@/types/i18n";
import { usePanelTitle } from "@/composables/dashboard/usePanelTitle";
@@ -1116,7 +1143,9 @@ export default defineComponent({
if (!Number.isFinite(start) || !Number.isFinite(end)) return [NaN, NaN];
return [toExemplarChartX(start), toExemplarChartX(end)];
});
const { locked: tracesLocked, lockedReason: tracesLockedReason } = useLicenseLock("traces");
const navigateToExemplarTrace = (marker: ExemplarMarker, verdict: TraceVerdict) => {
if (tracesLocked.value) return;
const target = buildExemplarTraceRoute(
marker,
verdict,
@@ -1328,6 +1357,7 @@ export default defineComponent({
// Context menu state for alert creation (shared with usePanelAlertCreation)
const contextMenuData = ref<any>(null);
const { locked: alertsLocked, lockedReason: alertsLockedReason } = useLicenseLock("alerts");
const {
contextMenuVisible,
@@ -2298,6 +2328,7 @@ export default defineComponent({
drilldownArray,
onChartClick,
openDrilldown,
drilldownLockedReason,
hidePopupsAndOverlays,
drilldownColumnAliases,
drilldownAllColumns,
@@ -2655,6 +2686,11 @@ export default defineComponent({
drilldownArray,
selectedAnnotationData,
openDrilldown,
alertsLocked,
alertsLockedReason,
drilldownLockedReason,
tracesLocked,
tracesLockedReason,
drilldownPopUpRef,
annotationPopupRef,
hidePopupsAndOverlays,
@@ -16,6 +16,7 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import { raw } from "@/types/i18n";
import ExemplarCard from "./ExemplarCard.vue";
import type { ExemplarMarker, TraceVerdict } from "@/ts/interfaces/exemplars";
@@ -173,6 +174,39 @@ describe("ExemplarCard", () => {
expect(wrapper.emitted("open-trace")).toHaveLength(1);
});
it.each([
{ state: "found", stream: "default", startUs: 1, endUs: 2 },
{ state: "unverified", reason: "timeout" },
] as TraceVerdict[])(
"traces locked ($state): Open trace is disabled with a lock and emits nothing",
async (verdict) => {
const wrapper = mountCard({ verdict, lockedReason: raw("Locked: needs Traces") });
const locked = byTest(wrapper, "dashboard-panel-exemplar-open-trace-locked");
expect(locked.attributes("disabled")).toBeDefined();
expect(byTest(wrapper, "dashboard-panel-exemplar-open-trace-lock").exists()).toBe(true);
expect(byTest(wrapper, "dashboard-panel-exemplar-open-trace").exists()).toBe(false);
expect(byTest(wrapper, "dashboard-panel-exemplar-open-trace-unverified").exists()).toBe(
false,
);
await locked.trigger("click");
expect(wrapper.emitted("open-trace")).toBeUndefined();
},
);
it("traces locked: shows the locked action, never Checking trace", () => {
const wrapper = mountCard({ lockedReason: raw("Locked: needs Traces") });
expect(byTest(wrapper, "dashboard-panel-exemplar-open-trace-locked").exists()).toBe(true);
expect(byTest(wrapper, "dashboard-panel-exemplar-trace-checking").exists()).toBe(false);
});
it("traces enabled: no locked Open trace", () => {
const wrapper = mountCard({
verdict: { state: "found", stream: "default", startUs: 1, endUs: 2 },
});
expect(byTest(wrapper, "dashboard-panel-exemplar-open-trace-locked").exists()).toBe(false);
expect(byTest(wrapper, "dashboard-panel-exemplar-open-trace").exists()).toBe(true);
});
it("shows a not-available trace as a muted line that wraps inside the card", () => {
const wrapper = mountCard({ verdict: { state: "not_available" } });
const line = byTest(wrapper, "dashboard-panel-exemplar-trace-unavailable");
@@ -16,7 +16,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import { computed } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { formatUnitValue, getUnitValue } from "@/utils/dashboard/convertDataIntoUnitValue";
import { formatTimestampInTimezone } from "@/utils/date";
import OButton from "@/lib/core/Button/OButton.vue";
@@ -48,8 +49,11 @@ const props = withDefaults(
verdict: TraceVerdict;
anchor?: DOMRect | null;
clamped?: "top" | "bottom" | false;
/** Set while the traces license is locked: Open trace shows locked and does nothing. */
lockedReason?: I18nText;
}>(),
{
lockedReason: undefined,
unit: undefined,
unitCustom: undefined,
decimals: undefined,
@@ -197,8 +201,28 @@ const position = computed(() => {
<span class="min-w-0">{{ t("dashboard.exemplars.traceNotAvailable") }}</span>
</div>
<div v-else class="flex min-w-0 flex-wrap items-center gap-2">
<OTooltip v-if="lockedReason" :content="lockedReason">
<span class="inline-flex">
<OButton
variant="outline"
size="sm"
disabled
data-test="dashboard-panel-exemplar-open-trace-locked"
>
<template #icon-left>
<OIcon
name="lock"
class="text-icon-locked"
size="sm"
data-test="dashboard-panel-exemplar-open-trace-lock"
/>
</template>
{{ t("dashboard.exemplars.openTrace") }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="verdict.state === 'checking'"
v-else-if="verdict.state === 'checking'"
variant="outline"
size="sm"
disabled
@@ -237,7 +261,7 @@ const position = computed(() => {
>
{{ marker.traceId }}
</div>
<div v-if="verdict.state === 'found'" class="text-text-secondary text-2xs">
<div v-if="verdict.state === 'found' && !lockedReason" class="text-text-secondary text-2xs">
{{ t("dashboard.exemplars.openTraceHint") }}
</div>
<div
@@ -0,0 +1,67 @@
// 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 } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { createStore } from "vuex";
import i18n from "@/locales";
import { raw } from "@/types/i18n";
import DbmMetricPanel from "./DbmMetricPanel.vue";
vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", () => ({
default: { name: "PanelSchemaRenderer", template: "<div />" },
}));
const EXPLORE = "http://localhost/web/metrics?org_identifier=org1";
const mountPanel = (features: Record<string, string>) =>
mount(DbmMetricPanel, {
props: {
panelKey: "load-cpu",
title: raw("CPU"),
schema: { queries: [] },
startTime: 0,
endTime: 1,
exploreUrl: EXPLORE,
},
global: {
plugins: [i18n, createStore({ state: { zoConfig: { features } } })],
stubs: { PanelSchemaRenderer: true, OSkeleton: true },
},
});
describe("DbmMetricPanel explore link", () => {
it("is a disabled lock with no href when the metrics license is locked", () => {
const wrapper = mountPanel({ metrics: "locked" });
const explore = wrapper.find('[data-test="dbm-metric-panel-explore-load-cpu"]');
expect(explore.element.tagName).toBe("SPAN");
expect(explore.attributes("aria-disabled")).toBe("true");
expect(explore.attributes("href")).toBeUndefined();
expect(wrapper.find('[data-test="dbm-metric-panel-explore-lock-load-cpu"]').exists()).toBe(
true,
);
});
it("links into the Metrics explorer when the license enables metrics", () => {
const wrapper = mountPanel({ metrics: "enabled" });
const explore = wrapper.find('[data-test="dbm-metric-panel-explore-load-cpu"]');
expect(explore.element.tagName).toBe("A");
expect(explore.attributes("href")).toBe(EXPLORE);
expect(wrapper.find('[data-test="dbm-metric-panel-explore-lock-load-cpu"]').exists()).toBe(
false,
);
});
});
+23 -1
View File
@@ -53,8 +53,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`dbm-metric-panel-by-${panelKey}`"
@update:model-value="emit('update:by', $event as string)"
/>
<OTooltip
v-if="exploreUrl && metricsLock.locked.value"
side="bottom"
:content="metricsLock.lockedReason.value"
>
<span
class="ms-auto inline-flex shrink-0"
role="link"
aria-disabled="true"
:aria-label="t('dbm.metrics.explore')"
:data-test="`dbm-metric-panel-explore-${panelKey}`"
>
<OIcon
name="lock"
size="xs"
class="text-icon-locked"
:data-test="`dbm-metric-panel-explore-lock-${panelKey}`"
/>
</span>
</OTooltip>
<a
v-if="exploreUrl"
v-else-if="exploreUrl"
:href="exploreUrl"
target="_blank"
rel="noopener"
@@ -121,6 +141,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
import type { SelectOption } from "@/lib/forms/Select/OSelect.types";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { useLicenseLock } from "@/composables/useFeatureLock";
import { panelErrorIsForbidden, panelErrorIsStreamMissing } from "@/utils/dbm/metricsPanels";
// Async: the dashboards engine is heavy and must not ride the DBM shell's
@@ -162,6 +183,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18nTyped();
const metricsLock = useLicenseLock("metrics");
const helpText = computed<I18nText | null>(() => (props.help ? raw(props.help) : null));
@@ -0,0 +1,54 @@
// 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 { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import { raw } from "@/types/i18n";
import DbmRowActions from "./DbmRowActions.vue";
const mountActions = () =>
mount(DbmRowActions, {
props: {
dataTest: "t",
actions: [
{ id: "copy", icon: "content-copy", label: raw("Copy") },
{
id: "ai",
icon: "brain-circuit",
label: raw("Suggest fix"),
lockedReason: raw("Locked: needs AI assistant"),
},
],
},
global: { plugins: [i18n] },
});
describe("DbmRowActions", () => {
it("emits an unlocked action on click", async () => {
const wrapper = mountActions();
await wrapper.find('[data-test="t-copy"]').trigger("click");
expect(wrapper.emitted("action")?.[0]).toEqual(["copy"]);
});
it("renders a license-locked action as a disabled button that emits nothing", async () => {
const wrapper = mountActions();
const locked = wrapper.find('[data-test="t-ai"]');
expect(locked.attributes("disabled")).toBeDefined();
expect(locked.attributes("aria-label")).toBe("Suggest fix");
await locked.trigger("click");
expect(wrapper.emitted("action")).toBeUndefined();
});
});
+32 -14
View File
@@ -29,19 +29,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="flex items-center justify-end gap-0.5" :data-test="dataTest">
<OButton
v-for="action in actions"
:key="action.id"
variant="ghost"
size="icon-xs-sq"
:icon-left="action.icon"
class="max-md:hidden"
:data-test="`${dataTest}-${action.id}`"
@click.stop="emit('action', action.id)"
>
<!-- Icon-only actions carry no label, so the tooltip names them; `left` opens it into the row, not over the row above. -->
<OTooltip side="left" :content="action.label" />
</OButton>
<template v-for="action in actions" :key="action.id">
<OTooltip v-if="action.lockedReason" side="left" :content="action.lockedReason">
<span class="inline-flex max-md:hidden">
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="lock"
disabled
:aria-label="action.label"
:data-test="`${dataTest}-${action.id}`"
/>
</span>
</OTooltip>
<OButton
v-else
variant="ghost"
size="icon-xs-sq"
:icon-left="action.icon"
class="max-md:hidden"
:data-test="`${dataTest}-${action.id}`"
@click.stop="emit('action', action.id)"
>
<!-- Icon-only actions carry no label, so the tooltip names them; `left` opens it into the row, not over the row above. -->
<OTooltip side="left" :content="action.label" />
</OButton>
</template>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
@@ -57,7 +70,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<ODropdownItem
v-for="action in actions"
:key="action.id"
:icon-left="action.icon"
:icon-left="action.lockedReason ? 'lock' : action.icon"
:disabled="!!action.lockedReason"
:focusable-unavailable="!!action.lockedReason"
:description="action.lockedReason"
class="md:hidden"
:data-test="`${dataTest}-${action.id}-menu`"
@select="emit('action', action.id)"
@@ -83,6 +99,8 @@ export interface DbmRowAction {
id: string;
icon: IconName;
label: I18nText;
/** License-lock reason; the action then renders disabled with a lock. */
lockedReason?: I18nText;
}
const props = withDefaults(
@@ -16,7 +16,12 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, ref } from "vue";
import i18n from "@/locales";
import { provideFeatureLock } from "@/composables/useFeatureLock";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import { DBM_ORIGIN_QUERY_KEY } from "@/utils/dbm/queryDetailOrigin";
@@ -541,4 +546,44 @@ describe("DbmSectionTabs", () => {
expect(push).not.toHaveBeenCalled();
});
});
describe("on a license-locked page", () => {
const mountLocked = (child: () => ReturnType<typeof h>) => {
currentRoute = { name: "dbmDatabases", query: {} };
const LockedPage = defineComponent({
setup() {
provideFeatureLock(ref("db_monitoring"));
return child;
},
});
return mount(LockedPage, { global: { plugins: [i18n] } });
};
const disabledTabs = (wrapper: ReturnType<typeof mount>) =>
wrapper
.findAll('[role="tab"]')
.filter((tab) => tab.attributes("aria-disabled") === "true")
.map((tab) => tab.attributes("data-test"));
it("disables a plain tab strip, which is why the section tabs opt out", () => {
const wrapper = mountLocked(() =>
h(OTabs, { modelValue: "a" }, () => [h(OTab, { name: "a", "data-test": "plain-a" })]),
);
expect(disabledTabs(wrapper)).toEqual(["plain-a"]);
});
it("keeps every section tab live so the reader can still move between sections", () => {
const wrapper = mountLocked(() => h(DbmSectionTabs, { databaseCount: 2, queryCount: 34 }));
expect(disabledTabs(wrapper)).toEqual([]);
});
it("keeps the edition gate on its own tabs", () => {
mockConfig.isEnterprise = "false";
const wrapper = mountLocked(() => h(DbmSectionTabs, { databaseCount: 2, queryCount: 34 }));
expect(disabledTabs(wrapper)).toEqual([
"dbm-section-tab-deadlocks",
"dbm-section-tab-blocked",
"dbm-section-tab-tableHealth",
]);
});
});
});
+43 -38
View File
@@ -41,27 +41,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
so drilling into a query does not unlight the tab it was opened from.
-->
<template>
<OTabs :model-value="activeTab" align="left" data-test="dbm-section-tabs" @change="navigate">
<OTab
v-for="section in sections"
:key="section.key"
:name="section.key"
:disable="section.disabled"
:data-test="`dbm-section-tab-${section.key}`"
>
<span>{{ section.label }}</span>
<!-- The lock is what makes a disabled tab READ as gated rather than
<!-- Section navigation stays live on a license-locked page; edition-gated tabs keep their own `disable`. -->
<FeatureLockExempt class="contents">
<OTabs :model-value="activeTab" align="left" data-test="dbm-section-tabs" @change="navigate">
<OTab
v-for="section in sections"
:key="section.key"
:name="section.key"
:disable="section.disabled"
:data-test="`dbm-section-tab-${section.key}`"
>
<span>{{ section.label }}</span>
<!-- The lock is what makes a disabled tab READ as gated rather than
broken: dimmed text alone says "unavailable", not "available on
another plan". It is decorative for AT — the tooltip carries the
sentence, and TabsTrigger already exposes aria-disabled. -->
<OIcon
v-if="section.disabled"
name="lock"
size="xs"
aria-hidden="true"
:data-test="`dbm-section-tab-lock-${section.key}`"
/>
<!-- The count is what the tab is about, so the reader can see the shape
<OIcon
v-if="section.disabled"
name="lock"
class="lock-chip"
size="xs"
aria-hidden="true"
:data-test="`dbm-section-tab-lock-${section.key}`"
/>
<!-- The count is what the tab is about, so the reader can see the shape
of the other views without opening them.
An OVERLAP count (calls) carries its vantage as a suffix on the
@@ -71,26 +74,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
absent for the counts that exist in one vantage only: qualifying a
deadlock count would imply a choice between feeds that was never
available. -->
<span
v-if="section.count != null"
class="text-2xs inline-flex items-baseline gap-1 rounded-full px-1.5 py-1 leading-none font-bold"
:class="
section.key === activeTab
? 'bg-badge-primary-soft-bg text-badge-primary-soft-text'
: 'bg-surface-subtle text-text-secondary'
"
:data-test="`dbm-section-tab-badge-${section.key}`"
><span>{{ section.count }}</span
><span
v-if="section.vantageLabel"
class="text-3xs font-normal opacity-80"
:data-test="`dbm-section-tab-vantage-${section.key}`"
>{{ section.vantageLabel }}</span
></span
>
<OTooltip v-if="section.hint" side="bottom" :content="section.hint" />
</OTab>
</OTabs>
<span
v-if="section.count != null"
class="text-2xs inline-flex items-baseline gap-1 rounded-full px-1.5 py-1 leading-none font-bold"
:class="
section.key === activeTab
? 'bg-badge-primary-soft-bg text-badge-primary-soft-text'
: 'bg-surface-subtle text-text-secondary'
"
:data-test="`dbm-section-tab-badge-${section.key}`"
><span>{{ section.count }}</span
><span
v-if="section.vantageLabel"
class="text-3xs font-normal opacity-80"
:data-test="`dbm-section-tab-vantage-${section.key}`"
>{{ section.vantageLabel }}</span
></span
>
<OTooltip v-if="section.hint" side="bottom" :content="section.hint" />
</OTab>
</OTabs>
</FeatureLockExempt>
</template>
<script setup lang="ts">
@@ -102,6 +106,7 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import FeatureLockExempt from "@/components/common/FeatureLockExempt.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import { badgeCount, countVantage, type DbmCountClaim } from "@/utils/dbm/format";
import {
+16 -1
View File
@@ -28,8 +28,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
QueryErrorState, so every AI entry point in the app reads as the same control.
-->
<template>
<OTooltip v-if="aiLock.locked.value" :content="aiLock.lockedReason.value">
<span class="inline-flex shrink-0">
<OButton
variant="ghost"
:size="size"
disabled
icon-left="lock"
:data-test="`${dataTest}-locked`"
>
{{ label }}
</OButton>
</span>
</OTooltip>
<OButton
v-if="aiEnabled"
v-else-if="aiEnabled"
variant="ghost"
:size="size"
class="group text-ai-accent! hover:shadow-glow-md hover:shadow-ai-accent/35 dark:shadow-glow-md dark:shadow-ai-accent/20 dark:hover:shadow-glow-md dark:hover:shadow-ai-accent/35 shrink-0 [background:var(--color-gradient-ai-subtle)]! [transition:background_0.3s_ease,box-shadow_0.3s_ease,color_0.3s_ease] hover:text-white! hover:[background:var(--color-gradient-ai)]! dark:text-white!"
@@ -57,6 +70,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
import type { ButtonSize } from "@/lib/core/Button/OButton.types";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useAiIcon } from "@/composables/useAiIcon";
import { useLicenseLock } from "@/composables/useFeatureLock";
import type { I18nText } from "@/types/i18n";
withDefaults(
@@ -74,6 +88,7 @@ const emit = defineEmits<{ (e: "click"): void }>();
const store = useStore();
const { aiIconSrc } = useAiIcon();
const aiLock = useLicenseLock("ai_assistant");
const aiEnabled = computed(
() => config.isEnterprise == "true" && Boolean(store.state.zoConfig?.ai_enabled),
+1 -1
View File
@@ -139,7 +139,7 @@ export default {
>
{{ node.label }}
</div>
<OIcon v-if="node.locked" name="lock" size="sm" class="text-warning-600 shrink-0" />
<OIcon v-if="node.locked" name="lock" size="sm" class="lock-chip shrink-0" />
<div v-else class="drag-dots grid h-2 w-2 grid-cols-2 gap-0.5">
<span class="dot h-0.5 w-0.5 rounded-full transition-all"></span>
<span class="dot h-0.5 w-0.5 rounded-full transition-all"></span>
+25 -3
View File
@@ -304,11 +304,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<li
v-for="(pipeline, index) in transformedPipelineList"
:key="pipeline.value"
@click="onPipelineSelect(pipeline)"
class="hover:bg-muted/50 flex cursor-pointer items-center px-3 py-2"
:class="
pipelinesLocked
? 'flex items-center gap-1.5 px-3 py-2'
: 'hover:bg-muted/50 flex cursor-pointer items-center px-3 py-2'
"
:data-test="`function-list-pipeline-item-${pipeline.value}`"
@click="!pipelinesLocked && onPipelineSelect(pipeline)"
>
<span class="text-sm">{{ index + 1 }}. {{ pipeline.label }}</span>
<OTooltip v-if="pipelinesLocked" :content="pipelinesLockedReason">
<span
class="inline-flex"
:data-test="`function-list-pipeline-lock-${pipeline.value}`"
>
<OIcon name="lock" size="xs" class="lock-chip shrink-0" />
</span>
</OTooltip>
</li>
</ul>
</div>
@@ -339,6 +351,8 @@ import { useReo } from "@/services/reodotdev_analytics";
import searchState from "@/composables/useLogs/searchState";
import OButton from "@/lib/core/Button/OButton.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
@@ -350,6 +364,7 @@ import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { downloadFile } from "@/utils/dom";
import { forbiddenState } from "@/utils/featureGate";
import { useMutation, useQuery } from "@tanstack/vue-query";
import {
bulkDeleteFunctionsMutation,
@@ -360,6 +375,7 @@ import { useOrgId } from "@/composables/query/useOrgId";
import { useEditorDraftIds } from "@/composables/useEditorDraft";
import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import EditorDraftDot from "@/components/common/drafts/EditorDraftDot.vue";
import { useLicenseLock } from "@/composables/useFeatureLock";
export default defineComponent({
name: "functionList",
@@ -372,6 +388,8 @@ export default defineComponent({
ConfirmDialog,
OButton,
OBadge,
OIcon,
OTooltip,
ODialog,
ODropdown,
ODropdownItem,
@@ -389,6 +407,8 @@ export default defineComponent({
const store = useStore();
const { t } = useI18nTyped();
const router = useRouter();
const { locked: pipelinesLocked, lockedReason: pipelinesLockedReason } =
useLicenseLock("pipelines");
const formData: any = ref({});
const showAddJSTransformDialog: any = ref(false);
const selectedDelete: any = ref(null);
@@ -483,7 +503,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 = functions.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
const fetching = functions.isFetching;
// Epoch ms of the last successful read — drives the button's "1m ago" label.
@@ -1013,6 +1033,8 @@ export default defineComponent({
selectedPipeline,
closeDialog,
onPipelineSelect,
pipelinesLocked,
pipelinesLockedReason,
transformedPipelineList,
getAssociatedPipelines,
filterQuery,
@@ -115,7 +115,7 @@
<OIcon
name="lock"
size="sm"
class="text-warning-600"
class="lock-chip"
data-test="function-transform-type-js-lock"
/>
</template>
+2 -1
View File
@@ -312,6 +312,7 @@ import { copyToClipboard } from "@/utils/clipboard";
import { getBasicAuth } from "@/utils/auth";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { forbiddenState } from "@/utils/featureGate";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
@@ -374,7 +375,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 = tokensList.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
const filterQuery = ref("");
const showCreateForm = ref(false);
+2 -1
View File
@@ -257,6 +257,7 @@ import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { forbiddenState } from "@/utils/featureGate";
import { useListViewState } from "@/composables/useListViewState";
const showAddGroup = ref(false);
@@ -492,7 +493,7 @@ const lastUpdatedAt = groupsList.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 = groupsList.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// `force` for every reload that follows a write or an explicit refresh —
// an "added" event means the server has something new to show.
@@ -271,6 +271,27 @@ describe("EditGroup Component", () => {
consoleSpy.mockRestore();
});
it("stays on the page for a license denial and leaves the list redirect to other failures", async () => {
const { getGroup } = await import("@/services/iam");
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const pushSpy = vi.spyOn(router, "push").mockResolvedValue(undefined as any);
vi.mocked(getGroup).mockRejectedValueOnce({
response: { status: 403, data: { code: 40001, error_detail: "rbac" } },
});
await wrapper.vm.getGroupDetails();
await flushPromises();
expect(pushSpy).not.toHaveBeenCalled();
vi.mocked(getGroup).mockRejectedValueOnce(new Error("Network error"));
await wrapper.vm.getGroupDetails();
await flushPromises();
expect(pushSpy).toHaveBeenCalledWith(expect.objectContaining({ name: "groups" }));
pushSpy.mockRestore();
consoleSpy.mockRestore();
});
it("stores group details in groupsState", async () => {
const { getGroup } = await import("@/services/iam");
const mockGroupData = {
@@ -110,6 +110,7 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import { confirmForcedSignOutDiscard, draftSignOutForced } from "@/utils/drafts/draftSession";
import { toast } from "@/lib/feedback/Toast/useToast";
import { licenseDeniedFeatures } from "@/utils/featureGate";
onBeforeMount(() => {
getGroupDetails();
@@ -222,6 +223,8 @@ const getGroupDetails = () => {
})
.catch((err) => {
console.log(err);
// The page stays and renders locked; the license bar explains the denial.
if (licenseDeniedFeatures(err)) return;
toast({
message: err?.message || t("iam.editGroup.groupNotFound"),
variant: "error",
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import i18n from "@/locales";
// Stub route guard and utils before router/component are imported
vi.mock("@/utils/zincutils", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
// minimal overrides used in Quota.vue and router guards
getImageURL: vi.fn(() => "#"),
getUUID: vi.fn(() => "uuid-1"),
routeGuard: vi.fn((_to, _from, next) => next()),
};
});
// Basic DOM/URL mocks similar to reference style
// @ts-expect-error - Partial URL mock for testing
global.URL = global.URL || {};
// @ts-expect-error - Attach functions if missing
if (typeof global.URL.createObjectURL !== "function") {
// @ts-expect-error
global.URL.createObjectURL = vi.fn(() => "blob://test");
}
// @ts-expect-error
if (typeof global.URL.revokeObjectURL !== "function") {
// @ts-expect-error
global.URL.revokeObjectURL = vi.fn();
}
import router from "@/test/unit/helpers/router";
import store from "@/test/unit/helpers/store";
// Avoid waiting for router.isReady() in tests
// @ts-ignore
router.isReady = () => Promise.resolve();
import Quota from "@/components/iam/quota/Quota.vue";
// Mocks
vi.mock("@/services/organizations", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: {
os_list: vi.fn(async () => ({
data: {
data: [
{ name: "Org A", identifier: "org_a" },
{ name: "Org B", identifier: "org_b" },
],
},
})),
},
});
});
vi.mock("@/services/iam", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
getRoles: vi.fn(async () => ({ data: ["admin", "member"] })),
});
});
// Mock toast — Quota.vue notifies via toast(), not a legacy notify plugin
const mockToast = vi.hoisted(() => vi.fn(() => vi.fn())); // toast returns a dismiss fn
vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: mockToast,
}));
vi.mock("@/services/rate_limit", () => ({
default: {
update_batch: vi.fn(async () => ({ status: 200, data: { message: "Saved" } })),
download_template: vi.fn(async () => ({ status: 200, data: { some: "json" } })),
upload_template: vi.fn(async () => ({ status: 200, data: { message: "Uploaded" } })),
},
}));
vi.mock("@/composables/useRateLimiter", () => {
const getApiLimitsByOrganization = vi.fn(async () => [
{ module_name: "module_1", list: 10, get: 20, create: 30, update: 40, delete: 50 },
{ module_name: "module_2", list: 10, get: "-", create: "-", update: 5, delete: 1 },
]);
const getRoleLimitsByOrganization = vi.fn(async () => [
{ module_name: "module_1", list: 11, get: 21, create: 31, update: 41, delete: 51 },
]);
const getModulesToDisplay = vi.fn(async () => [
{ label: "module_1", value: "module_1" },
{ label: "module_2", value: "module_2" },
]);
return {
default: () => ({
getApiLimitsByOrganization,
getRoleLimitsByOrganization,
getModulesToDisplay,
}),
};
});
const iamSvc = await import("@/services/iam");
const mountQuota = async (routeQuery = {}) => {
// ensure required store shape
store.state.modulesToDisplay = store.state.modulesToDisplay || {};
store.state.allApiLimitsByOrgId = store.state.allApiLimitsByOrgId || {};
store.state.allRoleLimitsByOrgIdByRole = store.state.allRoleLimitsByOrgIdByRole || {};
store.state.organizations = store.state.organizations || [];
store.state.theme = store.state.theme || "light";
await router.push({ name: "quota", query: routeQuery });
await router.isReady();
await flushPromises();
return mount(Quota, {
global: {
plugins: [i18n, router, store],
stubs: {
QueryEditor: { template: '<div data-test="query-editor-stub" />' },
ConfirmDialog: {
props: ["modelValue", "title", "message"],
template: '<div class="confirm-dialog-stub" />',
},
NoOrganizationSelected: { template: '<div data-test="no-org-selected" />' },
NoData: { template: '<div data-test="no-data" />' },
AppTabs: { template: '<div data-test="app-tabs-stub" />' },
Pagination: { template: '<div data-test="pagination-stub" />' },
},
},
});
};
describe("Quota role limits tab under a locked rbac license", () => {
it("keeps the tab, shows the denied state and skips the roles fetch", async () => {
const saved = store.state.zoConfig;
store.state.zoConfig = { ...saved, features: { rbac: "locked" } };
const wrapper = await mountQuota();
const tab = wrapper.vm.tabs.find((t) => t.value === "role-limits");
expect(tab).toBeDefined();
expect(tab.tooltipLabel).toContain("Locked");
iamSvc.getRoles.mockClear();
await wrapper.vm.updateActiveTab("role-limits");
await flushPromises();
expect(iamSvc.getRoles).not.toHaveBeenCalled();
expect(wrapper.find('[data-test="quota-role-limits-license-denied"]').exists()).toBe(true);
wrapper.unmount();
store.state.zoConfig = saved;
});
});
+36 -11
View File
@@ -59,7 +59,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="edit-table-btn"
variant="outline"
size="sm"
:disabled="activeTab == 'role-limits' && !expandedRow"
:disabled="activeTab == 'role-limits' && (rolesDenied || !expandedRow)"
@click="editTableWithInput"
>
{{ t("iam.quotaPage.editQuota") }}
@@ -73,7 +73,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="mb-2 flex w-full items-center justify-between max-md:flex-wrap max-md:gap-y-1.5"
>
<div
v-if="selectedOrganization && activeType == 'table'"
v-if="selectedOrganization && activeType == 'table' && !rolesDenied"
class="flex items-center max-md:min-w-0 max-md:flex-wrap max-md:gap-y-1.5"
>
<OSearchInput
@@ -102,7 +102,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<div
v-if="selectedOrganization"
v-if="selectedOrganization && !rolesDenied"
class="float-right ms-auto flex items-center max-md:flex-wrap max-md:justify-end max-md:gap-y-1.5"
>
<div class="app-tabs-container me-3 h-9 w-fit">
@@ -212,9 +212,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:read-only="!editTable"
/>
</div>
<LicenseDeniedState
v-if="rolesDenied"
:features="['rbac']"
data-test="quota-role-limits-license-denied"
/>
<!-- this table for role limits -->
<div
v-if="activeTab == 'role-limits' && activeType == 'table'"
v-if="activeTab == 'role-limits' && activeType == 'table' && !rolesDenied"
class="bg-card-glass-bg min-h-0 flex-1 overflow-hidden"
>
<OTable
@@ -310,7 +315,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div
class="bg-card-glass-bg min-h-0 flex-1"
v-if="activeTab == 'role-limits' && activeType == 'json'"
v-if="activeTab == 'role-limits' && activeType == 'json' && !rolesDenied"
>
<QueryEditor
data-test="json-view-roles-editor"
@@ -415,6 +420,7 @@ import {
ref,
watch,
defineAsyncComponent,
h,
} from "vue";
import NoOrganizationSelected from "@/components/shared/grid/NoOrganizationSelected.vue";
import OButton from "@/lib/core/Button/OButton.vue";
@@ -438,10 +444,16 @@ import useRateLimiter from "@/composables/useRateLimiter";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import { featureState } from "@/utils/featureGate";
import { useLicenseLocks } from "@/composables/useFeatureLock";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
const LockedTabIcon = () => h(OIcon, { name: "lock", size: "sm", class: "text-icon-locked" });
export default defineComponent({
name: "Quota",
components: {
NoOrganizationSelected,
LicenseDeniedState,
OButton,
OSelect,
OSearchInput,
@@ -475,17 +487,27 @@ export default defineComponent({
const activeType = ref<any>("table");
const activeTimeUnit = ref<string>("second");
const tabs = ref<any[]>([
const { lockedReason } = useLicenseLocks();
const rbacState = computed(() => featureState(store.state.zoConfig?.features, "rbac"));
const rbacLocked = computed(() => rbacState.value === "locked");
const tabs = computed(() => [
{
label: t("iam.quotaPage.apiLimits"),
value: "api-limits",
icon: "speed",
},
{
label: t("iam.quotaPage.roleLimits"),
value: "role-limits",
icon: "shield",
},
// Role limits list custom roles, an API the rbac license gates.
...(rbacState.value === "hidden"
? []
: [
{
label: t("iam.quotaPage.roleLimits"),
value: "role-limits",
icon: rbacLocked.value ? LockedTabIcon : "shield",
tooltipLabel: rbacLocked.value ? lockedReason("rbac") : undefined,
},
]),
]);
const timeUnitTabs = ref<any[]>([
@@ -678,6 +700,7 @@ export default defineComponent({
];
});
const activeTab = ref<string>("api-limits");
const rolesDenied = computed(() => activeTab.value === "role-limits" && rbacLocked.value);
const searchQuery = ref<string>("");
const apiLimitsRows = ref<any[]>([]);
const editTable = ref<boolean>(false);
@@ -910,6 +933,7 @@ export default defineComponent({
editTable.value = true;
};
const getRolesByOrganization = async () => {
if (rbacLocked.value) return;
//here we are getting the roles from the api
//as we are not storing the roles in the store
//so we need to get the roles from the api
@@ -1506,6 +1530,7 @@ export default defineComponent({
activeTab,
updateActiveTab,
tabs,
rolesDenied,
typeTabs,
editTable,
searchQuery,
+2 -1
View File
@@ -139,6 +139,7 @@ import { toast } from "@/lib/feedback/Toast/useToast";
import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
import { forbiddenState } from "@/utils/featureGate";
import { useListViewState } from "@/composables/useListViewState";
const { t } = useI18nTyped();
@@ -327,7 +328,7 @@ const lastUpdatedAt = rolesList.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 = rolesList.error.value;
return e?.status === 403 || e?.response?.status === 403;
return forbiddenState(e) || e?.status === 403;
});
// `GET /roles` returns role NAMES only, so a role row has nothing to show beyond
@@ -2925,6 +2925,21 @@ describe("EditRole savedGrants - getRoleDetails orchestration", () => {
);
push.mockRestore();
});
it("stays on the page, without a toast, for a license denial", async () => {
ctl.resourcesError = Object.assign(new Error("denied"), {
response: { status: 403, data: { code: 40001, error_detail: "rbac" } },
});
const push = vi.spyOn(router, "push");
const wrapper = await mountEditRole();
await flushPromises();
expect(push).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
expect(wrapper.vm.isFetchingInitialRoles).toBe(false);
expect(wrapper.vm.licenseDenied).toEqual(["rbac"]);
push.mockRestore();
});
});
describe("EditRole savedGrants - savePermissionHash / seedSaved", () => {
+12
View File
@@ -139,6 +139,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:module-label="moduleLabel"
:entity-label="entityLabel"
:summary-actions="summaryActions"
:license-denied="licenseDenied"
@open="(moduleKey) => (activeModule = moduleKey)"
@preset="(presetId) => applyPreset(presetId, { announce: true })"
@load-streams="loadPresetStreams()"
@@ -166,6 +167,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
language="json"
ref="permissionJsonEditorRef"
v-model:query="permissionsJsonValue"
:read-only="pageLocked"
style="height: calc(100vh - var(--navbar-height) - 295px)"
/>
<!-- eslint-enable local/no-hardcoded-px -->
@@ -355,7 +357,9 @@ import AppTabs from "@/components/common/AppTabs.vue";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import GroupServiceAccounts from "../groups/GroupServiceAccounts.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import { licenseDeniedFeatures } from "@/utils/featureGate";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import { useFeatureLock } from "@/composables/useFeatureLock";
interface RoleDraftData {
added: string[];
@@ -369,6 +373,9 @@ interface RoleDraftData {
const QueryEditor = defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue"));
// Monaco is not an O2 primitive, so the page lock cannot disable it on its own.
const { locked: pageLocked } = useFeatureLock();
onBeforeMount(() => {
permissionsState.permissions = [];
editingRole.value = router.currentRoute.value.params.role_name as string;
@@ -428,6 +435,8 @@ const removedServiceAccounts = ref(new Set());
const roleUsers: Ref<string[]> = ref([]);
const licenseDenied = ref<string[] | null>(null);
const permissionsUiType = ref("table");
// Held here, not in RoleSummary, so a closed Presets panel stays closed when a module is opened and left.
@@ -610,6 +619,9 @@ const getRoleDetails = () => {
})
.catch((error) => {
isFetchingInitialRoles.value = false;
// The page stays and renders locked; the license bar explains the denial.
licenseDenied.value = licenseDeniedFeatures(error);
if (licenseDenied.value) return;
if (error?.response?.status === 404) {
roleGone = true;
deleteEditorDraftsFor({
@@ -5,6 +5,8 @@ import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import { raw } from "@/types/i18n";
import type { PresetInfo, PresetPreview } from "@/composables/iam/useRolePresets";
import { defineComponent, h, ref } from "vue";
import { provideFeatureLock } from "@/composables/useFeatureLock";
vi.mock("@/aws-exports", () => ({
default: { isCloud: "false", isEnterprise: "true" },
@@ -47,6 +49,7 @@ async function mountSummary(
modules: SummaryModule[];
loading: boolean;
previewPreset: (id: string) => PresetPreview;
licenseDenied: string[];
}> = {},
attachTo?: HTMLElement,
) {
@@ -91,6 +94,31 @@ describe("RoleSummary - empty role", () => {
expect(wrapper.emitted("preset")).toEqual([["readonly"]]);
});
it("offers no preset on a license-locked page", async () => {
const LockedPage = defineComponent({
setup() {
provideFeatureLock(ref("rbac"));
return () => h(RoleSummary, { modules: [], loading: false });
},
});
const wrapper = mount(LockedPage, { global: { plugins: [i18n, store, router] } });
await flushPromises();
expect(wrapper.find('[data-test="edit-role-summary-empty"]').exists()).toBe(true);
expect(wrapper.findAll('[data-test="edit-role-summary-empty"] button')).toHaveLength(0);
expect(wrapper.find('[data-test="edit-role-presets-panel"]').exists()).toBe(false);
expect(wrapper.find('[data-test="edit-role-presets-open"]').exists()).toBe(false);
});
it("says the license lacks the feature instead of offering presets when the load was denied", async () => {
const wrapper = await mountSummary({ licenseDenied: ["rbac"] });
expect(wrapper.find('[data-test="edit-role-summary-license-denied"]').text()).toContain(
"Your license does not include",
);
expect(wrapper.find('[data-test="edit-role-summary-empty"]').exists()).toBe(false);
});
// Presets must not show counts while the saved grants are still loading.
it("shows a spinner and no preset counts while loading", async () => {
const wrapper = await mountSummary({ loading: true });
+15 -2
View File
@@ -37,6 +37,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OSpinner size="md" data-test="edit-role-summary-loading" />
</div>
<div v-else-if="licenseDenied" class="flex flex-1 items-center justify-center p-6">
<LicenseDeniedState
:features="licenseDenied"
data-test="edit-role-summary-license-denied"
/>
</div>
<div v-else-if="!modules.length" class="flex flex-1 items-center justify-center p-6">
<OEmptyState
size="hero"
@@ -128,7 +135,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<OButton
v-if="!presetsOpen"
v-if="!locked && !presetsOpen"
variant="outline"
size="icon-sm"
class="absolute end-3 top-3 z-10"
@@ -141,7 +148,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<RolePresetsPanel
v-else
v-else-if="!locked"
class="border-border-default w-85 shrink-0 border-s @max-[38rem]/role-overview:max-h-[55%] @max-[38rem]/role-overview:w-auto @max-[38rem]/role-overview:border-s-0 @max-[38rem]/role-overview:border-t"
:presets="presets"
:preview-preset="previewPreset"
@@ -174,6 +181,8 @@ import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import RolePresetsPanel from "@/components/iam/roles/RolePresetsPanel.vue";
import { groupTone } from "@/components/iam/roles/roleGroupTone";
import type { PresetInfo, PresetPreview } from "@/composables/iam/useRolePresets";
import { useFeatureLock } from "@/composables/useFeatureLock";
import LicenseDeniedState from "@/components/common/LicenseDeniedState.vue";
export type SummaryAction = { action: string; label: I18nText };
@@ -202,6 +211,8 @@ defineProps<{
moduleLabel: (key: string) => I18nText;
entityLabel: (resource: string, entity: string) => I18nText;
summaryActions: (actions: string[]) => SummaryAction[];
/** Missing license features when the role's permissions load was license-denied. */
licenseDenied?: readonly string[] | null;
}>();
const emit = defineEmits<{
@@ -211,6 +222,8 @@ const emit = defineEmits<{
}>();
const { t } = useI18nTyped();
// The preset cards are a library button the page lock does not reach.
const { locked } = useFeatureLock();
const grouped = (count: number) => raw(count.toLocaleString());

Some files were not shown because too many files have changed in this diff Show More