mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
Merge remote-tracking branch 'origin/main' into feat/unified-notification-templates
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user