fix(ui): show RBAC denials once, in place, with the missing permission (#16131)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Ashish Kolhe
2026-11-01 06:51:01 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent 59ad6d006a
commit ea263b344f
86 changed files with 3569 additions and 1101 deletions
+106 -23
View File
@@ -366,6 +366,75 @@
"yourPlan": "bdaeec33ca6d1e39",
"your_complete_license_key": "a9909fbea80f494c"
},
"accessBoundary": {
"chip": {
"ariaLabel": "212f743cadb836d3",
"label": "cc11d415d9326ccc",
"popoverLabel": "e451d992ff88b824"
},
"copied": "ea61bc15688d1e48",
"copyDetails": "ec7ee2826c849564",
"denied": {
"create": "e64bc3593a603832",
"delete": "c346b3c06e9303ed",
"edit": "6649052d1364e44e",
"missing": "effeaa19f70b0fbd",
"missingIntro": "475cc73919dd1e9d",
"more": "5f4f590acbda8ee3",
"parts": "8b82b8f1aed3dad5",
"showLess": "94ea9b1d33a02975",
"view": "27e9f709ef02acf1"
},
"resources": {
"ai": "11fb682be0a0233d",
"alerts": "f62e9267d196c761",
"cipherKeys": "9d5e36b817bf496e",
"dashboards": "a53bcafb67d960df",
"destinations": "72eb63f032e47e34",
"downtimes": "5d9972eaed771890",
"fleetConfigs": "affc3f6b5f45623a",
"functions": "75e942e5b366227e",
"groups": "39bbb719fa2b9d22",
"llmModelPricing": "794f915f46e1cc64",
"organizations": "2730183d6b776b40",
"passcode": "df5f51e2e793f3c9",
"pipelines": "d1a8fffeb935847e",
"publicDashboards": "b4bee918655d4e11",
"regexPatterns": "36e205848147c947",
"reports": "dacca3cba3f346a4",
"roles": "c25337055464f8d3",
"savedviews": "81184cb517eda985",
"serviceAccounts": "1642d9e225e63659",
"streams": "bfd297afbf9e7430",
"templates": "56b564b75c7fdb48",
"users": "6b0cc904d081a8c1"
},
"resourcesInline": {
"ai": "11fb682be0a0233d",
"alerts": "deb3e366c077c228",
"cipherKeys": "a4ec922695af251c",
"dashboards": "21b2e2095fd38f7d",
"destinations": "1af0500e4d2be663",
"downtimes": "573ef711d75684dd",
"fleetConfigs": "fa54fb3d9e25631c",
"functions": "ef7d009c7e812da1",
"groups": "4ed379d418bb8629",
"llmModelPricing": "794f915f46e1cc64",
"organizations": "f8a665a97769f1d2",
"passcode": "583375d251142f0d",
"pipelines": "8719e4b9771d340e",
"publicDashboards": "55ff9bd4571fa4cb",
"regexPatterns": "f2677e20964b4314",
"reports": "7f26104f77a7ce47",
"roles": "66cf5513b37462be",
"savedviews": "1b46920dbc530fb7",
"serviceAccounts": "7c40134810b0f1e1",
"streams": "d5a08b6893ecd1aa",
"templates": "9c6641c1f9cfb0c6",
"unnamed": "644ebf22501f57b5",
"users": "7dfb4cf67742cb06"
}
},
"aiAssistant": {
"aiChat": {
"actionCancelledByUser": "7cb211f36eebddd0",
@@ -10046,7 +10115,6 @@
"linkHint": "f876fec1488cf888",
"loading": "88585a23b84075fd",
"nextRefreshIn": "437006d85932d526",
"noPublishPermission": "320ebf35c94192ca",
"notAvailable": "1165ac7c8d43a270",
"offline": "5bca083dbc9d298b",
"panelFrom": "d6f75b131c1b2d1c",
@@ -16391,7 +16459,6 @@
},
"detailTable": {
"addIcon": "ac9dfbff07ca5a3f",
"contentCopiedSuccessfully": "f98423629a72ca51",
"noDataAvailable": "b00811d81b2f20d8",
"sendToAiChat": "ca126ff149d20388"
},
@@ -20930,6 +20997,23 @@
"executionErrorDesc": "a416e218fd2e8118",
"expandRange": "951a8f1dd3ac554c",
"expandRangeDesc": "608a2013ea43b54e",
"explain": {
"functionArgs": "23ccb311e23591dc",
"missingStream": "6c9df1ac8e6fdb24",
"notANumber": "e59bdfd66bc6af20",
"notAStatement": "ca113cdf9328b827",
"notGrouped": "26442db638b19196",
"streamNotFound": "590b2fb0f51f4840",
"unclosedBracket": "86c4cde703e1f012",
"unclosedDelimiter": "663da4ab2c44fde6",
"unclosedQuote": "33d6fba874d7f9c1",
"unexpectedEnd": "a925691b4ea0ff51",
"unexpectedEndAfter": "ec8170badd42dbd1",
"unexpectedToken": "8846e9f8b283322a",
"unknownField": "4f28d8cf4a5530b2",
"unknownFunction": "13ff0e1450a91e6f",
"unknownFunctionSuggest": "0747842459e85e05"
},
"fieldNotFound": "89920d262b850d8a",
"fieldNotFoundDesc": "d705d7b17e7efe50",
"fixQuery": "4ab707638498c314",
@@ -27344,10 +27428,6 @@
"themeResetToOrganization": "defdaeea3b3a1bf3"
},
"composables": {
"accessRequiredMessage": "b888a2a1c7c72a74",
"accessRequiredTitle": "8aba5015a4464812",
"copied": "ea61bc15688d1e48",
"copyDetails": "ec7ee2826c849564",
"couldNotUpdateFavoriteDashboards": "22c705c3f2b18d78",
"goToJobScheduler": "ca7c7c8927946335",
"invalidSqlSyntax": "ef02d4a9e9c35300",
@@ -27356,23 +27436,6 @@
"noPermissionToChangeFavorites": "206bbb174d753838",
"pleaseWaitWhileLoadingStreams": "b1f886d2ec113e3d",
"refresh": "0e91610117029a62",
"resourceFallback": "eb7a842ff958c49c",
"resources": {
"alerts": "f62e9267d196c761",
"dashboards": "a53bcafb67d960df",
"destinations": "72eb63f032e47e34",
"functions": "75e942e5b366227e",
"llmModelPricing": "9067acc52aa483f2",
"organizations": "2730183d6b776b40",
"passcode": "1b3dbe134ffb942b",
"pipelines": "d1a8fffeb935847e",
"reports": "dacca3cba3f346a4",
"roles": "c25337055464f8d3",
"savedviews": "8b4b9a23dfb5e991",
"streams": "bfd297afbf9e7430",
"templates": "56b564b75c7fdb48",
"users": "6b0cc904d081a8c1"
},
"thereAreSomeErrorsPleaseFix": "e0fa7e73ce02634d"
},
"correlation": {
@@ -27714,6 +27777,10 @@
"error": "d98ee0e5f9399db9",
"errorCount": "6b8c59fe228558f0",
"errorSpans": "19c03e5b2f215135",
"errorStrip": {
"menuTitle": "2a3929b2c638caac",
"spanLabel": "a35aeda058f35dd0"
},
"errorTraces": "e9aefb224ed93ab1",
"errorsLabel": "be4bd56772770471",
"escapedLabel": "bc78f872d7b6c370",
@@ -27900,6 +27967,22 @@
"p50Median": "76661f0c8ac6ce54",
"previousMatch": "daa2f8c3fc897cc7",
"redMetrics": "a9d1ab9a2d6d4dac",
"results": {
"errorColumn": "54a0e8c17ebb21a1",
"eventCounts": "7a0e9c1af1b3ba17",
"eventExceptions": "2ea75cfd9a3ec2ed",
"eventKindException": "2104e56ef446302c",
"eventKindLog": "836ff184e7b41b1e",
"eventLogs": "1f2a2a07ec3f5874",
"eventOffset": "b108bcbf6c8aa6cf",
"eventsColumn": "8d14f6e72de8f18a",
"eventsHoverOrder": "b8d161636fead509",
"eventsHoverTitle": "201a1b82683cb7e8",
"fieldValueCountsCaption": "c6ce1d3d4d2c5d21",
"moreEvents": "ecccea94c62457a7",
"spansColumnTooltip": "3d4bc223031676a9",
"tracesWithMatchingSpan": "628f865548a5bc81"
},
"reviewAndApplyFilters": "28a6fd8929f1ffa5",
"rowNav": {
"firstResult": "9ca403a8f5809d02",
@@ -145,7 +145,6 @@
"Logs": [
"web/src/plugins/logs/**",
"web/src/components/logs/**",
"web/src/services/httpsearch.*",
"web/src/services/saved_views.*",
"web/src/services/patterns.*",
"tests/ui-testing/pages/logsPages/**",
+3
View File
@@ -190,6 +190,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</OButton>
<OPageHeaderStatus fallback />
<!-- EDITION BADGE / UPGRADE BUTTON -->
<!-- Enterprise/Cloud: ghost-muted badge (informational, opens about dialog) -->
<!-- Open Source: primary CTA to drive upgrades -->
@@ -513,6 +514,7 @@ import OrganizationSelector from "./OrganizationSelector.vue";
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 OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { checkFeatureAccess } from "@/utils/enterpriseFeatures";
import { readSlackOrgConfig } from "@/utils/slackSettings";
@@ -528,6 +530,7 @@ import { chartColor } from "@/utils/chartTheme";
export default defineComponent({
name: "HeaderComponent",
components: {
OPageHeaderStatus,
ThemeSwitcher,
EnterpriseUpgradeDialog,
OrganizationSelector,
@@ -0,0 +1,63 @@
<!-- 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>
<OPopover
:open="!!actionNotice"
:anchor="actionNotice?.el ?? null"
side="bottom"
align="end"
flip-align
arrow
:side-offset="-1"
:collision-padding="8"
content-class="border-0!"
@update:open="onOpenChange"
@open-auto-focus="keepFocus"
>
<OAccessDeniedCard :denials="denials" role="alert" data-test="access-action-error" />
<template #arrow>
<path class="fill-dropdown-bg" d="M0 0L6 6L12 0Z" />
<path class="fill-banner-error-soft-bg" d="M0 0L6 6L12 0Z" />
<path class="stroke-banner-error-soft-border fill-none" d="M0 0L6 6L12 0" />
</template>
</OPopover>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OAccessDeniedCard from "@/components/access/OAccessDeniedCard.vue";
import { useAccessBoundary, type AccessDenial } from "@/composables/useAccessStore";
const { actionNotice, dismissAction } = useAccessBoundary();
const denials = computed<AccessDenial[]>(() => {
const notice = actionNotice.value;
if (!notice) return [];
const request = `${notice.method} ${notice.resource.path}`;
return [{ resource: notice.resource, permission: notice.permission, requests: [request] }];
});
function onOpenChange(open: boolean): void {
if (!open) dismissAction();
}
// The control the user just pressed keeps focus; the error is announced through role="alert".
function keepFocus(event: Event): void {
event.preventDefault();
}
</script>
@@ -0,0 +1,87 @@
<!-- 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>
<div class="flex h-full min-h-0 flex-1 flex-col">
<!-- Hidden, not unmounted: the page's own reads keep running and a later success lifts the lock. -->
<div v-show="!takesOver" class="min-h-0 flex-1">
<slot />
</div>
<div
v-if="takesOver"
class="flex min-h-0 flex-1 items-center justify-center"
data-test="access-boundary-denied"
>
<OAccessLock
:denials="lockDenials"
:requests="pageRequests"
size="hero"
data-test="access-boundary-empty-state"
/>
</div>
</div>
<OAccessActionPopover v-if="!nested" />
</template>
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, provide, toRaw } from "vue";
import { matchedRouteKey, useRoute } from "vue-router";
import { providePageHeaderStatus } from "@/lib/core/PageHeader/pageHeaderStatus";
import OAccessActionPopover from "@/components/access/OAccessActionPopover.vue";
import OAccessHeaderChip from "@/components/access/OAccessHeaderChip.vue";
import OAccessLock from "@/components/access/OAccessLock.vue";
import {
accessBoundaryKey,
registerAccessBoundary,
useAccessBoundary,
type AccessDenial,
} from "@/composables/useAccessStore";
const { pageDenied, pageDenial, pageRequests, sectionEntries, deepestBoundary } =
useAccessBoundary();
const route = useRoute();
const nested = inject(accessBoundaryKey, false);
provide(accessBoundaryKey, true);
// The record this boundary's router-view renders; only the level that renders the record declaring the resource takes over, so parent chrome stays.
const renderedRecord = inject(matchedRouteKey, null);
const takesOver = computed(
() =>
pageDenied.value &&
!!renderedRecord?.value &&
toRaw(renderedRecord.value) === toRaw(route.matched[route.matched.length - 1]),
);
// Section denials show once, from the innermost boundary on screen, so a shell's own header does not repeat them.
const depth = computed(() => {
const record = renderedRecord?.value;
return record ? route.matched.findIndex((r) => toRaw(r) === toRaw(record)) : -1;
});
onBeforeUnmount(registerAccessBoundary(depth));
const showsChip = computed(
() =>
depth.value >= 0 &&
depth.value === deepestBoundary.value &&
!pageDenied.value &&
sectionEntries.value.length > 0,
);
providePageHeaderStatus(
() => (showsChip.value ? OAccessHeaderChip : null),
() => route.fullPath,
);
const lockDenials = computed<AccessDenial[]>(() => (pageDenial.value ? [pageDenial.value] : []));
</script>
@@ -0,0 +1,134 @@
<!-- 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>
<div
v-if="message.groups.length"
ref="root"
tabindex="-1"
:role="role"
class="rounded-default border-banner-error-soft-border bg-banner-error-soft-bg text-banner-error-soft-text focus-visible:ring-focus-ring flex max-h-[60vh] w-[min(22.5rem,calc(100vw-1.5rem))] gap-2 overflow-y-auto border p-3 text-sm outline-none focus-visible:ring-2"
>
<OIcon name="error" size="sm" class="mt-0.5 shrink-0" />
<div class="flex min-w-0 flex-1 flex-col items-start gap-1.5">
<div class="font-medium" data-test="access-denied-title">{{ message.title }}</div>
<i18n-t
v-if="message.groups.length === 1"
keypath="accessBoundary.denied.missing"
tag="p"
class="text-text-body break-words"
data-test="access-denied-reason"
>
<template #permission>{{ message.groups[0].label }}</template>
<template #resource>
<ResourceList :resources="message.groups[0].resources" />
</template>
</i18n-t>
<template v-else>
<p class="text-text-body">{{ t("accessBoundary.denied.missingIntro") }}</p>
<dl
class="text-text-body grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1"
data-test="access-denied-groups"
>
<template v-for="group in message.groups" :key="group.permission">
<dt class="font-semibold">{{ group.label }}</dt>
<dd class="break-words" data-test="access-denied-row">
<ResourceList :resources="group.resources" />
</dd>
</template>
</dl>
</template>
<div class="flex flex-wrap items-center gap-1">
<OButton
variant="ghost-primary"
size="xs"
icon-left="content-copy"
data-test="access-denied-copy"
@click="copyDetails"
>
{{ copied ? t("accessBoundary.copied") : t("accessBoundary.copyDetails") }}
</OButton>
<OButton
v-if="expanded && folds"
variant="ghost-primary"
size="xs"
data-test="access-denied-show-less"
@click="expanded = false"
>
{{ t("accessBoundary.denied.showLess") }}
</OButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, ref, watch, type FunctionalComponent } from "vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { useAccessCopy } from "@/components/access/useAccessCopy";
import {
ACCESS_LIST_LIMIT,
accessDeniedMessage,
accessDetails,
accessListParts,
type AccessDenial,
} from "@/composables/useAccessStore";
import { useI18nTyped, type I18nText } from "@/types/i18n";
const props = defineProps<{
denials: AccessDenial[];
/** "alert" only where the user just acted; a card opened on request is a status. */
role: "alert" | "status";
}>();
const { t } = useI18nTyped();
const root = ref<HTMLElement | null>(null);
const expanded = ref(false);
const message = computed(() => accessDeniedMessage(props.denials, t));
const folds = computed(() =>
message.value.groups.some((g) => g.resources.length > ACCESS_LIST_LIMIT),
);
const { copied, copyDetails } = useAccessCopy(() => accessDetails(props.denials));
// One toggle for the whole card: any row's "N more" expands every row.
const ResourceList: FunctionalComponent<{ resources: I18nText[] }> = ({ resources }) =>
accessListParts(resources, expanded.value, t).map((part) =>
part.more
? h(
"button",
{
type: "button",
class:
"text-text-link hover:text-text-link-hover focus-visible:ring-focus-ring rounded-default font-medium underline-offset-2 outline-none hover:underline focus-visible:ring-2",
"data-test": "access-denied-more",
onClick: () => (expanded.value = true),
},
part.text,
)
: part.text,
);
watch(
() => props.denials,
() => {
copied.value = false;
expanded.value = false;
},
);
defineExpose({ focus: () => root.value?.focus() });
</script>
@@ -0,0 +1,78 @@
<!-- 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>
<OPopover
v-model:open="open"
side="bottom"
align="end"
flip-align
arrow
:side-offset="-1"
:collision-padding="8"
content-class="border-0!"
:aria-label="t('accessBoundary.chip.popoverLabel')"
@open-auto-focus="focusCard"
>
<template #trigger>
<OButton
variant="ghost-destructive"
size="sm"
icon-left="lock"
:aria-label="t('accessBoundary.chip.ariaLabel', { title: message.title })"
data-test="access-header-chip"
>
<span class="max-md:hidden">{{ t("accessBoundary.chip.label") }}</span>
<OBadge variant="error-soft" size="sm">{{ message.resourceCount }}</OBadge>
</OButton>
</template>
<OAccessDeniedCard
ref="card"
:denials="denials"
role="status"
data-test="access-header-popover"
/>
<template #arrow>
<path class="fill-dropdown-bg" d="M0 0L6 6L12 0Z" />
<path class="fill-banner-error-soft-bg" d="M0 0L6 6L12 0Z" />
<path class="stroke-banner-error-soft-border fill-none" d="M0 0L6 6L12 0" />
</template>
</OPopover>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OAccessDeniedCard from "@/components/access/OAccessDeniedCard.vue";
import { accessDeniedMessage, sortDenials, useAccessBoundary } from "@/composables/useAccessStore";
import { useI18nTyped } from "@/types/i18n";
const { t } = useI18nTyped();
const { sectionEntries } = useAccessBoundary();
const open = ref(false);
const denials = computed(() => sortDenials(sectionEntries.value, t));
const message = computed(() => accessDeniedMessage(denials.value, t));
const card = ref<{ focus: () => void } | null>(null);
// The card takes focus instead of its first button, so a mouse open shows no focus ring.
function focusCard(event: Event): void {
event.preventDefault();
card.value?.focus();
}
</script>
+83
View File
@@ -0,0 +1,83 @@
<!-- 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
preset="no-access"
:size="size"
:title="message.title"
:description="description"
:action-label="actionLabel ?? copyLabel"
:action-icon="actionLabel ? actionIcon : 'content-copy'"
:secondary-action-label="actionLabel ? copyLabel : undefined"
@action="actionLabel ? emit('action') : copyDetails()"
@secondary-action="copyDetails"
>
<template #illustration>
<span
class="bg-banner-error-soft-bg text-banner-error-soft-text inline-flex size-20 items-center justify-center rounded-full"
data-test="access-lock-icon"
>
<OIcon name="lock" size="xl" />
</span>
</template>
</OEmptyState>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import { useAccessCopy } from "@/components/access/useAccessCopy";
import {
accessDeniedMessage,
accessDetails,
accessReason,
type AccessDenial,
} from "@/composables/useAccessStore";
import { useI18nTyped, type I18nText } from "@/types/i18n";
const props = withDefaults(
defineProps<{
/** What the lock names; one denial gives the "You can't ..." title and its missing-permission line. */
denials: AccessDenial[];
/** Read when Copy details is pressed, since a page's denied requests keep arriving after it locks. */
requests?: () => string[];
size?: "hero" | "block";
/** The view's own next step; Copy details then moves to the secondary button. */
actionLabel?: I18nText;
actionIcon?: IconName;
}>(),
{ size: "block", requests: undefined, actionLabel: undefined, actionIcon: undefined },
);
const emit = defineEmits<{ (e: "action"): void }>();
const { t } = useI18nTyped();
const message = computed(() => accessDeniedMessage(props.denials, t));
const description = computed(() =>
message.value.groups[0] ? accessReason(message.value.groups[0], t) : undefined,
);
const { copied, copyDetails } = useAccessCopy(() => {
const requests = props.requests;
return accessDetails(
requests ? props.denials.map((d) => ({ ...d, requests: requests() })) : props.denials,
);
});
const copyLabel = computed(() =>
copied.value ? t("accessBoundary.copied") : t("accessBoundary.copyDetails"),
);
</script>
@@ -0,0 +1,37 @@
// 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 { onBeforeUnmount, ref } from "vue";
import { copyToClipboard } from "@/utils/clipboard";
import { useI18nTyped } from "@/types/i18n";
const COPIED_RESET_MS = 2000;
/** Copies a 403's details and flips `copied` for a moment. */
export function useAccessCopy(details: () => string) {
const { t } = useI18nTyped();
const copied = ref(false);
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
async function copyDetails(): Promise<void> {
if (!(await copyToClipboard(details(), t, { silent: true }))) return;
copied.value = true;
clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => (copied.value = false), COPIED_RESET_MS);
}
onBeforeUnmount(() => clearTimeout(copiedTimer));
return { copied, copyDetails };
}
@@ -429,6 +429,7 @@ const fetchAlerts = async () => {
);
alertsList.value = res.data.list || [];
} catch (error) {
if ((error as { response?: { status?: number } })?.response?.status === 403) return;
toast({
variant: "error",
message: t("toastMessages.alerts.failedToLoadAlerts"),
+9 -8
View File
@@ -2366,7 +2366,6 @@ export default defineComponent({
console.error(error);
dismiss();
forbidden.value = error?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
variant: "error",
@@ -2792,12 +2791,13 @@ export default defineComponent({
.then((list: any) => {
destinations.value = list as any;
})
.catch(() =>
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 403) return;
toast({
variant: "error",
message: t("toastMessages.alerts.errorWhileFetchingDestinations"),
}),
);
});
});
};
const getTemplates = (force = false) => {
@@ -2815,12 +2815,13 @@ export default defineComponent({
.then((list: any) => {
templates.value = list;
})
.catch(() =>
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 403) return;
toast({
variant: "error",
message: t("toastMessages.alerts.errorWhileFetchingTemplates"),
}),
);
});
});
};
// The form's refresh event carries no payload, so a bare `getTemplates` handler would never see `force`.
const refreshTemplates = () => getTemplates(true);
@@ -4071,7 +4072,7 @@ export default defineComponent({
error.response?.data?.message ||
error?.message ||
t("alerts.messages.bulkDeleteAlertsFailed");
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
variant: "error",
message: errorMessage,
@@ -1144,7 +1144,7 @@ export default defineComponent({
error.response?.data?.message ||
error?.message ||
t("alerts.messages.bulkDeleteDestinationsFailed");
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
variant: "error",
message: errorMessage,
@@ -628,7 +628,6 @@ export default defineComponent({
queryClient.getQueryState(options.queryKey)?.dataUpdatedAt ?? Date.now();
} catch (e: any) {
this.forbidden = e?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!this.forbidden) {
toast({ variant: "error", message: this.t("alert_sources.error") });
}
@@ -822,7 +822,6 @@ export default defineComponent({
{ immediate: true },
);
watch(incidentsList.error, (error: any) => {
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!error || forbidden.value) return;
toast({ variant: "error", message: t("alerts.incidents.errorLoading") });
console.error("Failed to load incidents:", error);
@@ -730,7 +730,7 @@ export default defineComponent({
error.response?.data?.message ||
error?.message ||
t("alerts.messages.bulkDeleteDestinationsFailed");
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
variant: "error",
message: errorMessage,
+1 -1
View File
@@ -908,7 +908,7 @@ const bulkDeleteTemplates = () => {
err.response?.data?.message ||
err?.message ||
t("alerts.messages.bulkDeleteTemplatesFailed");
if (err.response?.status != 403 || err?.status != 403) {
if (err.response?.status !== 403 && err?.status !== 403) {
toast({
variant: "error",
message: errorMessage,
@@ -106,7 +106,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OContent class="flex min-h-0 flex-1 flex-col gap-4 overflow-auto py-3">
<div v-if="!downtime" class="relative min-h-40" data-test="downtime-detail-loading">
<OEmptyState v-if="forbidden" preset="no-access" size="block" />
<OAccessLock
v-if="forbidden"
:denials="detailDenials"
data-test="downtime-detail-forbidden"
/>
<OEmptyState
v-else-if="notFound"
size="block"
@@ -379,6 +383,8 @@ import type {
TargetModule,
} from "@/services/downtimes";
import { destinationsQuery } from "@/services/alert_destination.queries";
import { failedRequests, viewDenial } from "@/composables/useAccessStore";
import OAccessLock from "@/components/access/OAccessLock.vue";
import { sendDowntimeTestMutation } from "@/services/downtimes.queries";
import { notifySentence } from "@/utils/downtimes/summary";
import type { TestDestination } from "@/utils/downtimes/notifyTest";
@@ -461,6 +467,9 @@ const errorStatus = computed(() => {
return e?.response?.status ?? e?.status;
});
const forbidden = computed(() => errorStatus.value === 403);
const detailDenials = computed(() => [
viewDenial("downtimes", "get", failedRequests(detailQuery.error.value)),
]);
const notFound = computed(() => errorStatus.value === 404);
const downtimeFolders = useQuery(() =>
@@ -55,10 +55,9 @@
</div>
</template>
<!-- Listing and creating are separate grants, so a refused list doesn't mean a refused create. -->
<OEmptyState
<OAccessLock
v-if="forbidden && currentView === 'list'"
preset="no-access"
:description="t('dashboard.publicDashboard.noPublishPermission')"
:denials="linksDenials"
:action-label="t('dashboard.publicLinks.newLink')"
data-test="dashboards-public-links-panel-no-permission"
@action="openForm(null)"
@@ -610,6 +609,7 @@ import useNotifications from "@/composables/useNotifications";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import { useOrgId } from "@/composables/query";
import { useNow } from "@/composables/useNow";
import { failedRequests, viewDenial } from "@/composables/useAccessStore";
import { copyToClipboard } from "@/utils/clipboard";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
@@ -627,6 +627,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OContent from "@/lib/core/Content/OContent.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OAccessLock from "@/components/access/OAccessLock.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
@@ -787,6 +788,9 @@ const forbidden = computed(
(linksQuery.error.value as { response?: { status?: number } } | null)?.response?.status === 403,
);
const loadFailed = computed(() => !!linksQuery.error.value && !forbidden.value);
const linksDenials = computed(() => [
viewDenial("public_dashboards", "get", failedRequests(linksQuery.error.value)),
]);
const linkIds = computed(() => links.value.map((link) => link.id));
const statusQuery = useQuery(() =>
Object.assign(publicLinksStatusBatchQuery(orgId.value, linkIds.value), {
@@ -501,6 +501,7 @@ export default defineComponent({
functionOptions.value = [...functionList.value];
return;
} catch (e) {
if ((e as { response?: { status?: number } })?.response?.status === 403) return;
showErrorNotification(t("dashboard.errorFetchingFunctions"));
}
};
@@ -938,7 +938,7 @@ export default defineComponent({
error.response?.data?.message ||
error?.message ||
t("functions.bulkDeleteFunctionsFailed");
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
variant: "error",
message: errorMessage,
@@ -458,7 +458,6 @@ export default defineComponent({
// The query owns its failure now, so this reports it once per error however
// the read was triggered.
watch(tokensList.error, (e: any) => {
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!e || forbidden.value) return;
toast({
variant: "error",
+1 -1
View File
@@ -642,7 +642,7 @@ const bulkDeleteUserGroups = async () => {
selectedGroups.value = [];
confirmBulkDelete.value = false;
} catch (error: any) {
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
message:
error.response?.data?.message || error?.message || t("iam.appGroups.errorDeletingGroups"),
+1 -1
View File
@@ -544,7 +544,7 @@ const bulkDeleteUserRoles = async () => {
selectedRoleNames.value = [];
confirmBulkDelete.value = false;
} catch (error: any) {
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
message:
error.response?.data?.message || error?.message || t("iam.appRoles.bulkDeleteRolesError"),
@@ -1194,7 +1194,7 @@ export default defineComponent({
confirmBulkDelete.value = false;
await getServiceAccountsUsers(true);
} catch (err: any) {
if (err.response?.status != 403 || err?.status != 403) {
if (err.response?.status !== 403 && err?.status !== 403) {
toast({
message:
err?.response?.data?.message ||
+1 -1
View File
@@ -517,7 +517,7 @@ export default defineComponent({
// without a remount.
existingUser.value = false;
} else {
if (err.response?.status != 403 || err?.status != 403) {
if (err.response?.status !== 403 && err?.status !== 403) {
toast({
variant: "error",
message: raw(err.response?.data?.message) || t("toastMessages.iam.userSaveFailed"),
@@ -333,7 +333,6 @@ export default defineComponent({
const e = error as { response?: { status?: number; data?: { message?: string } } };
dismiss();
forbidden.value = e.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
message: raw(e.response?.data?.message || t("iam.invitationList.failedLoadPending")),
+1 -2
View File
@@ -1054,7 +1054,6 @@ export default defineComponent({
console.error("Failed to fetch org members:", err);
dismiss();
forbidden.value = err?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
variant: "error",
@@ -1583,7 +1582,7 @@ export default defineComponent({
});
}
} catch (err: any) {
if (err.response?.status != 403 || err?.status != 403) {
if (err.response?.status !== 403 && err?.status !== 403) {
toast({
message: blocked
? t("iam.user.blockedUsersNotRemoved", { count: emails.length })
@@ -22,7 +22,6 @@ import { useQuery } from "@tanstack/vue-query";
import OButton from "@/lib/core/Button/OButton.vue";
import OCard from "@/lib/core/Card/OCard.vue";
import OCodeBlock from "@/lib/core/Code/OCodeBlock.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
@@ -44,11 +43,13 @@ import {
useCredentialSnippet,
} from "@/composables/useCredentialSnippet";
import { useOrgCredential } from "@/composables/useOrgCredential";
import { failedRequests, viewDenial } from "@/composables/useAccessStore";
import type { FirstEventResult, StreamSignal } from "@/composables/firstEvent/useFirstEventWatch";
import { firstSourceOption, readFirstSource } from "@/components/login/firstSourceOptions";
import { getDataSourceCard, hasDataSourceCard } from "./setupCard/registry";
import type { RichCardCode, RichCardContent } from "./setupCard/types";
import FirstEventStatus from "./FirstEventStatus.vue";
import OAccessLock from "@/components/access/OAccessLock.vue";
// how long the card outlives the store flip while the watcher confirms the stream that caused it
const CONFIRM_GRACE_MS = 10_000;
@@ -121,6 +122,17 @@ const isForbidden = (err: unknown) => {
const forbidden = computed(
() => flagOn.value && queriesFor().some(({ query }) => isForbidden(query.error.value)),
);
const streamDenials = computed(() => [
viewDenial(
"streams",
"list",
failedRequests(
...queriesFor()
.map(({ query }) => query.error.value)
.filter(isForbidden),
),
),
]);
// the compact card waits for MainLayout's list, so an org with data never sees it flash; a failed list reads as empty like a failed probe
const knownEmpty = computed(() =>
queriesFor().every(({ query }) =>
@@ -411,10 +423,10 @@ const StatusLine = defineComponent({
</OBanner>
<template v-else-if="!arrived">
<OEmptyState
<OAccessLock
v-if="forbidden && variant === 'full'"
:denials="streamDenials"
size="hero"
preset="no-access"
data-test="first-data-panel-no-access"
/>
<template v-else>
@@ -1560,7 +1560,7 @@ const bulkDeletePipelines = async () => {
// Show error message from response if available
const errorMessage =
error.response?.data?.message || error?.message || t("pipeline.bulkDeletePipelinesError");
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
variant: "error",
message: errorMessage,
@@ -537,7 +537,6 @@ const loadJobs = async () => {
} catch (error: any) {
console.error("Error loading backfill jobs:", error);
forbidden.value = error?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
variant: "error",
@@ -767,7 +767,6 @@ const fetchPipelineHistory = async (force = false) => {
console.error("Error fetching pipeline history:", error);
console.error("Error response:", error.response);
forbidden.value = error?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
variant: "error",
@@ -609,7 +609,6 @@ export default defineComponent({
})
.catch((error: any) => {
forbidden.value = error?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
message: error.response?.data?.message || t("queries.fetchRunningQueriesFailed"),
+1 -1
View File
@@ -608,7 +608,7 @@ export default defineComponent({
getData(true);
})
.catch((err: any) => {
if (err.response?.status != 403 || err?.status != 403) {
if (err.response?.status !== 403 && err?.status !== 403) {
toast({
variant: "error",
message:
@@ -276,7 +276,6 @@ const forbidden = computed(() => {
return e?.status === 403 || e?.response?.status === 403;
});
const lastUpdatedAt = providersList.dataUpdatedAt;
// The grouped access toast already reports a 403; a second red toast adds nothing.
watch(providersList.error, (err: any) => {
if (err && !forbidden.value) showError(err, t("llmProviders.loadError"));
});
@@ -507,7 +507,6 @@ export default defineComponent({
// The query owns its failure now, so this reports it once per error however
// the read was triggered.
watch(regexPatternsList.error, (error: any) => {
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!error || forbidden.value) return;
toast({
message: error.data?.message || t("settings.regexPatternList.errorFetching"),
@@ -671,7 +670,7 @@ export default defineComponent({
error?.data?.message ||
error?.message ||
t("settings.regexPatternList.errorBulkDeleting");
if (error.response?.status != 403 || error?.status != 403) {
if (error.response?.status !== 403 && error?.status !== 403) {
toast({
message: errorMessage,
variant: "error",
+3
View File
@@ -841,6 +841,9 @@ const useRoutes = () => {
path: "pipelines",
name: "pipelines",
component: PipelinesList,
meta: {
accessResource: "pipelines",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
@@ -0,0 +1,190 @@
// 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, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, reactive } from "vue";
import { mount } from "@vue/test-utils";
import { createMemoryHistory, createRouter, type Router } from "vue-router";
import {
accessPermission,
currentAccessRouteKey,
extractResourceInfo,
noteAccessSuccess,
recordForbidden,
resetAccess,
useAccessBoundary,
} from "@/composables/useAccessStore";
const Blank = defineComponent({ render: () => h("div") });
const state = reactive({ selectedOrganization: { identifier: "acme" }, userInfo: {} });
let router: Router;
let access: ReturnType<typeof useAccessBoundary>;
async function boot(path: string) {
router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/users", name: "users", component: Blank, meta: { accessResource: "users" } },
{ path: "/roles", name: "roles", component: Blank, meta: { accessResource: "roles" } },
{ path: "/settings", name: "settings", component: Blank },
],
});
await router.push(path);
const Host = defineComponent({
setup() {
access = useAccessBoundary();
return () => h("div");
},
});
mount(Host, { global: { plugins: [router], provide: { store: { state } } } });
}
const forbidden = (method: string, url: string, routeKey = currentAccessRouteKey()) =>
recordForbidden({ method, url, routeKey });
describe("access store classification", () => {
beforeEach(() => {
vi.useFakeTimers();
state.selectedOrganization.identifier = "acme";
resetAccess();
});
afterEach(() => {
vi.useRealTimers();
resetAccess();
});
it("surfaces a denied bulk delete on a resource whose caller no longer toasts a 403", async () => {
await boot("/settings");
for (const url of [
"/api/acme/cipher_keys/bulk",
"/api/acme/groups/bulk",
"/api/acme/service_accounts/bulk",
"/api/acme/re_patterns/bulk",
])
forbidden("DELETE", url);
expect(access.sectionEntries.value.map((e) => e.resource.key).sort()).toEqual([
"cipher_keys",
"groups",
"re_patterns",
"service_accounts",
]);
});
it("names the collection, not the item, for a named item in the path", () => {
expect(extractResourceInfo("/api/acme/functions/my_fn")?.key).toBe("functions");
expect(extractResourceInfo("/api/acme/roles/developer")?.key).toBe("roles");
expect(extractResourceInfo("/api/acme/alerts/templates/oncall")?.key).toBe("templates");
expect(extractResourceInfo("/api/acme/alerts/destinations/pager")?.key).toBe("destinations");
expect(accessPermission("GET", "/api/acme/functions/my_fn")).toBe("get");
expect(accessPermission("GET", "/api/acme/functions")).toBe("list");
expect(accessPermission("GET", "/api/acme/users/roles/all")).toBe("list");
});
it("ignores a late 403 from the org the user switched away from", async () => {
await boot("/users");
const stamp = currentAccessRouteKey();
state.selectedOrganization.identifier = "beta";
resetAccess();
forbidden("GET", "/api/acme/users", stamp);
expect(access.pageDenied.value).toBe(false);
});
it("clears a remembered write denial once the same write succeeds", async () => {
await boot("/settings");
forbidden("DELETE", "/api/acme/functions/my_fn");
expect(access.sectionEntries.value).toHaveLength(1);
noteAccessSuccess({
method: "delete",
url: "/api/acme/functions/my_fn",
routeKey: currentAccessRouteKey(),
});
expect(access.sectionEntries.value).toHaveLength(0);
});
it("never locks a readable page for a denied write, however slow its response", async () => {
await boot("/users");
document.body.innerHTML = "<button id='b'>Delete</button>";
document.getElementById("b")!.dispatchEvent(new Event("pointerdown", { bubbles: true }));
document.body.innerHTML = "";
vi.advanceTimersByTime(5001);
forbidden("DELETE", "/api/acme/users/bulk");
expect(access.pageDenied.value).toBe(false);
expect(access.sectionEntries.value.map((e) => e.permission)).toEqual(["delete"]);
});
it("keeps a List lock when only a Get on the same resource succeeds", async () => {
await boot("/roles");
forbidden("GET", "/api/acme/roles");
expect(access.pageDenied.value).toBe(true);
noteAccessSuccess({
method: "get",
url: "/api/acme/users/reviewer@example.com/roles",
routeKey: currentAccessRouteKey(),
});
expect(access.pageDenied.value).toBe(true);
noteAccessSuccess({ method: "get", url: "/api/acme/roles", routeKey: currentAccessRouteKey() });
expect(access.pageDenied.value).toBe(false);
});
it("keeps a readable page when only a sub-read of its resource is denied", async () => {
await boot("/roles");
forbidden("GET", "/api/acme/users/roles/all");
expect(access.pageDenied.value).toBe(false);
expect(access.sectionEntries.value.map((e) => e.resource.key)).toEqual(["roles"]);
});
it("reports a denied write on a resource with no friendly name", async () => {
await boot("/settings");
forbidden("POST", "/api/acme/storage");
expect(access.sectionEntries.value.map((e) => [e.permission, e.requests])).toEqual([
["create", ["POST /api/acme/storage"]],
]);
forbidden("GET", "/api/acme/storage");
expect(access.sectionEntries.value).toHaveLength(1);
});
it("keeps one object's denial when a different object of the same resource succeeds", async () => {
await boot("/settings");
forbidden("GET", "/api/acme/functions/secret");
noteAccessSuccess({
method: "get",
url: "/api/acme/functions/readable",
routeKey: currentAccessRouteKey(),
});
expect(access.sectionEntries.value).toHaveLength(1);
noteAccessSuccess({
method: "get",
url: "/api/acme/functions/secret",
routeKey: currentAccessRouteKey(),
});
expect(access.sectionEntries.value).toHaveLength(0);
});
it("asks for Update, not Create, on a POST that toggles existing items", () => {
expect(accessPermission("POST", "/api/acme/alerts/bulk/enable")).toBe("update");
expect(accessPermission("POST", "/api/acme/pipelines/bulk/disable")).toBe("update");
expect(accessPermission("POST", "/api/acme/alerts")).toBe("create");
});
it("keeps the list lock when only a sub-read of the same resource succeeds", async () => {
await boot("/roles");
forbidden("GET", "/api/acme/roles");
noteAccessSuccess({
method: "get",
url: "/api/acme/users/roles/all",
routeKey: currentAccessRouteKey(),
});
expect(access.pageDenied.value).toBe(true);
});
});
+108
View File
@@ -0,0 +1,108 @@
// 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 { createI18n } from "vue-i18n";
import en from "@/locales/languages/en-US.json";
import type { TranslateFn } from "@/types/i18n";
import {
accessDeniedMessage,
accessDetails,
accessListParts,
accessReason,
groupDenials,
viewDenial,
type AccessPermission,
} from "@/composables/useAccessStore";
const t = createI18n({ legacy: false, locale: "en", messages: { en } }).global
.t as unknown as TranslateFn;
const deny = (key: string, permission: AccessPermission = "list") =>
viewDenial(key, permission, [`GET /api/default/${key}`]);
describe("access denial grouping", () => {
it("names a single denial in the title and one sentence", () => {
const message = accessDeniedMessage([deny("alerts")], t);
expect(message.title).toBe("You can't view alerts");
expect(message.resourceCount).toBe(1);
expect(message.groups).toHaveLength(1);
expect(accessReason(message.groups[0], t)).toBe(
"Your role is missing the List permission on Alerts.",
);
});
it("joins two resources with the same permission into one sentence", () => {
const message = accessDeniedMessage([deny("destinations"), deny("templates")], t);
expect(message.title).toBe("You can't view 2 parts of this page");
expect(message.groups).toHaveLength(1);
expect(accessReason(message.groups[0], t)).toBe(
"Your role is missing the List permission on Destinations and Templates.",
);
});
it("groups ten mixed denials by permission in role-editor order, each resource once", () => {
const listed = [
"dashboards",
"streams",
"functions",
"savedviews",
"users",
"roles",
"pipelines",
"destinations",
];
const denials = [
deny("alerts", "delete"),
...listed.map((key) => deny(key)),
deny("reports", "delete"),
deny("roles"),
];
const message = accessDeniedMessage(denials, t);
expect(message.title).toBe("You can't view 10 parts of this page");
expect(message.resourceCount).toBe(10);
expect(message.groups.map((g) => [g.label, g.resources.length])).toEqual([
["List", 8],
["Delete", 2],
]);
expect(
groupDenials(denials)[0].denials.find((d) => d.resource.key === "roles")?.requests,
).toEqual(["GET /api/default/roles"]);
const lines = accessDetails(denials)
.split("\n")
.filter((l) => l.startsWith("denied"));
expect(lines).toEqual([
`denied list: ${listed.join(", ")} (${listed.map((k) => `GET /api/default/${k}`).join(", ")})`,
"denied delete: alerts, reports (GET /api/default/alerts, GET /api/default/reports)",
]);
});
it("folds a list longer than four into an expandable 'N more' part", () => {
const resources = accessDeniedMessage(
["dashboards", "streams", "functions", "users", "roles", "pipelines"].map((k) => deny(k)),
t,
).groups[0].resources;
const folded = accessListParts(resources, false, t);
expect(folded.map((p) => p.text).join("")).toBe(
"Dashboards, Streams, Functions, Users, and 2 more",
);
expect(folded.filter((p) => p.more).map((p) => p.text)).toEqual(["2 more"]);
const open = accessListParts(resources, true, t);
expect(open.some((p) => p.more)).toBe(false);
expect(open.map((p) => p.text).join("")).toBe(
"Dashboards, Streams, Functions, Users, Roles, and Pipelines",
);
});
});
+838
View File
@@ -0,0 +1,838 @@
// 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 { computed, shallowReactive, shallowRef, toRaw, type InjectionKey, type Ref } from "vue";
import { useRouter, type RouteLocationNormalizedLoaded, type Router } from "vue-router";
import { useStore } from "vuex";
import { getIntlLocale } from "@/locales";
import { raw, type I18nKey, type I18nText, type TranslateFn } from "@/types/i18n";
/** The resource a request touched, derived from its URL; only resources with a friendly name exist. */
export interface AccessResource {
/** Compared against a route's `meta.accessResource`. */
key: string;
labelKey: I18nKey;
/** Request path without host or query. */
path: string;
}
/** IAM permission a denied request needed, named as the role editor names it. */
export type AccessPermission = "list" | "get" | "create" | "update" | "delete";
export interface AccessSectionEntry {
resource: AccessResource;
permission: AccessPermission;
/** `METHOD path` of each denied request. */
requests: string[];
}
export interface AccessActionNotice {
el: Element;
permission: AccessPermission;
method: string;
resource: AccessResource;
/** Route the request started on; the notice falls back to this route's section notice. */
routeKey: string;
/** Older qualifying activations, newest first, to re-anchor on if `el` leaves the page. */
fallbacks: Element[];
}
/** What the caller knows about the request a response belongs to. */
export interface AccessRequest {
url: string;
method?: string;
/** Route key stamped when the request started, from {@link currentAccessRouteKey}. */
routeKey?: string;
}
interface AccessShellState {
selectedOrganization?: { identifier?: string; label?: string; name?: string } | null;
userInfo?: { email?: string } | null;
}
interface AccessShellStore {
state: AccessShellState;
}
interface Activation {
el: Element;
at: number;
}
/** A denied (resource, permission) pair and the requests that hit it, as every 403 surface shows it. */
export type AccessDenial = AccessSectionEntry;
/** One permission's denied resources, labelled for display. */
export interface AccessDeniedGroup {
permission: AccessPermission;
/** The permission as the role editor names it. */
label: I18nText;
resources: I18nText[];
}
/** The shared 403 wording: a "You can't ..." title and the denials grouped by permission. */
export interface AccessDeniedMessage {
title: I18nText;
groups: AccessDeniedGroup[];
/** Distinct resources across every group, as the chip counts them. */
resourceCount: number;
}
/** A piece of a resource list; `more` marks the "N more" item that expands the list. */
export interface AccessListPart {
text: I18nText;
more: boolean;
}
interface DeniedRequest {
permission: AccessPermission;
request: string;
}
// One sessionStorage record per org, so a revisit or a reload renders the access state on its first frame.
interface OrgAccessMemory {
/** `<route>|<resource>` of each locked page, with the permission whose denial locked it. */
pages: Record<string, AccessPermission>;
/** Section denials per route key. */
sections: Record<string, AccessSectionEntry[]>;
}
const FRIENDLY_NAME_KEYS: Record<string, I18nKey> = {
dashboards: "accessBoundary.resources.dashboards",
alerts: "accessBoundary.resources.alerts",
streams: "accessBoundary.resources.streams",
functions: "accessBoundary.resources.functions",
savedviews: "accessBoundary.resources.savedviews",
users: "accessBoundary.resources.users",
roles: "accessBoundary.resources.roles",
organizations: "accessBoundary.resources.organizations",
pipelines: "accessBoundary.resources.pipelines",
destinations: "accessBoundary.resources.destinations",
templates: "accessBoundary.resources.templates",
reports: "accessBoundary.resources.reports",
passcode: "accessBoundary.resources.passcode",
"llm/models": "accessBoundary.resources.llmModelPricing",
groups: "accessBoundary.resources.groups",
service_accounts: "accessBoundary.resources.serviceAccounts",
cipher_keys: "accessBoundary.resources.cipherKeys",
re_patterns: "accessBoundary.resources.regexPatterns",
};
// Resources only a view's own lock names; the URL classifier leaves their 403s silent, so the lock is not repeated as a chip.
const IN_PAGE_NAME_KEYS: Record<string, I18nKey> = {
ai: "accessBoundary.resources.ai",
downtimes: "accessBoundary.resources.downtimes",
fleet_configs: "accessBoundary.resources.fleetConfigs",
public_dashboards: "accessBoundary.resources.publicDashboards",
};
// Paths that hit the same IAM resource as another key, so their denials merge into one row.
const RESOURCE_ALIASES: Record<string, string> = {
query_functions: "functions",
"ingestion-tokens": "passcode",
"llm/models/built-in": "llm/models",
"llm/models/refresh-built-in": "llm/models",
};
// Trailing path words that name an operation or a view of a collection, never the resource itself.
const NON_RESOURCE_SEGMENTS = new Set([
"all",
"list",
"bulk",
"enable",
"disable",
"status",
"count",
"summary",
]);
// Casing inside a sentence is a per-locale choice (German keeps nouns capitalised), so it is translated, never lower-cased in code.
const INLINE_LABEL_KEYS: Record<string, I18nKey> = {
"accessBoundary.resources.dashboards": "accessBoundary.resourcesInline.dashboards",
"accessBoundary.resources.alerts": "accessBoundary.resourcesInline.alerts",
"accessBoundary.resources.streams": "accessBoundary.resourcesInline.streams",
"accessBoundary.resources.functions": "accessBoundary.resourcesInline.functions",
"accessBoundary.resources.savedviews": "accessBoundary.resourcesInline.savedviews",
"accessBoundary.resources.users": "accessBoundary.resourcesInline.users",
"accessBoundary.resources.roles": "accessBoundary.resourcesInline.roles",
"accessBoundary.resources.organizations": "accessBoundary.resourcesInline.organizations",
"accessBoundary.resources.pipelines": "accessBoundary.resourcesInline.pipelines",
"accessBoundary.resources.destinations": "accessBoundary.resourcesInline.destinations",
"accessBoundary.resources.templates": "accessBoundary.resourcesInline.templates",
"accessBoundary.resources.reports": "accessBoundary.resourcesInline.reports",
"accessBoundary.resources.llmModelPricing": "accessBoundary.resourcesInline.llmModelPricing",
"accessBoundary.resources.passcode": "accessBoundary.resourcesInline.passcode",
"accessBoundary.resources.groups": "accessBoundary.resourcesInline.groups",
"accessBoundary.resources.serviceAccounts": "accessBoundary.resourcesInline.serviceAccounts",
"accessBoundary.resources.cipherKeys": "accessBoundary.resourcesInline.cipherKeys",
"accessBoundary.resources.regexPatterns": "accessBoundary.resourcesInline.regexPatterns",
"accessBoundary.resources.ai": "accessBoundary.resourcesInline.ai",
"accessBoundary.resources.downtimes": "accessBoundary.resourcesInline.downtimes",
"accessBoundary.resources.fleetConfigs": "accessBoundary.resourcesInline.fleetConfigs",
"accessBoundary.resources.publicDashboards": "accessBoundary.resourcesInline.publicDashboards",
};
// App-shell and prefetch reads the user never asked for, matched on the path after `/api`; their 403s say nothing about the page on screen.
const BACKGROUND_PATHS: RegExp[] = [
/^\/organizations$/,
/^\/auth\/refresh_token$/,
/^\/license(\/refresh)?$/,
/^\/[^/]+\/config$/,
/^\/[^/]+\/settings$/,
/^\/[^/]+\/settings\/v2\//,
/^\/[^/]+\/announcements(\/config)?$/,
/^\/[^/]+\/alerts\/deduplication\/semantic-groups$/,
];
// The role editor's own column labels, so a message names the permission an admin will look for.
const PERMISSION_KEYS: Record<AccessPermission, I18nKey> = {
list: "iam.list",
get: "iam.get",
create: "iam.create",
update: "iam.update",
delete: "iam.delete",
};
// The verb a denial's title uses: List and Get both read as "view".
const DENIED_TITLE_KEYS: Record<AccessPermission, I18nKey> = {
list: "accessBoundary.denied.view",
get: "accessBoundary.denied.view",
create: "accessBoundary.denied.create",
update: "accessBoundary.denied.edit",
delete: "accessBoundary.denied.delete",
};
// POST endpoints that only read data; the server checks them as List, not Create.
const READ_POST_ACTIONS = new Set([
"_search",
"_search_partition",
"_search_partition_multi",
"_search_multi",
"_search_history",
"_values",
"_around",
"_around_multi",
"_correlate",
]);
// A POST that toggles an existing item is checked against Update on the server, not Create.
const UPDATE_POST_ACTIONS = new Set(["enable", "disable"]);
const PERMISSION_ORDER: AccessPermission[] = ["list", "get", "create", "update", "delete"];
/** Resources a collapsed list names before it folds the rest into "N more". */
export const ACCESS_LIST_LIMIT = 4;
const MEMORY_PREFIX = "o2.access.memory.";
const UNNAMED_RESOURCE = "_unnamed";
const ACTIVATION_WINDOW_MS = 5000;
const ACTIVATION_HISTORY = 5;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// KSUID, ULID and similar generated ids: a long alphanumeric run that contains a digit.
const GENERATED_ID_RE = /^(?=.*\d)[0-9A-Za-z]{20,}$/;
// A confirm dialog or menu is already closing when its button's request answers, so its controls are about to leave the page.
const CLOSING_OVERLAY =
'[role="dialog"][data-state="closed"],[role="alertdialog"][data-state="closed"],[role="menu"][data-state="closed"]';
/** Provided by the outermost boundary so nested ones do not render a second action popover. */
export const accessBoundaryKey: InjectionKey<true> = Symbol("accessBoundary");
const routerRef = shallowRef<Router | null>(null);
let shellStore: AccessShellStore | null = null;
let activations: Activation[] = [];
let listenersInstalled = false;
let anchorObserver: MutationObserver | null = null;
// Keyed by org; the reactive read tracks the org's entry, a cache fill goes through toRaw so a computed never writes what it reads.
const memoryByOrg = shallowReactive(new Map<string, OrgAccessMemory>());
// Request lines for "Copy details"; memory only, the next 403 refills them.
const deniedRequests = new Map<string, DeniedRequest[]>();
const actionNotice = shallowRef<AccessActionNotice | null>(null);
// Each mounted boundary's index in the current route's matched records; -1 when it renders none of them.
const boundaryDepths = shallowReactive(new Set<Ref<number>>());
const currentRoute = computed(() => routerRef.value?.currentRoute.value ?? null);
const currentResource = computed(() =>
currentRoute.value ? routeResource(currentRoute.value) : undefined,
);
const currentEntry = computed(() =>
currentRoute.value && currentResource.value
? deniedEntry(currentRoute.value, currentResource.value)
: null,
);
const deniedPermission = computed<AccessPermission | null>(() =>
currentEntry.value === null ? null : (readMemory(currentOrg()).pages[currentEntry.value] ?? null),
);
const pageDenied = computed(() => deniedPermission.value !== null);
const sectionEntries = computed<AccessSectionEntry[]>(() =>
currentRoute.value ? (readMemory(currentOrg()).sections[routeKey(currentRoute.value)] ?? []) : [],
);
const pageDenial = computed<AccessDenial | null>(() => {
const resource = currentResource.value ? resourceForKey(currentResource.value) : null;
return resource && deniedPermission.value
? { resource, permission: deniedPermission.value, requests: [] }
: null;
});
const deepestBoundary = computed(() =>
Math.max(-1, ...[...boundaryDepths].map((depth) => depth.value)),
);
/** Route key a request is stamped with when it starts, so its response is judged against the page that asked. */
export function currentAccessRouteKey(): string | undefined {
return currentRoute.value ? accessStamp(routeKey(currentRoute.value)) : undefined;
}
/** Classifies one 403 and records it as page-denied, a section entry or an action notice. */
export function recordForbidden({ url, method, routeKey: startedOn }: AccessRequest): void {
if (isBackground(url)) return;
const route = currentRoute.value;
if (!route) return;
const key = routeKey(route);
// A response from a page the user has already left says nothing about the page now on screen.
if (startedOn !== undefined && startedOn !== accessStamp(key)) return;
const verb = (method ?? "get").toUpperCase();
const permission = accessPermission(verb, url);
const isRead = permission === "list" || permission === "get";
// An unnamed read is mostly a side fetch the user never asked for; an unnamed write is their own action and must not vanish.
const resource = extractResourceInfo(url) ?? (isRead ? null : unnamedResource(url));
if (!resource) {
if (import.meta.env.DEV) console.debug("[access] 403 on an unnamed resource:", verb, url);
return;
}
const denied: DeniedRequest = { permission, request: `${verb} ${resource.path}` };
if (!isRead) {
const [el, ...fallbacks] = anchorCandidates();
if (el) {
showAction({ el, permission, method: verb, resource, routeKey: key, fallbacks });
return;
}
}
const pageResource = routeResource(route);
if (pageResource && resource.key === pageResource && isPageList(permission, url)) {
markPageDenied(deniedEntry(route, pageResource), denied);
return;
}
addSectionEntry(key, resource, denied);
}
/** Lifts a page lock or a section entry once the same permission on that resource succeeds on the same route. */
export function noteAccessSuccess({
url,
method,
routeKey: startedOn,
}: Partial<AccessRequest>): void {
// Two cached reads per response; parsing happens only while the route shows a denial.
if (!url || (!pageDenied.value && sectionEntries.value.length === 0)) return;
const route = currentRoute.value;
if (!route || (startedOn !== undefined && startedOn !== accessStamp(routeKey(route)))) return;
const verb = (method ?? "get").toUpperCase();
const permission = accessPermission(verb, url);
const info =
extractResourceInfo(url) ??
(permission === "list" || permission === "get" ? null : unnamedResource(url));
if (!info) return;
const entry = currentEntry.value;
const org = currentOrg();
if (
entry &&
deniedPermission.value === permission &&
info.key === currentResource.value &&
isPageList(permission, url)
) {
writeMemory(org, { ...readMemory(org), pages: omitKey(readMemory(org).pages, entry) });
deniedRequests.delete(entry);
}
const key = routeKey(route);
const entries = sectionEntries.value;
// Only the request that succeeded is cleared, so a readable dashboard does not erase another one's denial.
const request = `${verb} ${info.path}`;
let changed = false;
const kept = entries.flatMap((e) => {
if (entryId(e) !== info.key || e.permission !== permission || !e.requests.includes(request))
return [e];
changed = true;
const requests = e.requests.filter((r) => r !== request);
return requests.length ? [{ ...e, requests }] : [];
});
if (changed) writeSections(org, key, kept);
}
/** Forgets every denial, for an org switch or a sign-out. */
export function resetAccess(): void {
dismissAction();
activations = [];
deniedRequests.clear();
memoryByOrg.clear();
try {
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (key?.startsWith(MEMORY_PREFIX)) sessionStorage.removeItem(key);
}
} catch {
// Storage can be unavailable (privacy mode); memory is already cleared.
}
}
/** Counts a mounted boundary; the deepest one for the current route renders the section notice. */
export function registerAccessBoundary(depth: Ref<number>): () => void {
boundaryDepths.add(depth);
return () => {
boundaryDepths.delete(depth);
};
}
/** Denials sorted by resource label, then in the role editor's permission order. */
export function sortDenials(denials: AccessDenial[], t: TranslateFn): AccessDenial[] {
return [...denials].sort(
(a, b) =>
String(resourceLabel(a.resource, t)).localeCompare(String(resourceLabel(b.resource, t))) ||
PERMISSION_ORDER.indexOf(a.permission) - PERMISSION_ORDER.indexOf(b.permission),
);
}
/** Denials grouped by permission in the role editor's order, each resource once per group with its requests merged. */
export function groupDenials(
denials: AccessDenial[],
): { permission: AccessPermission; denials: AccessDenial[] }[] {
return PERMISSION_ORDER.flatMap((permission) => {
const byResource = new Map<string, AccessDenial>();
for (const d of denials) {
if (d.permission !== permission) continue;
const seen = byResource.get(entryId(d));
byResource.set(
entryId(d),
seen ? { ...seen, requests: [...new Set([...seen.requests, ...d.requests])] } : d,
);
}
return byResource.size ? [{ permission, denials: [...byResource.values()] }] : [];
});
}
/** Title and permission groups for any set of denials; every 403 surface words itself through this. */
export function accessDeniedMessage(denials: AccessDenial[], t: TranslateFn): AccessDeniedMessage {
const grouped = groupDenials(denials);
const resourceCount = new Set(denials.map(entryId)).size;
const first = grouped[0]?.denials[0];
const title =
resourceCount === 1 && first
? t(DENIED_TITLE_KEYS[first.permission], { resource: resourceInlineLabel(first.resource, t) })
: t("accessBoundary.denied.parts", resourceCount);
const groups = grouped.map((g) => ({
permission: g.permission,
label: permissionLabel(g.permission, t),
resources: g.denials.map((d) => resourceLabel(d.resource, t)),
}));
return { title, groups, resourceCount };
}
/** The full "missing permission" sentence for one group, naming every resource. */
export function accessReason(group: AccessDeniedGroup, t: TranslateFn): I18nText {
return t("accessBoundary.denied.missing", {
permission: group.label,
resource: listFormat().format(group.resources),
});
}
/** A group's resources as list parts, folded after {@link ACCESS_LIST_LIMIT} into an "N more" part unless expanded. */
export function accessListParts(
resources: I18nText[],
expanded: boolean,
t: TranslateFn,
): AccessListPart[] {
const folds = !expanded && resources.length > ACCESS_LIST_LIMIT;
const items: string[] = folds
? [
...resources.slice(0, ACCESS_LIST_LIMIT),
t("accessBoundary.denied.more", resources.length - ACCESS_LIST_LIMIT),
]
: resources;
const parts = listFormat().formatToParts(items);
const last = parts.map((p) => p.type).lastIndexOf("element");
return parts.map((p, i) => ({ text: raw(p.value), more: folds && i === last }));
}
/** Copy-details text shared by every 403 surface: who and where, then one line per permission group. */
export function accessDetails(denials: AccessDenial[]): string {
return [
...identityLines(),
...groupDenials(denials).map(({ permission, denials: inGroup }) => {
const requests = inGroup.flatMap((d) => d.requests);
const keys = inGroup.map((d) => d.resource.key).join(", ");
return `denied ${permission}: ${keys}${requests.length ? ` (${requests.join(", ")})` : ""}`;
}),
].join("\n");
}
/** A denial a view detects itself, named by its IAM resource key, for its own lock or message. */
export function viewDenial(
key: string,
permission: AccessPermission,
requests: string[] = [],
): AccessDenial {
const resource = resourceForKey(key) ?? { key, labelKey: IN_PAGE_NAME_KEYS[key], path: "" };
return { resource, permission, requests };
}
/** `METHOD path` of each failed HTTP request among `errors`, as Copy details lists them. */
export function failedRequests(...errors: unknown[]): string[] {
return errors.flatMap((err) => {
const config = (err as { config?: { method?: string; url?: string } } | null)?.config;
if (!config?.url) return [];
try {
const path = new URL(config.url, "http://localhost").pathname;
return [`${(config.method ?? "get").toUpperCase()} ${path}`];
} catch {
return [];
}
});
}
/** Maps a request URL to the resource key and label the access UI shows; null when the resource has no friendly name. */
export function extractResourceInfo(rawUrl: string): AccessResource | null {
let parsed: URL;
try {
parsed = new URL(rawUrl, "http://localhost");
} catch {
return null;
}
const located = locateResource(apiSegments(parsed.pathname));
if (!located) return null;
const { matched } = located;
const key = Object.hasOwn(RESOURCE_ALIASES, matched) ? RESOURCE_ALIASES[matched] : matched;
return resourceForKey(key, parsed.pathname);
}
/** IAM permission a request needed: reads are List or Get (an id after the resource), writes follow the method. */
export function accessPermission(method: string, rawUrl: string): AccessPermission {
const verb = method.toUpperCase();
let segments: string[] = [];
try {
segments = apiSegments(new URL(rawUrl, "http://localhost").pathname);
} catch {
// An unparseable URL reads as a collection.
}
if (verb === "POST") {
if (segments.some((s) => READ_POST_ACTIONS.has(s))) return "list";
return segments.some((s) => UPDATE_POST_ACTIONS.has(s)) ? "update" : "create";
}
if (verb === "PUT" || verb === "PATCH") return "update";
if (verb === "DELETE") return "delete";
const itemNamed = locateResource(segments)?.after.some((s) => !NON_RESOURCE_SEGMENTS.has(s));
return itemNamed || hasItemId(segments) ? "get" : "list";
}
/** Translated IAM permission name, as the role editor shows it. */
export function permissionLabel(permission: AccessPermission, t: TranslateFn): I18nText {
return t(PERMISSION_KEYS[permission]);
}
/** Translated label for a resource. */
export function resourceLabel(resource: AccessResource, t: TranslateFn): I18nText {
return t(resource.labelKey);
}
/** Label placed mid-sentence, as in "Your role can't delete {resource}." */
export function resourceInlineLabel(resource: AccessResource, t: TranslateFn): I18nText {
return t(INLINE_LABEL_KEYS[resource.labelKey] ?? resource.labelKey);
}
/** Binds the router and store once, and exposes the access state to the shell components. */
export function useAccessBoundary() {
bindAccess(useRouter(), useStore());
return {
pageDenied,
pageDenial,
pageRequests,
sectionEntries,
actionNotice,
deepestBoundary,
dismissAction,
};
}
function bindAccess(router: Router, store: AccessShellStore): void {
shellStore = store;
if (routerRef.value === router) return;
routerRef.value = router;
router.afterEach((to, from) => {
const leaving = routeKey(from);
if (leaving === routeKey(to)) return;
dismissAction();
});
if (!listenersInstalled && typeof document !== "undefined") {
listenersInstalled = true;
document.addEventListener("pointerdown", onActivation, true);
document.addEventListener("keydown", onActivation, true);
}
}
function onActivation(event: Event): void {
if (event instanceof KeyboardEvent && event.key !== "Enter" && event.key !== " ") return;
const at = Date.now();
const el =
event.target instanceof Element ? event.target.closest("button,[role=button],a") : null;
if (!el) return;
activations = [...activations.filter((a) => a.el !== el), { el, at }].slice(-ACTIVATION_HISTORY);
}
// Newest first: a confirm button that is closing gives way to the row control that opened its dialog.
function anchorCandidates(): Element[] {
const now = Date.now();
return activations
.filter((a) => now - a.at <= ACTIVATION_WINDOW_MS && isAnchorable(a.el))
.map((a) => a.el)
.reverse();
}
function isAnchorable(el: Element): boolean {
return el.isConnected && !el.closest(CLOSING_OVERLAY);
}
function showAction(notice: AccessActionNotice): void {
actionNotice.value = notice;
if (anchorObserver || typeof MutationObserver === "undefined") return;
anchorObserver = new MutationObserver(keepAnchored);
anchorObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-state"],
});
}
// The anchor can leave after the popover opens; move to the next control, or hand the denial to the section notice rather than drop it.
function keepAnchored(): void {
const notice = actionNotice.value;
if (!notice || isAnchorable(notice.el)) return;
const [el, ...fallbacks] = notice.fallbacks.filter(isAnchorable);
if (el) {
actionNotice.value = { ...notice, el, fallbacks };
return;
}
dismissAction();
if (currentRoute.value && notice.routeKey === routeKey(currentRoute.value))
addSectionEntry(notice.routeKey, notice.resource, {
permission: notice.permission,
request: `${notice.method} ${notice.resource.path}`,
});
}
function dismissAction(): void {
actionNotice.value = null;
anchorObserver?.disconnect();
anchorObserver = null;
}
function markPageDenied(entry: string, denied: DeniedRequest): void {
const records = deniedRequests.get(entry) ?? [];
if (!records.some((r) => r.request === denied.request))
deniedRequests.set(entry, [...records, denied]);
const org = currentOrg();
const memory = readMemory(org);
if (!memory.pages[entry])
writeMemory(org, { ...memory, pages: { ...memory.pages, [entry]: denied.permission } });
}
// One entry per (resource, permission): a denied List and a denied Delete on alerts are two things to grant.
function addSectionEntry(key: string, resource: AccessResource, denied: DeniedRequest): void {
const org = currentOrg();
const entries = readMemory(org).sections[key] ?? [];
const found = entries.find(
(e) => entryId(e) === resource.key && e.permission === denied.permission,
);
if (!found) {
writeSections(org, key, [
...entries,
{ resource, permission: denied.permission, requests: [denied.request] },
]);
return;
}
if (found.requests.includes(denied.request)) return;
writeSections(
org,
key,
entries.map((e) => (e === found ? { ...e, requests: [...e.requests, denied.request] } : e)),
);
}
function writeSections(org: string, key: string, entries: AccessSectionEntry[]): void {
const others = omitKey(readMemory(org).sections, key);
writeMemory(org, {
...readMemory(org),
sections: entries.length ? { ...others, [key]: entries } : others,
});
}
function readMemory(org: string): OrgAccessMemory {
const cached = memoryByOrg.get(org);
if (cached) return cached;
const memory: OrgAccessMemory = { pages: {}, sections: {} };
try {
const parsed: Partial<OrgAccessMemory> | null = JSON.parse(
sessionStorage.getItem(MEMORY_PREFIX + org) ?? "{}",
);
memory.pages = parsed?.pages ?? {};
memory.sections = namedSections(parsed?.sections ?? {});
} catch {
// Unreadable or unavailable storage starts the org with no remembered denials.
}
toRaw(memoryByOrg).set(org, memory);
return memory;
}
function writeMemory(org: string, memory: OrgAccessMemory): void {
memoryByOrg.set(org, memory);
try {
const empty = !Object.keys(memory.pages).length && !Object.keys(memory.sections).length;
if (empty) sessionStorage.removeItem(MEMORY_PREFIX + org);
else sessionStorage.setItem(MEMORY_PREFIX + org, JSON.stringify(memory));
} catch {
// Storage can be unavailable or full; the in-memory copy still drives this tab.
}
}
// A record written by an older build can hold resources with no friendly name, which must never render.
function namedSections(
sections: Record<string, AccessSectionEntry[]>,
): Record<string, AccessSectionEntry[]> {
const named = Object.entries(sections).map(
([key, entries]) => [key, entries.filter((e) => !!e.resource.labelKey)] as const,
);
return Object.fromEntries(named.filter(([, entries]) => entries.length > 0));
}
function entryId(entry: AccessSectionEntry): string {
return entry.resource.key;
}
function listFormat(): Intl.ListFormat {
return new Intl.ListFormat(getIntlLocale(), { type: "conjunction" });
}
function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([k]) => k !== key));
}
function pageRequests(): string[] {
const entry = currentEntry.value;
return entry ? (deniedRequests.get(entry) ?? []).map((r) => r.request) : [];
}
function identityLines(): string[] {
const org = shellStore?.state.selectedOrganization;
return [
`org: ${org?.label || org?.name || ""} (${org?.identifier ?? ""})`,
`user: ${shellStore?.state.userInfo?.email ?? ""}`,
`route: ${currentRoute.value?.fullPath ?? ""}`,
];
}
function resourceForKey(key: string, path = ""): AccessResource | null {
return Object.hasOwn(FRIENDLY_NAME_KEYS, key)
? { key, labelKey: FRIENDLY_NAME_KEYS[key], path }
: null;
}
// The segment after the optional version is the org (or a `_` system scope), never the resource; the last named word wins, so `functions/my_fn` is Functions.
function locateResource(segments: string[]): { matched: string; after: string[] } | null {
const rest = segments.slice((/^v\d+$/.test(segments[0] ?? "") ? 1 : 0) + 1);
for (let i = rest.length; i > 0; i--) {
const joined = rest
.slice(0, i)
.filter((s) => !isIdSegment(s))
.join("/");
const matched = isKnownResource(joined) ? joined : rest[i - 1];
if (isKnownResource(matched)) return { matched, after: rest.slice(i) };
}
return null;
}
// The page's own list is the bare collection; a failing sub-read such as `users/roles/all` leaves the page readable.
function isPageList(permission: AccessPermission, url: string): boolean {
if (permission !== "list") return false;
try {
return (
locateResource(apiSegments(new URL(url, "http://localhost").pathname))?.after.length === 0
);
} catch {
return false;
}
}
function unnamedResource(url: string): AccessResource {
let path = "";
try {
path = new URL(url, "http://localhost").pathname;
} catch {
// The request line in Copy details then omits the path.
}
return { key: UNNAMED_RESOURCE, labelKey: "accessBoundary.resourcesInline.unnamed", path };
}
function isKnownResource(path: string): boolean {
return Object.hasOwn(FRIENDLY_NAME_KEYS, path) || Object.hasOwn(RESOURCE_ALIASES, path);
}
function isBackground(url: string): boolean {
try {
const path = "/" + apiSegments(new URL(url, "http://localhost").pathname).join("/");
return BACKGROUND_PATHS.some((re) => re.test(path));
} catch {
return false;
}
}
function apiSegments(pathname: string): string[] {
const segments = pathname.split("/").filter(Boolean);
const apiAt = segments.indexOf("api");
return apiAt >= 0 ? segments.slice(apiAt + 1) : segments;
}
// The org segment is skipped: a generated org id would otherwise read as an item id.
function hasItemId(segments: string[]): boolean {
const rest = segments.slice((/^v\d+$/.test(segments[0] ?? "") ? 1 : 0) + 1);
const first = rest.findIndex((s) => !isIdSegment(s));
return first >= 0 && rest.slice(first + 1).some(isIdSegment);
}
function isIdSegment(s: string): boolean {
return (
s.includes("@") ||
s.includes("%40") ||
/^\d+$/.test(s) ||
UUID_RE.test(s) ||
GENERATED_ID_RE.test(s)
);
}
function accessStamp(key: string): string {
return `${currentOrg()}|${key}`;
}
function routeKey(route: { name?: unknown; path: string }): string {
return typeof route.name === "string" ? route.name : route.path;
}
function routeResource(route: RouteLocationNormalizedLoaded): string | undefined {
return route.matched[route.matched.length - 1]?.meta.accessResource;
}
function deniedEntry(route: RouteLocationNormalizedLoaded, resource: string): string {
return `${routeKey(route)}|${resource}`;
}
function currentOrg(): string {
return shellStore?.state.selectedOrganization?.identifier ?? "";
}
+4 -6
View File
@@ -173,14 +173,12 @@ describe("useFunctions", () => {
return returnValue;
});
it("wraps inner catch errors and re-throws as Error instances", async () => {
// The source uses a double try/catch; both convert to `new Error(e.message)`
vi.mocked(TransformService.list).mockRejectedValueOnce({
message: "Custom error object",
});
it("re-throws the original rejection so callers can read its HTTP status", async () => {
const denied = { message: "Forbidden", response: { status: 403 } };
vi.mocked(TransformService.list).mockRejectedValueOnce(denied);
const { getAllFunctions } = useFunctions();
await expect(getAllFunctions()).rejects.toThrow("Custom error object");
await expect(getAllFunctions()).rejects.toBe(denied);
});
});
});
+5 -9
View File
@@ -26,15 +26,11 @@ const useFunctions = () => {
* request while the entry is fresh, and concurrent callers share the promise.
*/
const getAllFunctions = async () => {
try {
const list = await queryClient.fetchQuery(
functionsQuery(store.state.selectedOrganization.identifier),
);
// Bridge for consumers still reading `organizationData.functions`.
store.dispatch("setFunctions", list);
} catch (e: any) {
throw new Error(e.message);
}
const list = await queryClient.fetchQuery(
functionsQuery(store.state.selectedOrganization.identifier),
);
// Bridge for consumers still reading `organizationData.functions`.
store.dispatch("setFunctions", list);
};
return { getAllFunctions };
@@ -111,6 +111,7 @@ export const useSearchBar = (t: TranslateFn) => {
});
return;
} catch (e) {
if ((e as { response?: { status?: number } })?.response?.status === 403) return;
showErrorNotification(t("toastMessages.useLogs.errorWhileFetchingFunctions"));
}
};
+5 -1
View File
@@ -24,6 +24,7 @@ import useSuggestions from "@/composables/useSuggestions";
import { parsePromQlQuery } from "@/utils/query/promQLUtils";
import { UNAUTHORIZED_MESSAGE_KEY, isAuthError } from "@/utils/authErrors";
import { aiCreditsNotice } from "@/utils/aiCredits";
import { accessDeniedMessage, accessReason, viewDenial } from "@/composables/useAccessStore";
import analytics from "@/services/product_analytics";
/**
@@ -496,7 +497,10 @@ export function useNLQuery(t: TranslateFn) {
refusalNotice.value = aiCreditsNotice(body, t);
streamingResponse.value = refusalNotice.value;
} else if (response.status === 403) {
streamingResponse.value = t(UNAUTHORIZED_MESSAGE_KEY);
streamingResponse.value = accessReason(
accessDeniedMessage([viewDenial("ai", "update")], t).groups[0],
t,
);
}
return null;
}
@@ -1,206 +0,0 @@
// 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 { toast, toastRecords, updateToast } from "@/lib/feedback/Toast/useToast";
import type { ToastDetail } from "@/lib/feedback/Toast/OToast.types";
import { copyToClipboard } from "@/utils/clipboard";
import { gt, raw, type I18nKey, type I18nText } from "@/types/i18n";
// Friendly name overrides. Keys, not text — this map is module scope, so it is
// translated at display time in extractResourceInfo().
const FRIENDLY_NAME_KEYS: Record<string, I18nKey> = {
dashboards: "toastMessages.composables.resources.dashboards",
alerts: "toastMessages.composables.resources.alerts",
streams: "toastMessages.composables.resources.streams",
functions: "toastMessages.composables.resources.functions",
savedviews: "toastMessages.composables.resources.savedviews",
users: "toastMessages.composables.resources.users",
roles: "toastMessages.composables.resources.roles",
organizations: "toastMessages.composables.resources.organizations",
pipelines: "toastMessages.composables.resources.pipelines",
destinations: "toastMessages.composables.resources.destinations",
templates: "toastMessages.composables.resources.templates",
reports: "toastMessages.composables.resources.reports",
// both paths expose the same org-wide ingestion token
passcode: "toastMessages.composables.resources.passcode",
"ingestion-tokens": "toastMessages.composables.resources.passcode",
"llm/models": "toastMessages.composables.resources.llmModelPricing",
"llm/models/built-in": "toastMessages.composables.resources.llmModelPricing",
"llm/models/refresh-built-in": "toastMessages.composables.resources.llmModelPricing",
};
// ── URL → {label, urlPath} extraction ───────────────────────────────────────
function extractResourceInfo(rawUrl: string): ToastDetail {
let label: I18nText = gt("toastMessages.composables.resourceFallback");
let urlPath = rawUrl;
try {
const parsed = new URL(rawUrl);
urlPath = parsed.pathname;
const segments = parsed.pathname.split("/").filter(Boolean);
// segments[0] is always "api"
let idx = 1;
// Skip optional version prefix (v1, v2, …)
if (/^v\d+$/.test(segments[idx] ?? "")) idx++;
const candidate = segments[idx];
if (candidate?.startsWith("_")) {
// Special namespace (_meta, etc.) — no org segment
const remaining = segments.slice(idx + 1);
if (remaining.length > 0) {
label = raw(capitalize(remaining[remaining.length - 1]));
}
} else if (candidate) {
// Skip org identifier
idx++;
const resourcePath = segments.slice(idx);
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isId = (s: string) => s.includes("@") || /^\d+$/.test(s) || uuidRe.test(s);
const resourceNames = resourcePath.filter((s) => !isId(s));
if (resourceNames.length > 0) {
const joined = resourceNames.join("/");
const friendlyKey =
FRIENDLY_NAME_KEYS[joined] ?? FRIENDLY_NAME_KEYS[resourceNames[resourceNames.length - 1]];
// Unknown resources fall back to the capitalised URL segment — a path
// token, not prose, so it is deliberately left untranslated.
label = friendlyKey
? gt(friendlyKey)
: raw(capitalize(resourceNames[resourceNames.length - 1]));
}
}
} catch {
// ignore URL parse errors — fall back to defaults
}
return { label, url: urlPath };
}
function capitalize(s: string): string {
if (!s) return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
// Resolved per call, not at module scope, so a locale change is reflected.
function buildTitle() {
return gt("toastMessages.composables.accessRequiredTitle");
}
function buildMessage() {
return gt("toastMessages.composables.accessRequiredMessage");
}
// ── Module-level singleton state ─────────────────────────────────────────────
let activeToastId: string | null = null;
let accumulatedErrors: ToastDetail[] = [];
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function onToastDismissed(): void {
activeToastId = null;
accumulatedErrors = [];
}
function copyDetails(): void {
const text = accumulatedErrors.map((e) => `${e.label}: ${e.url}`).join("\n");
// silent: true — feedback is shown on the button itself via successLabel,
// not as a separate toast notification.
copyToClipboard(text, gt, { silent: true });
}
function flushGroupedToast(): void {
const details = [...accumulatedErrors];
const title = buildTitle();
const message = buildMessage();
// If a toast is still visible, update it in-place instead of stacking
if (activeToastId !== null) {
const record = toastRecords.find((r) => r.id === activeToastId && r.open);
if (record) {
updateToast(activeToastId, {
title,
message,
details,
titleCount: details.length,
});
return;
}
// Toast was dismissed before the debounce fired
activeToastId = null;
}
// Show a fresh grouped notification.
// No explicit timeout — the default error timeout (30 s) applies.
toast({
variant: "error",
title,
message,
titleCount: details.length,
details,
action: {
label: gt("toastMessages.composables.copyDetails"),
handler: copyDetails,
successLabel: gt("toastMessages.composables.copied"),
},
onDismiss: onToastDismissed,
});
// Capture the record ID of the toast we just pushed
activeToastId = toastRecords[toastRecords.length - 1]?.id ?? null;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Add a single 403 failure to the grouped notification.
* Concurrent calls within 300 ms are batched into one toast; subsequent calls
* update the existing notification if it is still visible.
*/
export function addUnauthorizedError(responseUrl: string): void {
const detail = extractResourceInfo(responseUrl);
// Avoid showing the same resource twice in one group
const alreadyListed = accumulatedErrors.some((e) => e.url === detail.url);
if (!alreadyListed) {
accumulatedErrors.push(detail);
}
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
debounceTimer = null;
flushGroupedToast();
}, 300);
}
/**
* Clear the accumulated error list and forget the active toast ID.
* Call this on navigation so a new page gets a fresh notification.
*/
export function resetUnauthorizedErrors(): void {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
accumulatedErrors = [];
activeToastId = null;
}
@@ -448,7 +448,6 @@ async function refresh(force = true) {
// refresh control. A plain mount passes false and keeps the cached rows.
if (force) await datasetsList.refetch();
} catch {
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({ variant: "error", message: t("aiObservability.datasets.loadError") });
}
@@ -531,7 +531,6 @@ async function refresh(force = true) {
// open re-fetches.
optionsLoaded.value = false;
} catch {
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({ variant: "error", message: t("aiObservability.queues.loadError") });
}
@@ -488,7 +488,6 @@ async function refresh(force = false) {
lastUpdatedAt.value = queryClient.getQueryState(opts.queryKey)?.dataUpdatedAt ?? Date.now();
} catch (error: any) {
forbidden.value = error?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
variant: "error",
+14 -7
View File
@@ -179,13 +179,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<!-- Enter-only fade; the body's slide lives in OPageLayout so the header stays put. -->
<router-view v-slot="{ Component }">
<keep-alive :include="KEPT_ALIVE_VIEWS">
<component
:is="Component"
:class="[ENTER_FADE, 'h-full']"
@sendToAiChat="sendToAiChat"
/>
</keep-alive>
<OAccessBoundary>
<keep-alive :include="KEPT_ALIVE_VIEWS">
<component
:is="Component"
:class="[ENTER_FADE, 'h-full']"
@sendToAiChat="sendToAiChat"
/>
</keep-alive>
</OAccessBoundary>
</router-view>
</div>
<div
@@ -276,6 +278,8 @@ import { ENTER_FADE } from "@/lib/styles/motion";
import type { NavItem } from "@/lib/core/Navbar/ONavbar.types";
import { checkFeatureAccess, buildFeatureGateContext } from "@/utils/enterpriseFeatures";
import AppHeader from "../components/Header.vue";
import OAccessBoundary from "@/components/access/OAccessBoundary.vue";
import { resetAccess } from "@/composables/useAccessStore";
import { raw, useI18nTyped } from "@/types/i18n";
import {
useLocalOrganization,
@@ -372,6 +376,7 @@ export default defineComponent({
PasswordExpiryBanner,
LicenseSearchBanner,
"keep-alive": KeepAlive,
OAccessBoundary,
ONavbar,
"router-view": RouterView,
SlackIcon,
@@ -1829,6 +1834,8 @@ export default defineComponent({
if (previous) purgeOrgQueries(previous, _next);
// Clear notifications from the previous org — they no longer apply.
dismissAll();
// The first org resolved on load is not a switch, so the session's page-denied memory survives a reload.
if (previous) resetAccess();
this.store.dispatch("setOrganizationPasscode", "");
this.store.dispatch("resetOrganizationData", {});
+8 -1
View File
@@ -52,6 +52,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
border-b on a wrapper — that adds a second 1px line and makes the header
61px on some pages and 60px on others. -->
<header
ref="headerEl"
class="app-page-header px-page-edge border-border-default shrink-0 border-b"
:data-drawer-anchor="ownsSidebarTrigger ? 'page-layout-sidebar' : undefined"
:class="[tabsBelow ? 'flex flex-col' : ROW_CLASS]"
@@ -170,9 +171,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- max-w-full + shrink let the group wrap within its row (shrink-0 alone runs it off-screen); phone date pickers go icon-only to share the title row. -->
<div
v-if="hasActions() || hasActionsOverflow()"
v-if="statusComponent || hasActions() || hasActionsOverflow()"
class="flex shrink-0 items-center gap-2 max-lg:ms-auto max-md:max-w-full max-md:shrink max-md:flex-wrap max-md:justify-end @max-4xl/page:ms-auto @max-4xl/page:max-w-full @max-4xl/page:shrink @max-4xl/page:flex-wrap @max-4xl/page:justify-end max-md:[&_.date-time-label]:hidden"
>
<component :is="statusComponent" v-if="statusComponent" />
<slot v-if="hasActionsOverflow() && !isMobile && overflowFirst" name="actions-overflow" />
<slot name="actions" />
@@ -220,6 +222,7 @@ import {
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
useSlots,
} from "vue";
import { useRouter } from "vue-router";
@@ -230,6 +233,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import useBreakpoint from "@/composables/useBreakpoint";
import { PAGE_LAYOUT_SIDEBAR_KEY } from "@/lib/core/PageLayout/pageLayoutSidebar";
import { usePageHeaderStatusHost } from "@/lib/core/PageHeader/pageHeaderStatus";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
interface BackTarget {
@@ -299,6 +303,9 @@ const ownsSidebarTrigger = computed(
() => isMobile.value && layoutSidebar?.owner.value === sidebarClaim,
);
const headerEl = ref<HTMLElement | null>(null);
const statusComponent = usePageHeaderStatusHost(headerEl);
// A slot passed with an always-present <template> but a falsy inner v-if still
// yields a comment placeholder node — so checking `$slots.x` is truthy even
// when there's nothing to show. Detect *real* content instead, so we never
@@ -0,0 +1,35 @@
<!-- 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/>.
-->
<!-- Hosts the status an ancestor provides (see pageHeaderStatus.ts); place once at the start of a toolbar's right-aligned group. -->
<template>
<span ref="hostEl" class="contents" data-test="o-page-header-status">
<component :is="status" v-if="status" />
</span>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { usePageHeaderStatusHost } from "@/lib/core/PageHeader/pageHeaderStatus";
const props = defineProps<{
/** The app top bar's host: shows only a status that no page host took. */
fallback?: boolean;
}>();
const hostEl = ref<HTMLElement | null>(null);
const status = usePageHeaderStatusHost(hostEl, props.fallback ? "fallback" : "page");
</script>
@@ -0,0 +1,148 @@
// 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 {
computed,
inject,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
provide,
ref,
shallowReactive,
watch,
type Component,
type ComputedRef,
type InjectionKey,
type Ref,
} from "vue";
/** A page host sits in a page header or toolbar; the fallback host sits in the app top bar and shows only what no page host took. */
export type PageHeaderStatusPriority = "page" | "fallback";
/** One provider's status and the page hosts competing to show it. */
export interface PageHeaderStatusContext {
/** What this provider's owning page host renders: its own status, or a nested provider's that found no host. */
display: ComputedRef<Component | null>;
/** The display once settled with no page host, for an ancestor provider or the fallback host. */
unhosted: ComputedRef<Component | null>;
owner: ComputedRef<symbol | null>;
claim: (id: symbol, el: Element) => void;
release: (id: symbol) => void;
adopt: (child: PageHeaderStatusContext) => () => void;
}
export const PAGE_HEADER_STATUS_KEY: InjectionKey<PageHeaderStatusContext> =
Symbol("pageHeaderStatus");
const OVERLAY_SELECTOR = '[role="dialog"], [role="alertdialog"]';
// A page's toolbar can arrive with a lazily loaded child, so a status waits this long before it falls back to an outer host.
const SETTLE_MS = 250;
const rootContexts = shallowReactive(new Set<PageHeaderStatusContext>());
/** Offers `component` to the first page host below; `resetKey` names the screen, so a change restarts the wait before falling back. */
export function providePageHeaderStatus(
component: () => Component | null,
resetKey: () => unknown,
): PageHeaderStatusContext {
const parent = inject(PAGE_HEADER_STATUS_KEY, null);
const claims = shallowReactive(new Map<symbol, Element>());
const children = shallowReactive(new Set<PageHeaderStatusContext>());
const owner = computed(() => firstInDocument(claims));
const display = computed(() => component() ?? firstUnhosted(children));
const settled = ref(false);
let settleTimer: ReturnType<typeof setTimeout> | undefined;
watch(
[resetKey, display],
() => {
settled.value = false;
clearTimeout(settleTimer);
settleTimer = setTimeout(() => (settled.value = true), SETTLE_MS);
},
{ immediate: true },
);
const context: PageHeaderStatusContext = {
display,
unhosted: computed(() => (settled.value && owner.value === null ? display.value : null)),
owner,
claim: (id, el) => {
claims.set(id, el);
},
release: (id) => {
claims.delete(id);
},
adopt: (child) => {
children.add(child);
return () => {
children.delete(child);
};
},
};
const detach = parent ? parent.adopt(context) : adoptRoot(context);
provide(PAGE_HEADER_STATUS_KEY, context);
onBeforeUnmount(() => {
detach();
clearTimeout(settleTimer);
});
return context;
}
/** Claims a place for the provided status at `el` and returns what this host should render now. */
export function usePageHeaderStatusHost(
el: Ref<Element | null>,
priority: PageHeaderStatusPriority = "page",
): ComputedRef<Component | null> {
if (priority === "fallback") return computed(() => firstUnhosted(rootContexts));
const context = inject(PAGE_HEADER_STATUS_KEY, null);
const id = Symbol("page-header-status-host");
// Claims compare DOM position, so they are made once the host is in the document and dropped while kept-alive and hidden.
const claim = () => {
if (context && el.value) context.claim(id, el.value);
};
const release = () => context?.release(id);
onMounted(claim);
onActivated(claim);
onBeforeUnmount(release);
onDeactivated(release);
return computed(() => (context?.owner.value === id ? context.display.value : null));
}
function adoptRoot(context: PageHeaderStatusContext): () => void {
rootContexts.add(context);
return () => {
rootContexts.delete(context);
};
}
function firstUnhosted(contexts: Set<PageHeaderStatusContext>): Component | null {
for (const context of contexts) {
if (context.unhosted.value) return context.unhosted.value;
}
return null;
}
// The first host in document order that is not inside a dialog or drawer.
function firstInDocument(claims: Map<symbol, Element>): symbol | null {
let first: [symbol, Element] | null = null;
for (const [id, el] of claims) {
if (!el.isConnected || el.closest(OVERLAY_SELECTOR)) continue;
if (!first || first[1].compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
first = [id, el];
}
}
return first?.[0] ?? null;
}
+39 -7
View File
@@ -9,8 +9,8 @@
Shares ODropdown's overlay coordination so descendant overlays (OSelect /
OCombobox / nested dropdowns) opening INSIDE the popover never dismiss it.
Props: open (v-model) | side | align | sideOffset | hideWhenDetached | modal | ariaLabel | contentClass | collisionPadding
Slots: trigger | default
Props: open (v-model) | anchor | side | align | flipAlign | sideOffset | arrow | hideWhenDetached | modal | ariaLabel | contentClass | collisionPadding
Slots: trigger (unused when `anchor` is set) | default | arrow (paths in a 12x6 viewBox)
Emits: update:open | open-auto-focus
-->
<script lang="ts">
@@ -40,8 +40,15 @@ if (typeof document !== "undefined" && !(globalThis as any)[_oPopKey]) {
</script>
<script setup lang="ts">
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from "reka-ui";
import { inject, onBeforeUnmount, provide, ref, watch, type Ref } from "vue";
import {
PopoverRoot,
PopoverTrigger,
PopoverAnchor,
PopoverArrow,
PopoverPortal,
PopoverContent,
} from "reka-ui";
import { inject, nextTick, onBeforeUnmount, provide, ref, watch, type Ref } from "vue";
import useBreakpoint from "@/composables/useBreakpoint";
import {
O_DROPDOWN_NESTED_KEY,
@@ -55,11 +62,17 @@ const { lgUp } = useBreakpoint();
const props = withDefaults(
defineProps<{
open?: boolean;
/** Positions against this element instead of a trigger, for a popover opened by code rather than a click on its own control. */
anchor?: Element | null;
/** Trap focus + block outside pointer events. Popovers are non-modal by default. */
modal?: boolean;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
/** End alignment flips to start when the content would cross the viewport's left edge, instead of sliding off the anchor's edge. */
flipAlign?: boolean;
sideOffset?: number;
/** Draws a caret on the edge facing the anchor, pointing at the anchor's centre. */
arrow?: boolean;
/** Hide the content when the trigger scrolls out of view (Floating UI). */
hideWhenDetached?: boolean;
ariaLabel?: I18nText;
@@ -95,6 +108,18 @@ const emit = defineEmits<{
// responsive in both controlled and uncontrolled usage.
const internalOpen = ref(props.open ?? false);
const triggerRef = ref<{ $el?: Node } | null>(null);
const contentRef = ref<{ $el?: Node } | null>(null);
const placedAlign = ref(props.align);
watch([internalOpen, () => props.align], async ([open]) => {
placedAlign.value = props.align;
if (!open || !props.flipAlign || props.align !== "end") return;
await nextTick();
const anchorEl = props.anchor ?? triggerRef.value?.$el;
const contentEl = contentRef.value?.$el;
if (!(anchorEl instanceof Element) || !(contentEl instanceof HTMLElement)) return;
if (anchorEl.getBoundingClientRect().right < contentEl.offsetWidth) placedAlign.value = "start";
});
watch(
() => props.open,
@@ -205,7 +230,7 @@ function handleViewportScroll(event: Event) {
target.closest?.("[data-o-popover-content], [role='menu'], [role='listbox']")
)
return;
const triggerEl = triggerRef.value?.$el as Node | undefined;
const triggerEl = (props.anchor ?? triggerRef.value?.$el) as Node | null | undefined;
if (triggerEl && target.contains(triggerEl)) handleOpenChange(false);
}
watch(internalOpen, (open) => {
@@ -228,15 +253,17 @@ onBeforeUnmount(() => {
<template>
<PopoverRoot :open="internalOpen" :modal="modal" @update:open="handleOpenChange">
<PopoverTrigger ref="triggerRef" as-child>
<PopoverAnchor v-if="anchor" :reference="anchor" class="hidden" />
<PopoverTrigger v-else ref="triggerRef" as-child>
<slot name="trigger" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
ref="contentRef"
data-o-popover-content
:side="side"
:align="align"
:align="placedAlign"
:side-offset="sideOffset"
:hide-when-detached="hideWhenDetached"
:collision-padding="collisionPadding ?? (lgUp ? 0 : 8)"
@@ -259,6 +286,11 @@ onBeforeUnmount(() => {
]"
>
<slot />
<PopoverArrow v-if="arrow" :width="14" :height="7" class="-translate-y-px overflow-visible">
<slot name="arrow">
<path class="fill-dropdown-bg" d="M0 0L6 6L12 0Z" />
</slot>
</PopoverArrow>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "شغّل الاستعلام نفسه مرة أخرى"
}
},
"accessBoundary": {
"copyDetails": "نسخ التفاصيل",
"copied": "تم النسخ!",
"resources": {
"dashboards": "لوحات المعلومات",
"alerts": "تنبيهات",
"streams": "التدفقات",
"functions": "الدوال",
"savedviews": "المشاهدات المحفوظة",
"users": "المستخدمون",
"roles": "الأدوار",
"organizations": "المؤسسات",
"pipelines": "مسارات المعالجة",
"destinations": "الوجهات",
"templates": "القوالب",
"reports": "التقارير",
"llmModelPricing": "تسعير نماذج LLM",
"passcode": "رمز الاستيعاب",
"ai": "الذكاء الاصطناعي",
"downtimes": "فترات الصيانة",
"fleetConfigs": "إعدادات الأسطول",
"publicDashboards": "لوحات المعلومات العامة",
"groups": "المجموعات",
"serviceAccounts": "حسابات الخدمة",
"cipherKeys": "مفاتيح التشفير",
"regexPatterns": "أنماط Regex"
},
"resourcesInline": {
"dashboards": "لوحات المعلومات",
"alerts": "تنبيهات",
"streams": "التدفقات",
"functions": "الدوال",
"savedviews": "المشاهدات المحفوظة",
"users": "المستخدمون",
"roles": "الأدوار",
"organizations": "المؤسسات",
"pipelines": "مسارات المعالجة",
"destinations": "الوجهات",
"templates": "القوالب",
"reports": "التقارير",
"llmModelPricing": "تسعير نماذج LLM",
"passcode": "رمز الاستيعاب",
"ai": "الذكاء الاصطناعي",
"downtimes": "فترات الصيانة",
"fleetConfigs": "إعدادات الأسطول",
"publicDashboards": "لوحات المعلومات العامة",
"groups": "المجموعات",
"serviceAccounts": "حسابات الخدمة",
"cipherKeys": "مفاتيح التشفير",
"regexPatterns": "أنماط Regex",
"unnamed": "هذا المورد"
},
"denied": {
"view": "لا يمكنك عرض {resource}",
"create": "لا يمكنك إنشاء {resource}",
"edit": "لا يمكنك تعديل {resource}",
"delete": "لا يمكنك حذف {resource}",
"missing": "يفتقد دورك إلى صلاحية {permission} على {resource}.",
"parts": "لا يمكنك عرض {count} جزء من هذه الصفحة | لا يمكنك عرض أجزاء من هذه الصفحة ({count})",
"missingIntro": "يفتقد دورك إلى هذه الصلاحيات:",
"more": "{count} أخرى",
"showLess": "عرض أقل"
},
"chip": {
"label": "تم رفض الوصول",
"ariaLabel": "تم رفض الوصول: {title}",
"popoverLabel": "الأجزاء المخفية من هذه الصفحة"
}
},
"common": {
"dismiss": "تجاهل",
"exitFullscreen": "إنهاء ملء الشاشة",
@@ -6978,7 +7047,6 @@
"publishFailed": "تعذّر النشر",
"revokedToast": "تم إبطال الرابط العام وحذفه",
"revokeFailed": "تعذّر إبطال الرابط وحذفه",
"noPublishPermission": "ليس لديك إذن لعرض الروابط العامة لهذه اللوحة. اطلب من المسؤول إذن لوحات المعلومات العامة.",
"linkCopied": "تم نسخ الرابط",
"copyLink": "نسخ الرابط",
"expired": "انتهت صلاحية هذا الرابط. اطلب من الشخص الذي شاركه تمديده."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "راجع وتطبيق الفلاتر على Traces",
"spansLabel": "الامتدادات",
"errorsLabel": "أخطاء",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "امتدادات الخطأ، الأعمق أولًا"
},
"previousMatch": "التطابق السابق",
"nextMatch": "التطابق التالي",
"searchInSpans": "البحث في الامتدادات",
@@ -15440,6 +15512,22 @@
"roleSystem": "النظام",
"roleTool": "الأداة"
},
"results": {
"tracesWithMatchingSpan": "{count} تتبع يحتوي على امتداد مطابق | {count} تتبعات تحتوي على امتداد مطابق",
"spansColumnTooltip": "إجمالي الامتدادات في التتبع",
"fieldValueCountsCaption": "الأعداد تمثل امتدادات تحمل هذه القيمة، في النطاق الحالي",
"errorColumn": "خطأ",
"eventsColumn": "الأحداث",
"eventExceptions": "{count} استثناء | {count} استثناءات",
"eventLogs": "{count} سجل | {count} سجلات",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "الأحداث على {operation}",
"eventsHoverOrder": "{count}، حسب الترتيب الزمني",
"eventKindException": "استثناء",
"eventKindLog": "سجل",
"eventOffset": "+{offset}",
"moreEvents": "+{count} أخرى"
},
"tableColumns": {
"service": "الخدمة",
"operationName": "اسم العملية",
@@ -22005,27 +22093,6 @@
"refresh": "تحديث",
"invalidSqlSyntax": "تركيب SQL غير صالح",
"pleaseWaitWhileLoadingStreams": "يرجى الانتظار أثناء تحميل التدفقات...",
"copied": "تم النسخ!",
"copyDetails": "نسخ التفاصيل",
"accessRequiredTitle": "الوصول مطلوب",
"accessRequiredMessage": "بعض الأقسام لم تتمكن من التحميل لأنك لا تمتلك الأذونات المطلوبة. اتصل بمسؤولك إذا كنت تعتقد أنه يجب أن يكون لديك الوصول.",
"resourceFallback": "المصدر",
"resources": {
"dashboards": "لوحات المعلومات",
"alerts": "تنبيهات",
"streams": "التدفقات",
"functions": "الدوال",
"savedviews": "المشاهدات المحفوظة",
"users": "المستخدمون",
"roles": "الأدوار",
"organizations": "المؤسسات",
"pipelines": "مسارات المعالجة",
"destinations": "الوجهات",
"templates": "القوالب",
"reports": "التقارير",
"llmModelPricing": "تسعير نماذج LLM",
"passcode": "رمز الاستيعاب"
},
"noPermissionToChangeFavorites": "ليس لديك إذن لتغيير المفضلات",
"couldNotUpdateFavoriteDashboards": "تعذر تحديث لوحات المعلومات المفضلة"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Dieselbe Abfrage erneut ausführen"
}
},
"accessBoundary": {
"copyDetails": "Details kopieren",
"copied": "Kopiert!",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alarme",
"streams": "Streams",
"functions": "Funktionen",
"savedviews": "Gespeicherte Ansichten",
"users": "Benutzer",
"roles": "Rollen",
"organizations": "Organisationen",
"pipelines": "Pipelines",
"destinations": "Ziele",
"templates": "Vorlagen",
"reports": "Berichte",
"llmModelPricing": "LLM-Modellpreise",
"passcode": "Datenerfassungs-Token",
"ai": "KI",
"downtimes": "Wartungsfenster",
"fleetConfigs": "Fleet-Konfigurationen",
"publicDashboards": "Öffentliche Dashboards",
"groups": "Gruppen",
"serviceAccounts": "Dienstkonten",
"cipherKeys": "Verschlüsselungsschlüssel",
"regexPatterns": "Regex-Muster"
},
"resourcesInline": {
"dashboards": "Dashboards",
"alerts": "Alarme",
"streams": "Streams",
"functions": "Funktionen",
"savedviews": "gespeicherte Ansichten",
"users": "Benutzer",
"roles": "Rollen",
"organizations": "Organisationen",
"pipelines": "Pipelines",
"destinations": "Ziele",
"templates": "Vorlagen",
"reports": "Berichte",
"llmModelPricing": "LLM-Modellpreise",
"passcode": "Datenerfassungs-Token",
"ai": "KI",
"downtimes": "Wartungsfenster",
"fleetConfigs": "Fleet-Konfigurationen",
"publicDashboards": "öffentliche Dashboards",
"groups": "Gruppen",
"serviceAccounts": "Dienstkonten",
"cipherKeys": "Verschlüsselungsschlüssel",
"regexPatterns": "Regex-Muster",
"unnamed": "diese Ressource"
},
"denied": {
"view": "Sie können {resource} nicht anzeigen",
"create": "Sie können {resource} nicht erstellen",
"edit": "Sie können {resource} nicht bearbeiten",
"delete": "Sie können {resource} nicht löschen",
"missing": "Ihrer Rolle fehlt die Berechtigung „{permission}“ für {resource}.",
"parts": "Sie können {count} Teil dieser Seite nicht anzeigen | Sie können {count} Teile dieser Seite nicht anzeigen",
"missingIntro": "Ihrer Rolle fehlen diese Berechtigungen:",
"more": "{count} weitere",
"showLess": "Weniger anzeigen"
},
"chip": {
"label": "Zugriff verweigert",
"ariaLabel": "Zugriff verweigert: {title}",
"popoverLabel": "Ausgeblendete Bereiche dieser Seite"
}
},
"common": {
"dismiss": "Schließen",
"exitFullscreen": "Vollbild verlassen",
@@ -6978,7 +7047,6 @@
"publishFailed": "Veröffentlichen fehlgeschlagen",
"revokedToast": "Öffentlicher Link widerrufen und gelöscht",
"revokeFailed": "Widerrufen und Löschen des Links fehlgeschlagen",
"noPublishPermission": "Sie haben keine Berechtigung, die öffentlichen Links dieses Dashboards anzuzeigen. Bitten Sie einen Administrator um die Berechtigung für öffentliche Dashboards.",
"linkCopied": "Link kopiert",
"copyLink": "Link kopieren",
"expired": "Dieser Link ist abgelaufen. Bitten Sie die Person, die ihn geteilt hat, ihn zu verlängern."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Filter überprüfen und auf Traces anwenden",
"spansLabel": "Spans",
"errorsLabel": "Fehler",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Fehler-Spans, tiefste zuerst"
},
"previousMatch": "Vorheriges Spiel",
"nextMatch": "Nächstes Spiel",
"searchInSpans": "In Spans suchen",
@@ -15440,6 +15512,22 @@
"roleSystem": "System",
"roleTool": "Werkzeug"
},
"results": {
"tracesWithMatchingSpan": "{count} Trace mit passendem Span | {count} Traces mit passendem Span",
"spansColumnTooltip": "Spans im Trace gesamt",
"fieldValueCountsCaption": "Anzahl sind Spans mit diesem Wert im aktuellen Zeitraum",
"errorColumn": "Fehler",
"eventsColumn": "Ereignisse",
"eventExceptions": "{count} Ausnahme | {count} Ausnahmen",
"eventLogs": "{count} Log | {count} Logs",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Ereignisse bei {operation}",
"eventsHoverOrder": "{count}, in zeitlicher Reihenfolge",
"eventKindException": "Ausnahme",
"eventKindLog": "Log",
"eventOffset": "+{offset}",
"moreEvents": "+{count} weitere"
},
"tableColumns": {
"service": "Service",
"operationName": "Operationsname",
@@ -22005,27 +22093,6 @@
"refresh": "Aktualisieren",
"invalidSqlSyntax": "Ungültige SQL-Syntax",
"pleaseWaitWhileLoadingStreams": "Bitte warten Sie, während die Streams geladen werden...",
"copied": "Kopiert!",
"copyDetails": "Details kopieren",
"accessRequiredTitle": "Zugriff erforderlich",
"accessRequiredMessage": "Einige Abschnitte konnten nicht geladen werden, da Sie nicht über die erforderlichen Berechtigungen verfügen. Wenden Sie sich an Ihren Administrator, wenn Sie der Meinung sind, dass Sie Zugriff haben sollten.",
"resourceFallback": "Ressource",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alarme",
"streams": "Streams",
"functions": "Funktionen",
"savedviews": "Gespeicherte Ansichten",
"users": "Benutzer",
"roles": "Rollen",
"organizations": "Organisationen",
"pipelines": "Pipelines",
"destinations": "Ziele",
"templates": "Vorlagen",
"reports": "Berichte",
"llmModelPricing": "LLM-Modellpreise",
"passcode": "Datenerfassungs-Token"
},
"noPermissionToChangeFavorites": "Sie haben keine Berechtigung, Favoriten zu ändern",
"couldNotUpdateFavoriteDashboards": "Favoriten-Dashboards konnten nicht aktualisiert werden"
},
+69 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Run the same query again"
}
},
"accessBoundary": {
"copyDetails": "Copy details",
"copied": "Copied!",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alerts",
"streams": "Streams",
"functions": "Functions",
"savedviews": "Saved views",
"users": "Users",
"roles": "Roles",
"organizations": "Organizations",
"pipelines": "Pipelines",
"destinations": "Destinations",
"templates": "Templates",
"reports": "Reports",
"llmModelPricing": "LLM model pricing",
"passcode": "Ingestion token",
"ai": "AI",
"downtimes": "Downtimes",
"fleetConfigs": "Fleet configurations",
"publicDashboards": "Public dashboards",
"groups": "Groups",
"serviceAccounts": "Service accounts",
"cipherKeys": "Cipher keys",
"regexPatterns": "Regex patterns"
},
"resourcesInline": {
"dashboards": "dashboards",
"alerts": "alerts",
"streams": "streams",
"functions": "functions",
"savedviews": "saved views",
"users": "users",
"roles": "roles",
"organizations": "organizations",
"pipelines": "pipelines",
"destinations": "destinations",
"templates": "templates",
"reports": "reports",
"llmModelPricing": "LLM model pricing",
"passcode": "the ingestion token",
"ai": "AI",
"downtimes": "downtimes",
"fleetConfigs": "fleet configurations",
"publicDashboards": "public dashboards",
"groups": "groups",
"serviceAccounts": "service accounts",
"cipherKeys": "cipher keys",
"regexPatterns": "regex patterns",
"unnamed": "this resource"
},
"denied": {
"view": "You can't view {resource}",
"create": "You can't create {resource}",
"edit": "You can't edit {resource}",
"delete": "You can't delete {resource}",
"missing": "Your role is missing the {permission} permission on {resource}.",
"parts": "You can't view {count} part of this page | You can't view {count} parts of this page",
"missingIntro": "Your role is missing these permissions:",
"more": "{count} more",
"showLess": "Show less"
},
"chip": {
"label": "Access denied",
"ariaLabel": "Access denied: {title}",
"popoverLabel": "Hidden parts of this page"
}
},
"common": {
"dismiss": "Dismiss",
"exitFullscreen": "Exit full screen",
@@ -6978,7 +7047,6 @@
"publishFailed": "Failed to publish",
"revokedToast": "Public link revoked and deleted",
"revokeFailed": "Failed to revoke and delete the link",
"noPublishPermission": "You don't have permission to view this dashboard's public links. Ask an admin for the Public Dashboards permission.",
"linkCopied": "Link copied",
"copyLink": "Copy link",
"expired": "This link has expired. Ask the person who shared it to extend it."
@@ -22023,27 +22091,6 @@
"refresh": "Refresh",
"invalidSqlSyntax": "Invalid SQL Syntax",
"pleaseWaitWhileLoadingStreams": "Please wait while loading streams...",
"copied": "Copied!",
"copyDetails": "Copy details",
"accessRequiredTitle": "Access Required",
"accessRequiredMessage": "Some sections couldn't load because you don't have the required permissions. Contact your administrator if you believe you should have access.",
"resourceFallback": "Resource",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alerts",
"streams": "Streams",
"functions": "Functions",
"savedviews": "Saved Views",
"users": "Users",
"roles": "Roles",
"organizations": "Organizations",
"pipelines": "Pipelines",
"destinations": "Destinations",
"templates": "Templates",
"reports": "Reports",
"llmModelPricing": "LLM Model Pricing",
"passcode": "Ingestion Token"
},
"noPermissionToChangeFavorites": "You don't have permission to change favorites",
"couldNotUpdateFavoriteDashboards": "Couldn't update favorite dashboards"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Ejecutar la misma consulta de nuevo"
}
},
"accessBoundary": {
"copyDetails": "Copiar detalles",
"copied": "¡Copiado!",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alertas",
"streams": "Streams",
"functions": "Funciones",
"savedviews": "Vistas guardadas",
"users": "Usuarios",
"roles": "Roles",
"organizations": "Organizaciones",
"pipelines": "Pipelines",
"destinations": "Destinos",
"templates": "Plantillas",
"reports": "Informes",
"llmModelPricing": "Precios de modelos LLM",
"passcode": "Token de ingesta",
"ai": "IA",
"downtimes": "Periodos de mantenimiento",
"fleetConfigs": "Configuraciones de flota",
"publicDashboards": "Dashboards públicos",
"groups": "Grupos",
"serviceAccounts": "Cuentas de servicio",
"cipherKeys": "Claves de cifrado",
"regexPatterns": "Patrones de expresiones regulares"
},
"resourcesInline": {
"dashboards": "dashboards",
"alerts": "alertas",
"streams": "streams",
"functions": "funciones",
"savedviews": "vistas guardadas",
"users": "usuarios",
"roles": "roles",
"organizations": "organizaciones",
"pipelines": "pipelines",
"destinations": "destinos",
"templates": "plantillas",
"reports": "informes",
"llmModelPricing": "precios de modelos LLM",
"passcode": "token de ingesta",
"ai": "IA",
"downtimes": "periodos de mantenimiento",
"fleetConfigs": "configuraciones de flota",
"publicDashboards": "dashboards públicos",
"groups": "grupos",
"serviceAccounts": "cuentas de servicio",
"cipherKeys": "claves de cifrado",
"regexPatterns": "patrones de expresiones regulares",
"unnamed": "este recurso"
},
"denied": {
"view": "No puedes ver {resource}",
"create": "No puedes crear {resource}",
"edit": "No puedes editar {resource}",
"delete": "No puedes eliminar {resource}",
"missing": "A tu rol le falta el permiso {permission} sobre {resource}.",
"parts": "No puedes ver {count} parte de esta página | No puedes ver {count} partes de esta página",
"missingIntro": "A tu rol le faltan estos permisos:",
"more": "{count} más",
"showLess": "Mostrar menos"
},
"chip": {
"label": "Acceso denegado",
"ariaLabel": "Acceso denegado: {title}",
"popoverLabel": "Partes ocultas de esta página"
}
},
"common": {
"dismiss": "Descartar",
"exitFullscreen": "Salir de pantalla completa",
@@ -6978,7 +7047,6 @@
"publishFailed": "No se pudo publicar",
"revokedToast": "Enlace público revocado y eliminado",
"revokeFailed": "No se pudo revocar y eliminar el enlace",
"noPublishPermission": "No tienes permiso para ver los enlaces públicos de este dashboard. Solicita a un administrador el permiso de Dashboards públicos.",
"linkCopied": "Enlace copiado",
"copyLink": "Copiar enlace",
"expired": "Este enlace ha caducado. Pide a la persona que lo compartió que amplíe su vigencia."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Revisar y aplicar filtros a las trazas",
"spansLabel": "spans",
"errorsLabel": "errores",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Spans con error, primero los más profundos"
},
"previousMatch": "Coincidencia anterior",
"nextMatch": "Siguiente coincidencia",
"searchInSpans": "Buscar en spans",
@@ -15440,6 +15512,22 @@
"roleSystem": "Sistema",
"roleTool": "Herramienta"
},
"results": {
"tracesWithMatchingSpan": "{count} traza con un span coincidente | {count} trazas con un span coincidente",
"spansColumnTooltip": "Total de spans en la traza",
"fieldValueCountsCaption": "Los recuentos son spans con este valor, en el rango actual",
"errorColumn": "Error",
"eventsColumn": "Eventos",
"eventExceptions": "{count} excepción | {count} excepciones",
"eventLogs": "{count} log | {count} logs",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Eventos en {operation}",
"eventsHoverOrder": "{count}, en orden temporal",
"eventKindException": "excepción",
"eventKindLog": "log",
"eventOffset": "+{offset}",
"moreEvents": "+{count} más"
},
"tableColumns": {
"service": "Servicio",
"operationName": "Nombre de la operación",
@@ -22005,27 +22093,6 @@
"refresh": "Actualizar",
"invalidSqlSyntax": "Sintaxis SQL no válida",
"pleaseWaitWhileLoadingStreams": "Espere mientras se cargan los streams...",
"copied": "¡Copiado!",
"copyDetails": "Copiar detalles",
"accessRequiredTitle": "Acceso requerido",
"accessRequiredMessage": "Algunas secciones no pudieron cargarse porque no tienes los permisos necesarios. Contacta a tu administrador si crees que deberías tener acceso.",
"resourceFallback": "Recurso",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alertas",
"streams": "Streams",
"functions": "Funciones",
"savedviews": "Vistas guardadas",
"users": "Usuarios",
"roles": "Roles",
"organizations": "Organizaciones",
"pipelines": "Pipelines",
"destinations": "Destinos",
"templates": "Plantillas",
"reports": "Informes",
"llmModelPricing": "Precios de modelos LLM",
"passcode": "Token de ingesta"
},
"noPermissionToChangeFavorites": "No tienes permiso para cambiar favoritos",
"couldNotUpdateFavoriteDashboards": "No se pudieron actualizar los Dashboards favoritos"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Relancer la même requête"
}
},
"accessBoundary": {
"copyDetails": "Copier les détails",
"copied": "Copié !",
"resources": {
"dashboards": "Tableaux de bord",
"alerts": "Alertes",
"streams": "Flux",
"functions": "Fonctions",
"savedviews": "Vues enregistrées",
"users": "Utilisateurs",
"roles": "Rôles",
"organizations": "Organisations",
"pipelines": "Pipelines",
"destinations": "Destinations",
"templates": "Modèles",
"reports": "Rapports",
"llmModelPricing": "Tarification des modèles LLM",
"passcode": "Jeton d'ingestion",
"ai": "IA",
"downtimes": "Plages de maintenance",
"fleetConfigs": "Configurations de flotte",
"publicDashboards": "Tableaux de bord publics",
"groups": "Groupes",
"serviceAccounts": "Comptes de service",
"cipherKeys": "Clés de chiffrement",
"regexPatterns": "Motifs regex"
},
"resourcesInline": {
"dashboards": "tableaux de bord",
"alerts": "alertes",
"streams": "flux",
"functions": "fonctions",
"savedviews": "vues enregistrées",
"users": "utilisateurs",
"roles": "rôles",
"organizations": "organisations",
"pipelines": "pipelines",
"destinations": "destinations",
"templates": "modèles",
"reports": "rapports",
"llmModelPricing": "tarification des modèles LLM",
"passcode": "jeton d'ingestion",
"ai": "l'IA",
"downtimes": "plages de maintenance",
"fleetConfigs": "configurations de flotte",
"publicDashboards": "tableaux de bord publics",
"groups": "groupes",
"serviceAccounts": "comptes de service",
"cipherKeys": "clés de chiffrement",
"regexPatterns": "motifs regex",
"unnamed": "cette ressource"
},
"denied": {
"view": "Vous ne pouvez pas afficher {resource}",
"create": "Vous ne pouvez pas créer {resource}",
"edit": "Vous ne pouvez pas modifier {resource}",
"delete": "Vous ne pouvez pas supprimer {resource}",
"missing": "Il manque à votre rôle l'autorisation {permission} sur {resource}.",
"parts": "Vous ne pouvez pas afficher {count} partie de cette page | Vous ne pouvez pas afficher {count} parties de cette page",
"missingIntro": "Il manque à votre rôle ces autorisations :",
"more": "{count} de plus",
"showLess": "Afficher moins"
},
"chip": {
"label": "Accès refusé",
"ariaLabel": "Accès refusé : {title}",
"popoverLabel": "Parties masquées de cette page"
}
},
"common": {
"dismiss": "Ignorer",
"exitFullscreen": "Quitter le plein écran",
@@ -6978,7 +7047,6 @@
"publishFailed": "Échec de la publication",
"revokedToast": "Lien public révoqué et supprimé",
"revokeFailed": "Échec de la révocation et de la suppression du lien",
"noPublishPermission": "Vous n'avez pas la permission de voir les liens publics de ce tableau de bord. Demandez à un administrateur la permission Public Dashboards.",
"linkCopied": "Lien copié",
"copyLink": "Copier le lien",
"expired": "Ce lien a expiré. Demandez à la personne qui l'a partagé de le prolonger."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Examiner et appliquer les filtres aux traces",
"spansLabel": "spans",
"errorsLabel": "erreurs",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Spans en erreur, les plus profonds d'abord"
},
"previousMatch": "Match précédent",
"nextMatch": "Prochain match",
"searchInSpans": "Rechercher dans les spans",
@@ -15440,6 +15512,22 @@
"roleSystem": "Système",
"roleTool": "Outil"
},
"results": {
"tracesWithMatchingSpan": "{count} trace avec un span correspondant | {count} traces avec un span correspondant",
"spansColumnTooltip": "Total des spans dans la trace",
"fieldValueCountsCaption": "Les décomptes correspondent aux spans ayant cette valeur, dans la plage actuelle",
"errorColumn": "Erreur",
"eventsColumn": "Événements",
"eventExceptions": "{count} exception | {count} exceptions",
"eventLogs": "{count} log | {count} logs",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Événements sur {operation}",
"eventsHoverOrder": "{count}, par ordre chronologique",
"eventKindException": "exception",
"eventKindLog": "log",
"eventOffset": "+{offset}",
"moreEvents": "+{count} de plus"
},
"tableColumns": {
"service": "Service",
"operationName": "Nom de l'opération",
@@ -22005,27 +22093,6 @@
"refresh": "Actualiser",
"invalidSqlSyntax": "Syntaxe SQL invalide",
"pleaseWaitWhileLoadingStreams": "Veuillez patienter pendant le chargement des flux...",
"copied": "Copié !",
"copyDetails": "Copier les détails",
"accessRequiredTitle": "Accès requis",
"accessRequiredMessage": "Certaines sections n'ont pas pu se charger car vous ne disposez pas des autorisations requises. Contactez votre administrateur si vous pensez que vous devriez y avoir accès.",
"resourceFallback": "Ressource",
"resources": {
"dashboards": "Tableaux de bord",
"alerts": "Alertes",
"streams": "Flux",
"functions": "Fonctions",
"savedviews": "Vues enregistrées",
"users": "Utilisateurs",
"roles": "Rôles",
"organizations": "Organisations",
"pipelines": "Pipelines",
"destinations": "Destinations",
"templates": "Modèles",
"reports": "Rapports",
"llmModelPricing": "Tarification des modèles LLM",
"passcode": "Jeton d'ingestion"
},
"noPermissionToChangeFavorites": "Vous n'avez pas la permission de modifier les favoris",
"couldNotUpdateFavoriteDashboards": "Impossible de mettre à jour les tableaux de bord favoris"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Esegui di nuovo la stessa query"
}
},
"accessBoundary": {
"copyDetails": "Copia dettagli",
"copied": "Copiato!",
"resources": {
"dashboards": "Dashboard",
"alerts": "Avvisi",
"streams": "Stream",
"functions": "Funzioni",
"savedviews": "Viste salvate",
"users": "Utenti",
"roles": "Ruoli",
"organizations": "Organizzazioni",
"pipelines": "Pipeline",
"destinations": "Destinazioni",
"templates": "Template",
"reports": "Report",
"llmModelPricing": "Prezzi dei modelli LLM",
"passcode": "Token di ingestione",
"ai": "IA",
"downtimes": "Downtime",
"fleetConfigs": "Configurazioni fleet",
"publicDashboards": "Dashboard pubbliche",
"groups": "Gruppi",
"serviceAccounts": "Account di servizio",
"cipherKeys": "Chiavi di cifratura",
"regexPatterns": "Pattern regex"
},
"resourcesInline": {
"dashboards": "dashboard",
"alerts": "avvisi",
"streams": "stream",
"functions": "funzioni",
"savedviews": "viste salvate",
"users": "utenti",
"roles": "ruoli",
"organizations": "organizzazioni",
"pipelines": "pipeline",
"destinations": "destinazioni",
"templates": "template",
"reports": "report",
"llmModelPricing": "prezzi dei modelli LLM",
"passcode": "token di ingestione",
"ai": "IA",
"downtimes": "downtime",
"fleetConfigs": "configurazioni fleet",
"publicDashboards": "dashboard pubbliche",
"groups": "gruppi",
"serviceAccounts": "account di servizio",
"cipherKeys": "chiavi di cifratura",
"regexPatterns": "pattern regex",
"unnamed": "questa risorsa"
},
"denied": {
"view": "Non puoi visualizzare {resource}",
"create": "Non puoi creare {resource}",
"edit": "Non puoi modificare {resource}",
"delete": "Non puoi eliminare {resource}",
"missing": "Al tuo ruolo manca il permesso {permission} su {resource}.",
"parts": "Non puoi visualizzare {count} parte di questa pagina | Non puoi visualizzare {count} parti di questa pagina",
"missingIntro": "Al tuo ruolo mancano questi permessi:",
"more": "altri {count}",
"showLess": "Mostra meno"
},
"chip": {
"label": "Accesso negato",
"ariaLabel": "Accesso negato: {title}",
"popoverLabel": "Parti nascoste di questa pagina"
}
},
"common": {
"dismiss": "Ignora",
"exitFullscreen": "Esci da schermo intero",
@@ -6978,7 +7047,6 @@
"publishFailed": "Pubblicazione non riuscita",
"revokedToast": "Link pubblico revocato ed eliminato",
"revokeFailed": "Impossibile revocare ed eliminare il link",
"noPublishPermission": "Non hai il permesso di visualizzare i link pubblici di questa dashboard. Chiedi a un amministratore il permesso Dashboard pubbliche.",
"linkCopied": "Link copiato",
"copyLink": "Copia link",
"expired": "Questo link è scaduto. Chiedi a chi lo ha condiviso di estenderne la scadenza."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Rivedi e applica i filtri alle tracce",
"spansLabel": "span",
"errorsLabel": "errori",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Span con errori, prima i più profondi"
},
"previousMatch": "Corrispondenza precedente",
"nextMatch": "Corrispondenza successiva",
"searchInSpans": "Cerca negli span",
@@ -15440,6 +15512,22 @@
"roleSystem": "Sistema",
"roleTool": "Strumento"
},
"results": {
"tracesWithMatchingSpan": "{count} traccia con uno span corrispondente | {count} tracce con uno span corrispondente",
"spansColumnTooltip": "Span totali nella traccia",
"fieldValueCountsCaption": "I conteggi indicano gli span con questo valore, nell'intervallo corrente",
"errorColumn": "Errore",
"eventsColumn": "Eventi",
"eventExceptions": "{count} eccezione | {count} eccezioni",
"eventLogs": "{count} log | {count} log",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Eventi su {operation}",
"eventsHoverOrder": "{count}, in ordine temporale",
"eventKindException": "eccezione",
"eventKindLog": "log",
"eventOffset": "+{offset}",
"moreEvents": "+{count} altri"
},
"tableColumns": {
"service": "Servizio",
"operationName": "Nome operazione",
@@ -22005,27 +22093,6 @@
"refresh": "Aggiorna",
"invalidSqlSyntax": "Sintassi SQL non valida",
"pleaseWaitWhileLoadingStreams": "Attendere mentre si caricano gli stream...",
"copied": "Copiato!",
"copyDetails": "Copia dettagli",
"accessRequiredTitle": "Accesso richiesto",
"accessRequiredMessage": "Alcune sezioni non hanno potuto caricarsi perché non si dispone delle autorizzazioni richieste. Contattare l'amministratore se si ritiene di dover avere accesso.",
"resourceFallback": "Risorsa",
"resources": {
"dashboards": "Dashboard",
"alerts": "Avvisi",
"streams": "Stream",
"functions": "Funzioni",
"savedviews": "Viste salvate",
"users": "Utenti",
"roles": "Ruoli",
"organizations": "Organizzazioni",
"pipelines": "Pipeline",
"destinations": "Destinazioni",
"templates": "Template",
"reports": "Report",
"llmModelPricing": "Prezzi dei modelli LLM",
"passcode": "Token di ingestione"
},
"noPermissionToChangeFavorites": "Non hai il permesso di modificare i preferiti",
"couldNotUpdateFavoriteDashboards": "Impossibile aggiornare le dashboard preferite"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "同じクエリをもう一度実行します"
}
},
"accessBoundary": {
"copyDetails": "詳細をコピー",
"copied": "コピーしました!",
"resources": {
"dashboards": "ダッシュボード",
"alerts": "アラート",
"streams": "ストリーム",
"functions": "関数",
"savedviews": "保存済みビュー",
"users": "ユーザー",
"roles": "ロール",
"organizations": "組織",
"pipelines": "パイプライン",
"destinations": "宛先",
"templates": "テンプレート",
"reports": "レポート",
"llmModelPricing": "LLMモデルの価格設定",
"passcode": "取り込みトークン",
"ai": "AI",
"downtimes": "ダウンタイム",
"fleetConfigs": "フリート構成",
"publicDashboards": "公開ダッシュボード",
"groups": "グループ",
"serviceAccounts": "サービスアカウント",
"cipherKeys": "暗号化キー",
"regexPatterns": "正規表現パターン"
},
"resourcesInline": {
"dashboards": "ダッシュボード",
"alerts": "アラート",
"streams": "ストリーム",
"functions": "関数",
"savedviews": "保存済みビュー",
"users": "ユーザー",
"roles": "ロール",
"organizations": "組織",
"pipelines": "パイプライン",
"destinations": "宛先",
"templates": "テンプレート",
"reports": "レポート",
"llmModelPricing": "LLMモデルの価格設定",
"passcode": "取り込みトークン",
"ai": "AI",
"downtimes": "ダウンタイム",
"fleetConfigs": "フリート構成",
"publicDashboards": "公開ダッシュボード",
"groups": "グループ",
"serviceAccounts": "サービスアカウント",
"cipherKeys": "暗号化キー",
"regexPatterns": "正規表現パターン",
"unnamed": "このリソース"
},
"denied": {
"view": "{resource} を表示できません",
"create": "{resource} を作成できません",
"edit": "{resource} を編集できません",
"delete": "{resource} を削除できません",
"missing": "あなたのロールには {resource} に対する {permission} 権限がありません。",
"parts": "このページの {count} 箇所を表示できません | このページの {count} 箇所を表示できません",
"missingIntro": "あなたのロールには次の権限がありません:",
"more": "他 {count} 件",
"showLess": "表示を減らす"
},
"chip": {
"label": "アクセス拒否",
"ariaLabel": "アクセス拒否: {title}",
"popoverLabel": "このページの非表示の項目"
}
},
"common": {
"dismiss": "閉じる",
"exitFullscreen": "全画面表示を終了",
@@ -6978,7 +7047,6 @@
"publishFailed": "公開に失敗しました",
"revokedToast": "公開リンクを取り消して削除しました",
"revokeFailed": "リンクの取り消しと削除に失敗しました",
"noPublishPermission": "このダッシュボードの公開リンクを表示する権限がありません。管理者に「公開ダッシュボード」権限を依頼してください。",
"linkCopied": "リンクをコピーしました",
"copyLink": "リンクをコピー",
"expired": "このリンクは有効期限が切れています。共有した人に延長を依頼してください。"
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "フィルターを確認してトレースに適用",
"spansLabel": "スパン",
"errorsLabel": "エラー",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "エラースパン(深い順)"
},
"previousMatch": "前の試合",
"nextMatch": "次の試合",
"searchInSpans": "スパン内を検索",
@@ -15440,6 +15512,22 @@
"roleSystem": "システム",
"roleTool": "ツール"
},
"results": {
"tracesWithMatchingSpan": "{count} 件の一致するスパンを含むトレース | {count} 件の一致するスパンを含むトレース",
"spansColumnTooltip": "トレース内のスパン総数",
"fieldValueCountsCaption": "件数は現在の範囲でこの値を持つスパンの数です",
"errorColumn": "エラー",
"eventsColumn": "イベント",
"eventExceptions": "{count} 件の例外 | {count} 件の例外",
"eventLogs": "{count} 件のログ | {count} 件のログ",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "{operation} のイベント",
"eventsHoverOrder": "{count}、時刻順",
"eventKindException": "例外",
"eventKindLog": "ログ",
"eventOffset": "+{offset}",
"moreEvents": "+{count} 件"
},
"tableColumns": {
"service": "サービス",
"operationName": "オペレーション名",
@@ -22005,27 +22093,6 @@
"refresh": "更新",
"invalidSqlSyntax": "無効なSQL構文です",
"pleaseWaitWhileLoadingStreams": "ストリームを読み込んでいます。お待ちください...",
"copied": "コピーしました!",
"copyDetails": "詳細をコピー",
"accessRequiredTitle": "アクセスが必要です",
"accessRequiredMessage": "一部のセクションを読み込めませんでした。必要な権限がないためです。アクセスできるはずだと思われる場合は、管理者にお問い合わせください。",
"resourceFallback": "リソース",
"resources": {
"dashboards": "ダッシュボード",
"alerts": "アラート",
"streams": "ストリーム",
"functions": "関数",
"savedviews": "保存済みビュー",
"users": "ユーザー",
"roles": "ロール",
"organizations": "組織",
"pipelines": "パイプライン",
"destinations": "宛先",
"templates": "テンプレート",
"reports": "レポート",
"llmModelPricing": "LLMモデルの価格設定",
"passcode": "取り込みトークン"
},
"noPermissionToChangeFavorites": "お気に入りを変更する権限がありません",
"couldNotUpdateFavoriteDashboards": "お気に入りのダッシュボードを更新できませんでした"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "동일한 쿼리를 다시 실행"
}
},
"accessBoundary": {
"copyDetails": "세부 정보 복사",
"copied": "복사되었습니다!",
"resources": {
"dashboards": "대시보드",
"alerts": "알림",
"streams": "스트림",
"functions": "함수",
"savedviews": "저장된 보기",
"users": "사용자",
"roles": "역할",
"organizations": "조직",
"pipelines": "파이프라인",
"destinations": "대상",
"templates": "템플릿",
"reports": "보고서",
"llmModelPricing": "LLM 모델 가격",
"passcode": "수집 토큰",
"ai": "AI",
"downtimes": "다운타임",
"fleetConfigs": "플릿 구성",
"publicDashboards": "공개 대시보드",
"groups": "그룹",
"serviceAccounts": "서비스 계정",
"cipherKeys": "암호 키",
"regexPatterns": "정규식 패턴"
},
"resourcesInline": {
"dashboards": "대시보드",
"alerts": "알림",
"streams": "스트림",
"functions": "함수",
"savedviews": "저장된 보기",
"users": "사용자",
"roles": "역할",
"organizations": "조직",
"pipelines": "파이프라인",
"destinations": "대상",
"templates": "템플릿",
"reports": "보고서",
"llmModelPricing": "LLM 모델 가격",
"passcode": "수집 토큰",
"ai": "AI",
"downtimes": "다운타임",
"fleetConfigs": "플릿 구성",
"publicDashboards": "공개 대시보드",
"groups": "그룹",
"serviceAccounts": "서비스 계정",
"cipherKeys": "암호 키",
"regexPatterns": "정규식 패턴",
"unnamed": "이 리소스"
},
"denied": {
"view": "{resource}을(를) 볼 수 없습니다",
"create": "{resource}을(를) 만들 수 없습니다",
"edit": "{resource}을(를) 편집할 수 없습니다",
"delete": "{resource}을(를) 삭제할 수 없습니다",
"missing": "내 역할에 {resource}에 대한 {permission} 권한이 없습니다.",
"parts": "이 페이지의 {count}개 부분을 볼 수 없습니다 | 이 페이지의 {count}개 부분을 볼 수 없습니다",
"missingIntro": "내 역할에 다음 권한이 없습니다:",
"more": "{count}개 더",
"showLess": "간단히 보기"
},
"chip": {
"label": "액세스 거부됨",
"ariaLabel": "액세스 거부됨: {title}",
"popoverLabel": "이 페이지의 숨겨진 항목"
}
},
"common": {
"dismiss": "닫기",
"exitFullscreen": "전체 화면 종료",
@@ -6978,7 +7047,6 @@
"publishFailed": "게시하지 못했습니다",
"revokedToast": "공개 링크를 해지하고 삭제했습니다",
"revokeFailed": "링크를 해지하고 삭제하지 못했습니다",
"noPublishPermission": "이 대시보드의 공개 링크를 볼 권한이 없습니다. 관리자에게 공개 대시보드 권한을 요청하세요.",
"linkCopied": "링크가 복사되었습니다",
"copyLink": "링크 복사",
"expired": "이 링크는 만료되었습니다. 공유한 사람에게 연장을 요청하세요."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "트레이스에 필터 검토 및 적용",
"spansLabel": "스팬",
"errorsLabel": "오류",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "오류 스팬, 깊은 것부터"
},
"previousMatch": "이전 일치 항목",
"nextMatch": "다음 일치 항목",
"searchInSpans": "스팬에서 검색",
@@ -15440,6 +15512,22 @@
"roleSystem": "시스템",
"roleTool": "도구"
},
"results": {
"tracesWithMatchingSpan": "일치하는 스팬이 있는 트레이스 {count}개 | 일치하는 스팬이 있는 트레이스 {count}개",
"spansColumnTooltip": "트레이스의 총 스팬 수",
"fieldValueCountsCaption": "개수는 현재 범위에서 이 값을 가진 스팬 수입니다",
"errorColumn": "오류",
"eventsColumn": "이벤트",
"eventExceptions": "예외 {count}개 | 예외 {count}개",
"eventLogs": "로그 {count}개 | 로그 {count}개",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "{operation}의 이벤트",
"eventsHoverOrder": "{count}개, 시간순",
"eventKindException": "예외",
"eventKindLog": "로그",
"eventOffset": "+{offset}",
"moreEvents": "+{count}개 더"
},
"tableColumns": {
"service": "서비스",
"operationName": "작업 이름",
@@ -22005,27 +22093,6 @@
"refresh": "새로 고침",
"invalidSqlSyntax": "잘못된 SQL 구문입니다.",
"pleaseWaitWhileLoadingStreams": "스트림을 불러오는 동안 잠시 기다려 주세요...",
"copied": "복사되었습니다!",
"copyDetails": "세부 정보 복사",
"accessRequiredTitle": "액세스 필요",
"accessRequiredMessage": "필요한 권한이 없어 일부 섹션을 불러올 수 없습니다. 액세스 권한이 있어야 한다고 생각되면 관리자에게 문의하세요.",
"resourceFallback": "리소스",
"resources": {
"dashboards": "대시보드",
"alerts": "알림",
"streams": "스트림",
"functions": "함수",
"savedviews": "저장된 보기",
"users": "사용자",
"roles": "역할",
"organizations": "조직",
"pipelines": "파이프라인",
"destinations": "대상",
"templates": "템플릿",
"reports": "보고서",
"llmModelPricing": "LLM 모델 가격",
"passcode": "수집 토큰"
},
"noPermissionToChangeFavorites": "즐겨찾기를 변경할 권한이 없습니다.",
"couldNotUpdateFavoriteDashboards": "즐겨찾기 대시보드를 업데이트하지 못했습니다."
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Dezelfde query opnieuw uitvoeren"
}
},
"accessBoundary": {
"copyDetails": "Details kopiëren",
"copied": "Gekopieerd!",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alarmen",
"streams": "Streams",
"functions": "Functies",
"savedviews": "Opgeslagen weergaven",
"users": "Gebruikers",
"roles": "Rollen",
"organizations": "Organisaties",
"pipelines": "Pipelines",
"destinations": "Bestemmingen",
"templates": "Sjablonen",
"reports": "Rapporten",
"llmModelPricing": "LLM-modelprijzen",
"passcode": "Ingestietoken",
"ai": "AI",
"downtimes": "Onderhoudsperiodes",
"fleetConfigs": "Fleetconfiguraties",
"publicDashboards": "Openbare dashboards",
"groups": "Groepen",
"serviceAccounts": "Serviceaccounts",
"cipherKeys": "Cipher keys",
"regexPatterns": "Regex-patronen"
},
"resourcesInline": {
"dashboards": "dashboards",
"alerts": "alarmen",
"streams": "streams",
"functions": "functies",
"savedviews": "opgeslagen weergaven",
"users": "gebruikers",
"roles": "rollen",
"organizations": "organisaties",
"pipelines": "pipelines",
"destinations": "bestemmingen",
"templates": "sjablonen",
"reports": "rapporten",
"llmModelPricing": "LLM-modelprijzen",
"passcode": "ingestietoken",
"ai": "AI",
"downtimes": "onderhoudsperiodes",
"fleetConfigs": "fleetconfiguraties",
"publicDashboards": "openbare dashboards",
"groups": "groepen",
"serviceAccounts": "serviceaccounts",
"cipherKeys": "cipher keys",
"regexPatterns": "regex-patronen",
"unnamed": "deze resource"
},
"denied": {
"view": "U kunt {resource} niet bekijken",
"create": "U kunt {resource} niet aanmaken",
"edit": "U kunt {resource} niet bewerken",
"delete": "U kunt {resource} niet verwijderen",
"missing": "Uw rol mist de machtiging {permission} op {resource}.",
"parts": "U kunt {count} deel van deze pagina niet bekijken | U kunt {count} delen van deze pagina niet bekijken",
"missingIntro": "Uw rol mist deze machtigingen:",
"more": "{count} meer",
"showLess": "Minder weergeven"
},
"chip": {
"label": "Toegang geweigerd",
"ariaLabel": "Toegang geweigerd: {title}",
"popoverLabel": "Verborgen delen van deze pagina"
}
},
"common": {
"dismiss": "Sluiten",
"exitFullscreen": "Volledig scherm sluiten",
@@ -6978,7 +7047,6 @@
"publishFailed": "Publiceren mislukt",
"revokedToast": "Openbare link ingetrokken en verwijderd",
"revokeFailed": "Intrekken en verwijderen van de link mislukt",
"noPublishPermission": "Je hebt geen toestemming om de openbare links van dit dashboard te bekijken. Vraag een beheerder om de machtiging Openbare dashboards.",
"linkCopied": "Link gekopieerd",
"copyLink": "Link kopiëren",
"expired": "Deze link is verlopen. Vraag degene die hem heeft gedeeld om hem te verlengen."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Bekijk en pas filters toe op traces",
"spansLabel": "spans",
"errorsLabel": "fouten",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Foutspans, diepste eerst"
},
"previousMatch": "Vorige wedstrijd",
"nextMatch": "Volgende wedstrijd",
"searchInSpans": "Zoeken in spans",
@@ -15440,6 +15512,22 @@
"roleSystem": "Systeem",
"roleTool": "Tool"
},
"results": {
"tracesWithMatchingSpan": "{count} trace met een overeenkomende span | {count} traces met een overeenkomende span",
"spansColumnTooltip": "Totaal aantal spans in trace",
"fieldValueCountsCaption": "Aantallen zijn spans met deze waarde, in het huidige bereik",
"errorColumn": "Fout",
"eventsColumn": "Gebeurtenissen",
"eventExceptions": "{count} uitzondering | {count} uitzonderingen",
"eventLogs": "{count} log | {count} logs",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Gebeurtenissen op {operation}",
"eventsHoverOrder": "{count}, in tijdsvolgorde",
"eventKindException": "uitzondering",
"eventKindLog": "log",
"eventOffset": "+{offset}",
"moreEvents": "+{count} meer"
},
"tableColumns": {
"service": "Service",
"operationName": "Operatienaam",
@@ -22005,27 +22093,6 @@
"refresh": "Vernieuwen",
"invalidSqlSyntax": "Ongeldige SQL-syntaxis",
"pleaseWaitWhileLoadingStreams": "Even geduld terwijl streams worden geladen...",
"copied": "Gekopieerd!",
"copyDetails": "Details kopiëren",
"accessRequiredTitle": "Toegang vereist",
"accessRequiredMessage": "Sommige secties konden niet worden geladen omdat u niet over de vereiste rechten beschikt. Neem contact op met uw beheerder als u denkt dat u toegang zou moeten hebben.",
"resourceFallback": "Resource",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alarmen",
"streams": "Streams",
"functions": "Functies",
"savedviews": "Opgeslagen weergaven",
"users": "Gebruikers",
"roles": "Rollen",
"organizations": "Organisaties",
"pipelines": "Pipelines",
"destinations": "Bestemmingen",
"templates": "Sjablonen",
"reports": "Rapporten",
"llmModelPricing": "LLM-modelprijzen",
"passcode": "Ingestietoken"
},
"noPermissionToChangeFavorites": "Je hebt geen toestemming om favorieten te wijzigen",
"couldNotUpdateFavoriteDashboards": "Kan favoriete dashboards niet bijwerken"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Uruchom to samo zapytanie ponownie"
}
},
"accessBoundary": {
"copyDetails": "Kopiuj szczegóły",
"copied": "Skopiowano!",
"resources": {
"dashboards": "Pulpity",
"alerts": "Alerty",
"streams": "Strumienie",
"functions": "Funkcje",
"savedviews": "Zapisane widoki",
"users": "Użytkownicy",
"roles": "Role",
"organizations": "Organizacje",
"pipelines": "Potoki",
"destinations": "Miejsca docelowe",
"templates": "Szablony",
"reports": "Raporty",
"llmModelPricing": "Cennik modeli LLM",
"passcode": "Token pozyskiwania danych",
"ai": "AI",
"downtimes": "Okna serwisowe",
"fleetConfigs": "Konfiguracje floty",
"publicDashboards": "Pulpity publiczne",
"groups": "Grupy",
"serviceAccounts": "Konta usługi",
"cipherKeys": "Klucze szyfrujące",
"regexPatterns": "Wzorce regex"
},
"resourcesInline": {
"dashboards": "pulpity",
"alerts": "alerty",
"streams": "strumienie",
"functions": "funkcje",
"savedviews": "zapisane widoki",
"users": "użytkownicy",
"roles": "role",
"organizations": "organizacje",
"pipelines": "potoki",
"destinations": "miejsca docelowe",
"templates": "szablony",
"reports": "raporty",
"llmModelPricing": "cennik modeli LLM",
"passcode": "token pozyskiwania danych",
"ai": "AI",
"downtimes": "okna serwisowe",
"fleetConfigs": "konfiguracje floty",
"publicDashboards": "pulpity publiczne",
"groups": "grupy",
"serviceAccounts": "konta usługi",
"cipherKeys": "klucze szyfrujące",
"regexPatterns": "wzorce regex",
"unnamed": "ten zasób"
},
"denied": {
"view": "Nie możesz wyświetlać: {resource}",
"create": "Nie możesz tworzyć: {resource}",
"edit": "Nie możesz edytować: {resource}",
"delete": "Nie możesz usuwać: {resource}",
"missing": "Twojej roli brakuje uprawnienia {permission} dla: {resource}.",
"parts": "Nie możesz wyświetlić {count} części tej strony | Nie możesz wyświetlić części tej strony ({count})",
"missingIntro": "Twojej roli brakuje tych uprawnień:",
"more": "{count} więcej",
"showLess": "Pokaż mniej"
},
"chip": {
"label": "Odmowa dostępu",
"ariaLabel": "Odmowa dostępu: {title}",
"popoverLabel": "Ukryte części tej strony"
}
},
"common": {
"dismiss": "Odrzuć",
"exitFullscreen": "Wyjdź z pełnego ekranu",
@@ -6978,7 +7047,6 @@
"publishFailed": "Nie udało się opublikować",
"revokedToast": "Unieważniono i usunięto link publiczny",
"revokeFailed": "Nie udało się unieważnić i usunąć linku",
"noPublishPermission": "Nie masz uprawnień do wyświetlania publicznych linków tego pulpitu. Poproś administratora o uprawnienie do publicznych pulpitów.",
"linkCopied": "Skopiowano link",
"copyLink": "Kopiuj link",
"expired": "Ten link wygasł. Poproś osobę, która go udostępniła, o przedłużenie ważności."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Przejrzyj i zastosuj filtry do trace",
"spansLabel": "spany",
"errorsLabel": "błędy",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Spany błędów, najgłębsze najpierw"
},
"previousMatch": "Poprzednie dopasowanie",
"nextMatch": "Następne dopasowanie",
"searchInSpans": "Szukaj w spanach",
@@ -15440,6 +15512,22 @@
"roleSystem": "System",
"roleTool": "Narzędzie"
},
"results": {
"tracesWithMatchingSpan": "{count} trace ze zgodnym spanem | {count} trace ze zgodnymi spanami",
"spansColumnTooltip": "Łączna liczba spanów w trace",
"fieldValueCountsCaption": "Zliczenia dotyczą spanów z tą wartością w bieżącym zakresie",
"errorColumn": "Błąd",
"eventsColumn": "Zdarzenia",
"eventExceptions": "{count} wyjątek | {count} wyjątków",
"eventLogs": "{count} log | {count} logów",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Zdarzenia dla {operation}",
"eventsHoverOrder": "{count}, w kolejności czasowej",
"eventKindException": "wyjątek",
"eventKindLog": "log",
"eventOffset": "+{offset}",
"moreEvents": "+{count} więcej"
},
"tableColumns": {
"service": "Usługa",
"operationName": "Nazwa operacji",
@@ -22005,27 +22093,6 @@
"refresh": "Odśwież",
"invalidSqlSyntax": "Nieprawidłowa składnia SQL",
"pleaseWaitWhileLoadingStreams": "Proszę czekać podczas ładowania strumieni...",
"copied": "Skopiowano!",
"copyDetails": "Kopiuj szczegóły",
"accessRequiredTitle": "Wymagany dostęp",
"accessRequiredMessage": "Niektóre sekcje nie mogły zostać wczytane, ponieważ nie masz wymaganych uprawnień. Skontaktuj się z administratorem, jeśli uważasz, że powinieneś mieć do nich dostęp.",
"resourceFallback": "Zasób",
"resources": {
"dashboards": "Pulpity",
"alerts": "Alerty",
"streams": "Strumienie",
"functions": "Funkcje",
"savedviews": "Zapisane widoki",
"users": "Użytkownicy",
"roles": "Role",
"organizations": "Organizacje",
"pipelines": "Potoki",
"destinations": "Miejsca docelowe",
"templates": "Szablony",
"reports": "Raporty",
"llmModelPricing": "Cennik modeli LLM",
"passcode": "Token pozyskiwania danych"
},
"noPermissionToChangeFavorites": "Nie masz uprawnień do zmiany ulubionych",
"couldNotUpdateFavoriteDashboards": "Nie udało się zaktualizować ulubionych pulpitów"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Executar a mesma consulta novamente"
}
},
"accessBoundary": {
"copyDetails": "Copiar detalhes",
"copied": "Copiado!",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alertas",
"streams": "Streams",
"functions": "Funções",
"savedviews": "Visualizações guardadas",
"users": "Utilizadores",
"roles": "Funções",
"organizations": "Organizações",
"pipelines": "Pipelines",
"destinations": "Destinos",
"templates": "Modelos",
"reports": "Relatórios",
"llmModelPricing": "Preços de Modelos LLM",
"passcode": "Token de ingestão",
"ai": "IA",
"downtimes": "Janelas de manutenção",
"fleetConfigs": "Configurações de frota",
"publicDashboards": "Dashboards públicos",
"groups": "Grupos",
"serviceAccounts": "Contas de serviço",
"cipherKeys": "Chaves de cifra",
"regexPatterns": "Padrões regex"
},
"resourcesInline": {
"dashboards": "dashboards",
"alerts": "alertas",
"streams": "streams",
"functions": "funções",
"savedviews": "visualizações guardadas",
"users": "utilizadores",
"roles": "funções",
"organizations": "organizações",
"pipelines": "pipelines",
"destinations": "destinos",
"templates": "modelos",
"reports": "relatórios",
"llmModelPricing": "preços de modelos LLM",
"passcode": "token de ingestão",
"ai": "IA",
"downtimes": "janelas de manutenção",
"fleetConfigs": "configurações de frota",
"publicDashboards": "dashboards públicos",
"groups": "grupos",
"serviceAccounts": "contas de serviço",
"cipherKeys": "chaves de cifra",
"regexPatterns": "padrões regex",
"unnamed": "este recurso"
},
"denied": {
"view": "Não pode ver {resource}",
"create": "Não pode criar {resource}",
"edit": "Não pode editar {resource}",
"delete": "Não pode eliminar {resource}",
"missing": "Falta à sua função a permissão {permission} em {resource}.",
"parts": "Não pode ver {count} parte desta página | Não pode ver {count} partes desta página",
"missingIntro": "Faltam à sua função estas permissões:",
"more": "mais {count}",
"showLess": "Mostrar menos"
},
"chip": {
"label": "Acesso negado",
"ariaLabel": "Acesso negado: {title}",
"popoverLabel": "Partes ocultas desta página"
}
},
"common": {
"dismiss": "Dispensar",
"exitFullscreen": "Sair do ecrã inteiro",
@@ -6978,7 +7047,6 @@
"publishFailed": "Falha ao publicar",
"revokedToast": "Link público revogado e excluído",
"revokeFailed": "Falha ao revogar e excluir o link",
"noPublishPermission": "Você não tem permissão para ver os links públicos deste painel. Peça a um administrador a permissão de Painéis Públicos.",
"linkCopied": "Link copiado",
"copyLink": "Copiar link",
"expired": "Este link expirou. Peça à pessoa que o compartilhou para estendê-lo."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Rever e aplicar filtros aos Traces",
"spansLabel": "spans",
"errorsLabel": "erros",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Spans com erro, mais profundos primeiro"
},
"previousMatch": "Correspondência anterior",
"nextMatch": "Próxima correspondência",
"searchInSpans": "Pesquisar em spans",
@@ -15440,6 +15512,22 @@
"roleSystem": "Sistema",
"roleTool": "Ferramenta"
},
"results": {
"tracesWithMatchingSpan": "{count} trace com um span correspondente | {count} traces com um span correspondente",
"spansColumnTooltip": "Total de spans no trace",
"fieldValueCountsCaption": "As contagens correspondem a spans com este valor, no intervalo atual",
"errorColumn": "Erro",
"eventsColumn": "Eventos",
"eventExceptions": "{count} exceção | {count} exceções",
"eventLogs": "{count} log | {count} logs",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Eventos em {operation}",
"eventsHoverOrder": "{count}, por ordem temporal",
"eventKindException": "exceção",
"eventKindLog": "log",
"eventOffset": "+{offset}",
"moreEvents": "+{count} mais"
},
"tableColumns": {
"service": "Serviço",
"operationName": "Nome da operação",
@@ -22005,27 +22093,6 @@
"refresh": "Atualizar",
"invalidSqlSyntax": "Sintaxe SQL inválida",
"pleaseWaitWhileLoadingStreams": "Aguarde enquanto os streams são carregados...",
"copied": "Copiado!",
"copyDetails": "Copiar detalhes",
"accessRequiredTitle": "Acesso Necessário",
"accessRequiredMessage": "Algumas secções não puderam carregar porque não tem as permissões necessárias. Contacte o administrador se acredita que deveria ter acesso.",
"resourceFallback": "Recurso",
"resources": {
"dashboards": "Dashboards",
"alerts": "Alertas",
"streams": "Streams",
"functions": "Funções",
"savedviews": "Visualizações guardadas",
"users": "Utilizadores",
"roles": "Funções",
"organizations": "Organizações",
"pipelines": "Pipelines",
"destinations": "Destinos",
"templates": "Modelos",
"reports": "Relatórios",
"llmModelPricing": "Preços de Modelos LLM",
"passcode": "Token de ingestão"
},
"noPermissionToChangeFavorites": "Não tem permissão para alterar favoritos",
"couldNotUpdateFavoriteDashboards": "Não foi possível atualizar os dashboards favoritos"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Запустить тот же запрос ещё раз"
}
},
"accessBoundary": {
"copyDetails": "Копировать сведения",
"copied": "Скопировано!",
"resources": {
"dashboards": "Дашборды",
"alerts": "Оповещения",
"streams": "Потоки",
"functions": "Функции",
"savedviews": "Сохранённые представления",
"users": "Пользователи",
"roles": "Роли",
"organizations": "Организации",
"pipelines": "Конвейеры",
"destinations": "Получатели",
"templates": "Шаблоны",
"reports": "Отчёты",
"llmModelPricing": "Цены на модели LLM",
"passcode": "Токен приема данных",
"ai": "ИИ",
"downtimes": "Окна обслуживания",
"fleetConfigs": "Конфигурации парка",
"publicDashboards": "Публичные дашборды",
"groups": "Группы",
"serviceAccounts": "Сервисные аккаунты",
"cipherKeys": "Ключи шифрования",
"regexPatterns": "Регулярные выражения"
},
"resourcesInline": {
"dashboards": "дашборды",
"alerts": "оповещения",
"streams": "потоки",
"functions": "функции",
"savedviews": "сохранённые представления",
"users": "пользователи",
"roles": "роли",
"organizations": "организации",
"pipelines": "конвейеры",
"destinations": "получатели",
"templates": "шаблоны",
"reports": "отчёты",
"llmModelPricing": "цены на модели LLM",
"passcode": "токен приема данных",
"ai": "ИИ",
"downtimes": "окна обслуживания",
"fleetConfigs": "конфигурации парка",
"publicDashboards": "публичные дашборды",
"groups": "группы",
"serviceAccounts": "сервисные аккаунты",
"cipherKeys": "ключи шифрования",
"regexPatterns": "регулярные выражения",
"unnamed": "этот ресурс"
},
"denied": {
"view": "Вы не можете просматривать {resource}",
"create": "Вы не можете создавать «{resource}»",
"edit": "Вы не можете изменять «{resource}»",
"delete": "Вы не можете удалять «{resource}»",
"missing": "У вашей роли нет разрешения «{permission}» для «{resource}».",
"parts": "Вы не можете просматривать {count} часть этой страницы | Вы не можете просматривать части этой страницы ({count})",
"missingIntro": "У вашей роли нет этих разрешений:",
"more": "ещё {count}",
"showLess": "Свернуть"
},
"chip": {
"label": "Доступ запрещён",
"ariaLabel": "Доступ запрещён: {title}",
"popoverLabel": "Скрытые части этой страницы"
}
},
"common": {
"dismiss": "Закрыть",
"exitFullscreen": "Выйти из полноэкранного режима",
@@ -6978,7 +7047,6 @@
"publishFailed": "Не удалось опубликовать",
"revokedToast": "Публичная ссылка отозвана и удалена",
"revokeFailed": "Не удалось отозвать и удалить ссылку",
"noPublishPermission": "У вас нет разрешения на просмотр публичных ссылок этого дашборда. Попросите администратора предоставить разрешение «Публичные дашборды».",
"linkCopied": "Ссылка скопирована",
"copyLink": "Копировать ссылку",
"expired": "Срок действия этой ссылки истёк. Попросите того, кто ею поделился, продлить его."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Просмотреть и применить фильтры к трейсам",
"spansLabel": "спаны",
"errorsLabel": "ошибки",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Спаны с ошибками, сначала самые глубокие"
},
"previousMatch": "Предыдущее совпадение",
"nextMatch": "Следующее совпадение",
"searchInSpans": "Искать в спанах",
@@ -15440,6 +15512,22 @@
"roleSystem": "Система",
"roleTool": "Инструмент"
},
"results": {
"tracesWithMatchingSpan": "{count} трейс с совпадающим спаном | {count} трейсов с совпадающим спаном",
"spansColumnTooltip": "Всего спанов в трейсе",
"fieldValueCountsCaption": "Показано количество спанов с этим значением в текущем диапазоне",
"errorColumn": "Ошибка",
"eventsColumn": "События",
"eventExceptions": "{count} исключение | {count} исключений",
"eventLogs": "{count} лог | {count} логов",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "События для {operation}",
"eventsHoverOrder": "{count}, в хронологическом порядке",
"eventKindException": "исключение",
"eventKindLog": "лог",
"eventOffset": "+{offset}",
"moreEvents": "ещё {count}"
},
"tableColumns": {
"service": "Сервис",
"operationName": "Название операции",
@@ -22005,27 +22093,6 @@
"refresh": "Обновить",
"invalidSqlSyntax": "Недопустимый синтаксис SQL",
"pleaseWaitWhileLoadingStreams": "Подождите, идёт загрузка потоков...",
"copied": "Скопировано!",
"copyDetails": "Копировать сведения",
"accessRequiredTitle": "Требуется доступ",
"accessRequiredMessage": "Некоторые разделы не удалось загрузить, так как у вас нет необходимых разрешений. Свяжитесь с администратором, если вы считаете, что у вас должен быть доступ.",
"resourceFallback": "Ресурс",
"resources": {
"dashboards": "Дашборды",
"alerts": "Оповещения",
"streams": "Потоки",
"functions": "Функции",
"savedviews": "Сохранённые представления",
"users": "Пользователи",
"roles": "Роли",
"organizations": "Организации",
"pipelines": "Конвейеры",
"destinations": "Получатели",
"templates": "Шаблоны",
"reports": "Отчёты",
"llmModelPricing": "Цены на модели LLM",
"passcode": "Токен приема данных"
},
"noPermissionToChangeFavorites": "У вас нет разрешения изменять избранное",
"couldNotUpdateFavoriteDashboards": "Не удалось обновить избранные дашборды"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Aynı sorguyu tekrar çalıştır"
}
},
"accessBoundary": {
"copyDetails": "Ayrıntıları kopyala",
"copied": "Kopyalandı!",
"resources": {
"dashboards": "Panolar",
"alerts": "Uyarılar",
"streams": "Akışlar",
"functions": "Fonksiyonlar",
"savedviews": "Kaydedilmiş Görünümler",
"users": "Kullanıcılar",
"roles": "Roller",
"organizations": "Kuruluşlar",
"pipelines": "İşlem hatları",
"destinations": "Hedefler",
"templates": "Şablonlar",
"reports": "Raporlar",
"llmModelPricing": "LLM Model Fiyatlandırması",
"passcode": "Veri Alımı Token'ı",
"ai": "Yapay zekâ",
"downtimes": "Bakım pencereleri",
"fleetConfigs": "Filo yapılandırmaları",
"publicDashboards": "Herkese açık panolar",
"groups": "Gruplar",
"serviceAccounts": "Hizmet hesapları",
"cipherKeys": "Şifreleme anahtarları",
"regexPatterns": "Regex desenleri"
},
"resourcesInline": {
"dashboards": "panolar",
"alerts": "uyarılar",
"streams": "akışlar",
"functions": "fonksiyonlar",
"savedviews": "kaydedilmiş görünümler",
"users": "kullanıcılar",
"roles": "roller",
"organizations": "kuruluşlar",
"pipelines": "işlem hatları",
"destinations": "hedefler",
"templates": "şablonlar",
"reports": "raporlar",
"llmModelPricing": "LLM model fiyatlandırması",
"passcode": "veri alımı token'ı",
"ai": "yapay zekâ",
"downtimes": "bakım pencereleri",
"fleetConfigs": "filo yapılandırmaları",
"publicDashboards": "herkese açık panolar",
"groups": "gruplar",
"serviceAccounts": "hizmet hesapları",
"cipherKeys": "şifreleme anahtarları",
"regexPatterns": "regex desenleri",
"unnamed": "bu kaynak"
},
"denied": {
"view": "{resource} görüntüleyemezsiniz",
"create": "{resource} oluşturamazsınız",
"edit": "{resource} düzenleyemezsiniz",
"delete": "{resource} silemezsiniz",
"missing": "Rolünüzde {resource} için {permission} izni eksik.",
"parts": "Bu sayfanın {count} bölümünü görüntüleyemezsiniz | Bu sayfanın {count} bölümünü görüntüleyemezsiniz",
"missingIntro": "Rolünüzde şu izinler eksik:",
"more": "{count} tane daha",
"showLess": "Daha az göster"
},
"chip": {
"label": "Erişim reddedildi",
"ariaLabel": "Erişim reddedildi: {title}",
"popoverLabel": "Bu sayfanın gizli bölümleri"
}
},
"common": {
"dismiss": "Kapat",
"exitFullscreen": "Tam ekrandan çık",
@@ -6978,7 +7047,6 @@
"publishFailed": "Yayımlanamadı",
"revokedToast": "Genel bağlantı iptal edildi ve silindi",
"revokeFailed": "Bağlantı iptal edilemedi ve silinemedi",
"noPublishPermission": "Bu gösterge panosunun genel bağlantılarını görüntüleme izniniz yok. Yöneticiden Genel Gösterge Panoları iznini isteyin.",
"linkCopied": "Bağlantı kopyalandı",
"copyLink": "Bağlantıyı kopyala",
"expired": "Bu bağlantının süresi dolmuş. Paylaşan kişiden süresini uzatmasını isteyin."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Filtreleri gözden geçir ve İzlere uygula",
"spansLabel": "spanlar",
"errorsLabel": "hatalar",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Hata span'leri, en derin önce"
},
"previousMatch": "Önceki eşleşme",
"nextMatch": "Sonraki eşleşme",
"searchInSpans": "Spanlarda ara",
@@ -15440,6 +15512,22 @@
"roleSystem": "Sistem",
"roleTool": "Araç"
},
"results": {
"tracesWithMatchingSpan": "{count} eşleşen span içeren iz | {count} eşleşen span içeren iz",
"spansColumnTooltip": "İzdeki toplam span",
"fieldValueCountsCaption": "Sayımlar, geçerli aralıkta bu değere sahip span sayısıdır",
"errorColumn": "Hata",
"eventsColumn": "Olaylar",
"eventExceptions": "{count} istisna | {count} istisna",
"eventLogs": "{count} günlük | {count} günlük",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "{operation} üzerindeki olaylar",
"eventsHoverOrder": "{count}, zaman sırasına göre",
"eventKindException": "istisna",
"eventKindLog": "günlük",
"eventOffset": "+{offset}",
"moreEvents": "+{count} daha"
},
"tableColumns": {
"service": "Hizmet",
"operationName": "İşlem Adı",
@@ -22005,27 +22093,6 @@
"refresh": "Yenile",
"invalidSqlSyntax": "Geçersiz SQL Sözdizimi",
"pleaseWaitWhileLoadingStreams": "Akışlar yüklenirken lütfen bekleyin...",
"copied": "Kopyalandı!",
"copyDetails": "Ayrıntıları kopyala",
"accessRequiredTitle": "Erişim Gerekli",
"accessRequiredMessage": "Bazı bölümler yüklenemedi çünkü gerekli izinlere sahip değilsiniz. Erişiminiz olması gerektiğini düşünüyorsanız yöneticinize başvurun.",
"resourceFallback": "Kaynak",
"resources": {
"dashboards": "Panolar",
"alerts": "Uyarılar",
"streams": "Akışlar",
"functions": "Fonksiyonlar",
"savedviews": "Kaydedilmiş Görünümler",
"users": "Kullanıcılar",
"roles": "Roller",
"organizations": "Kuruluşlar",
"pipelines": "İşlem hatları",
"destinations": "Hedefler",
"templates": "Şablonlar",
"reports": "Raporlar",
"llmModelPricing": "LLM Model Fiyatlandırması",
"passcode": "Veri Alımı Token'ı"
},
"noPermissionToChangeFavorites": "Favorileri değiştirme izniniz yok",
"couldNotUpdateFavoriteDashboards": "Favori panolar güncellenemedi"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "Chạy lại cùng truy vấn đó"
}
},
"accessBoundary": {
"copyDetails": "Sao chép chi tiết",
"copied": "Đã sao chép!",
"resources": {
"dashboards": "Bảng điều khiển",
"alerts": "Cảnh báo",
"streams": "Stream",
"functions": "Hàm",
"savedviews": "Chế độ xem đã lưu",
"users": "Người dùng",
"roles": "Vai trò",
"organizations": "Tổ chức",
"pipelines": "Pipeline",
"destinations": "Đích",
"templates": "Mẫu",
"reports": "Báo cáo",
"llmModelPricing": "Giá mô hình LLM",
"passcode": "Token tiếp nhận dữ liệu",
"ai": "AI",
"downtimes": "Thời gian bảo trì",
"fleetConfigs": "Cấu hình fleet",
"publicDashboards": "Bảng điều khiển công khai",
"groups": "Nhóm",
"serviceAccounts": "Tài khoản dịch vụ",
"cipherKeys": "Khóa mã hóa",
"regexPatterns": "Mẫu regex"
},
"resourcesInline": {
"dashboards": "bảng điều khiển",
"alerts": "cảnh báo",
"streams": "stream",
"functions": "hàm",
"savedviews": "chế độ xem đã lưu",
"users": "người dùng",
"roles": "vai trò",
"organizations": "tổ chức",
"pipelines": "pipeline",
"destinations": "đích",
"templates": "mẫu",
"reports": "báo cáo",
"llmModelPricing": "giá mô hình LLM",
"passcode": "token tiếp nhận dữ liệu",
"ai": "AI",
"downtimes": "thời gian bảo trì",
"fleetConfigs": "cấu hình fleet",
"publicDashboards": "bảng điều khiển công khai",
"groups": "nhóm",
"serviceAccounts": "tài khoản dịch vụ",
"cipherKeys": "khóa mã hóa",
"regexPatterns": "mẫu regex",
"unnamed": "tài nguyên này"
},
"denied": {
"view": "Bạn không thể xem {resource}",
"create": "Bạn không thể tạo {resource}",
"edit": "Bạn không thể chỉnh sửa {resource}",
"delete": "Bạn không thể xóa {resource}",
"missing": "Vai trò của bạn thiếu quyền {permission} trên {resource}.",
"parts": "Bạn không thể xem {count} phần của trang này | Bạn không thể xem {count} phần của trang này",
"missingIntro": "Vai trò của bạn thiếu các quyền sau:",
"more": "{count} mục khác",
"showLess": "Thu gọn"
},
"chip": {
"label": "Truy cập bị từ chối",
"ariaLabel": "Truy cập bị từ chối: {title}",
"popoverLabel": "Các phần bị ẩn của trang này"
}
},
"common": {
"dismiss": "Bỏ qua",
"exitFullscreen": "Thoát toàn màn hình",
@@ -6978,7 +7047,6 @@
"publishFailed": "Công bố thất bại",
"revokedToast": "Đã thu hồi và xóa liên kết công khai",
"revokeFailed": "Thu hồi và xóa liên kết thất bại",
"noPublishPermission": "Bạn không có quyền xem các liên kết công khai của bảng điều khiển này. Hãy yêu cầu quản trị viên cấp quyền Public Dashboards.",
"linkCopied": "Đã sao chép liên kết",
"copyLink": "Sao chép liên kết",
"expired": "Liên kết này đã hết hạn. Hãy yêu cầu người đã chia sẻ liên kết gia hạn nó."
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "Xem lại và áp dụng bộ lọc cho Trace",
"spansLabel": "span",
"errorsLabel": "lỗi",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "Span lỗi, sâu nhất trước"
},
"previousMatch": "Kết quả trước",
"nextMatch": "Kết quả tiếp theo",
"searchInSpans": "Tìm kiếm trong span",
@@ -15440,6 +15512,22 @@
"roleSystem": "Hệ thống",
"roleTool": "Công cụ"
},
"results": {
"tracesWithMatchingSpan": "{count} trace có span khớp | {count} trace có span khớp",
"spansColumnTooltip": "Tổng số span trong trace",
"fieldValueCountsCaption": "Số đếm là số span có giá trị này, trong khoảng hiện tại",
"errorColumn": "Lỗi",
"eventsColumn": "Sự kiện",
"eventExceptions": "{count} ngoại lệ | {count} ngoại lệ",
"eventLogs": "{count} nhật ký | {count} nhật ký",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "Sự kiện trên {operation}",
"eventsHoverOrder": "{count}, theo thứ tự thời gian",
"eventKindException": "ngoại lệ",
"eventKindLog": "nhật ký",
"eventOffset": "+{offset}",
"moreEvents": "+{count} nữa"
},
"tableColumns": {
"service": "Dịch vụ",
"operationName": "Tên thao tác",
@@ -22005,27 +22093,6 @@
"refresh": "Làm mới",
"invalidSqlSyntax": "Cú pháp SQL không hợp lệ",
"pleaseWaitWhileLoadingStreams": "Vui lòng đợi trong khi tải stream...",
"copied": "Đã sao chép!",
"copyDetails": "Sao chép chi tiết",
"accessRequiredTitle": "Yêu cầu quyền truy cập",
"accessRequiredMessage": "Một số phần không thể tải vì bạn không có quyền cần thiết. Hãy liên hệ với quản trị viên nếu bạn cho rằng mình cần quyền truy cập.",
"resourceFallback": "Tài nguyên",
"resources": {
"dashboards": "Bảng điều khiển",
"alerts": "Cảnh báo",
"streams": "Stream",
"functions": "Hàm",
"savedviews": "Chế độ xem đã lưu",
"users": "Người dùng",
"roles": "Vai trò",
"organizations": "Tổ chức",
"pipelines": "Pipeline",
"destinations": "Đích",
"templates": "Mẫu",
"reports": "Báo cáo",
"llmModelPricing": "Giá mô hình LLM",
"passcode": "Token tiếp nhận dữ liệu"
},
"noPermissionToChangeFavorites": "Bạn không có quyền thay đổi mục yêu thích",
"couldNotUpdateFavoriteDashboards": "Không thể cập nhật bảng điều khiển yêu thích"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "再次运行同一查询"
}
},
"accessBoundary": {
"copyDetails": "复制详情",
"copied": "已复制!",
"resources": {
"dashboards": "仪表盘",
"alerts": "告警",
"streams": "数据流",
"functions": "函数",
"savedviews": "已保存视图",
"users": "用户",
"roles": "角色",
"organizations": "组织",
"pipelines": "流水线",
"destinations": "目标",
"templates": "模板",
"reports": "报表",
"llmModelPricing": "LLM 模型定价",
"passcode": "数据摄取令牌",
"ai": "AI",
"downtimes": "停机时间",
"fleetConfigs": "机群配置",
"publicDashboards": "公开仪表盘",
"groups": "组",
"serviceAccounts": "服务账号",
"cipherKeys": "加密密钥",
"regexPatterns": "正则表达式模式"
},
"resourcesInline": {
"dashboards": "仪表盘",
"alerts": "告警",
"streams": "数据流",
"functions": "函数",
"savedviews": "已保存视图",
"users": "用户",
"roles": "角色",
"organizations": "组织",
"pipelines": "流水线",
"destinations": "目标",
"templates": "模板",
"reports": "报表",
"llmModelPricing": "LLM 模型定价",
"passcode": "数据摄取令牌",
"ai": "AI",
"downtimes": "停机时间",
"fleetConfigs": "机群配置",
"publicDashboards": "公开仪表盘",
"groups": "组",
"serviceAccounts": "服务账号",
"cipherKeys": "加密密钥",
"regexPatterns": "正则表达式模式",
"unnamed": "此资源"
},
"denied": {
"view": "您无法查看{resource}",
"create": "您无法创建{resource}",
"edit": "您无法编辑{resource}",
"delete": "您无法删除{resource}",
"missing": "您的角色缺少对{resource}的“{permission}”权限。",
"parts": "您无法查看此页面的 {count} 个部分 | 您无法查看此页面的 {count} 个部分",
"missingIntro": "您的角色缺少以下权限:",
"more": "另外 {count} 项",
"showLess": "收起"
},
"chip": {
"label": "访问被拒绝",
"ariaLabel": "访问被拒绝:{title}",
"popoverLabel": "此页面已隐藏的内容"
}
},
"common": {
"dismiss": "关闭",
"exitFullscreen": "退出全屏",
@@ -6978,7 +7047,6 @@
"publishFailed": "发布失败",
"revokedToast": "公开链接已撤销并删除",
"revokeFailed": "撤销并删除链接失败",
"noPublishPermission": "您无权查看此仪表盘的公开链接。请向管理员申请“公开仪表盘”权限。",
"linkCopied": "链接已复制",
"copyLink": "复制链接",
"expired": "此链接已过期。请让分享链接的人延长有效期。"
@@ -15190,6 +15258,10 @@
"reviewAndApplyFilters": "查看筛选条件并将其应用于 Traces",
"spansLabel": "Span",
"errorsLabel": "错误",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "错误 Span,最深优先"
},
"previousMatch": "上一个匹配项",
"nextMatch": "下一个匹配项",
"searchInSpans": "在 Span 中搜索",
@@ -15440,6 +15512,22 @@
"roleSystem": "系统",
"roleTool": "工具"
},
"results": {
"tracesWithMatchingSpan": "{count} 条包含匹配 Span 的追踪 | {count} 条包含匹配 Span 的追踪",
"spansColumnTooltip": "追踪中的 Span 总数",
"fieldValueCountsCaption": "计数表示当前范围内具有该值的 Span 数量",
"errorColumn": "错误",
"eventsColumn": "事件",
"eventExceptions": "{count} 个异常 | {count} 个异常",
"eventLogs": "{count} 条日志 | {count} 条日志",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "{operation} 上的事件",
"eventsHoverOrder": "{count},按时间顺序",
"eventKindException": "异常",
"eventKindLog": "日志",
"eventOffset": "+{offset}",
"moreEvents": "还有 {count} 个"
},
"tableColumns": {
"service": "服务",
"operationName": "操作名称",
@@ -22005,27 +22093,6 @@
"refresh": "刷新",
"invalidSqlSyntax": "无效的 SQL 语法",
"pleaseWaitWhileLoadingStreams": "正在加载流,请稍候...",
"copied": "已复制!",
"copyDetails": "复制详情",
"accessRequiredTitle": "需要访问权限",
"accessRequiredMessage": "某些部分无法加载,因为您没有所需的权限。如果您认为自己应该拥有访问权限,请联系管理员。",
"resourceFallback": "资源",
"resources": {
"dashboards": "仪表盘",
"alerts": "告警",
"streams": "数据流",
"functions": "函数",
"savedviews": "已保存视图",
"users": "用户",
"roles": "角色",
"organizations": "组织",
"pipelines": "流水线",
"destinations": "目标",
"templates": "模板",
"reports": "报表",
"llmModelPricing": "LLM 模型定价",
"passcode": "数据摄取令牌"
},
"noPermissionToChangeFavorites": "您没有权限更改收藏",
"couldNotUpdateFavoriteDashboards": "无法更新收藏的仪表盘"
},
+89 -22
View File
@@ -613,6 +613,75 @@
"actionDesc": "再次執行相同的查詢"
}
},
"accessBoundary": {
"copyDetails": "複製詳細資訊",
"copied": "已複製!",
"resources": {
"dashboards": "儀表板",
"alerts": "警示",
"streams": "串流",
"functions": "函式",
"savedviews": "已儲存的檢視",
"users": "使用者",
"roles": "角色",
"organizations": "組織",
"pipelines": "管線",
"destinations": "目標",
"templates": "範本",
"reports": "報表",
"llmModelPricing": "LLM 模型定價",
"passcode": "資料擷取權杖",
"ai": "AI",
"downtimes": "停機時段",
"fleetConfigs": "Fleet 設定",
"publicDashboards": "公開儀表板",
"groups": "群組",
"serviceAccounts": "服務帳戶",
"cipherKeys": "加密金鑰",
"regexPatterns": "正規表示式模式"
},
"resourcesInline": {
"dashboards": "儀表板",
"alerts": "警示",
"streams": "串流",
"functions": "函式",
"savedviews": "已儲存的檢視",
"users": "使用者",
"roles": "角色",
"organizations": "組織",
"pipelines": "管線",
"destinations": "目標",
"templates": "範本",
"reports": "報表",
"llmModelPricing": "LLM 模型定價",
"passcode": "資料擷取權杖",
"ai": "AI",
"downtimes": "停機時段",
"fleetConfigs": "Fleet 設定",
"publicDashboards": "公開儀表板",
"groups": "群組",
"serviceAccounts": "服務帳戶",
"cipherKeys": "加密金鑰",
"regexPatterns": "正規表示式模式",
"unnamed": "此資源"
},
"denied": {
"view": "您無法檢視{resource}",
"create": "您無法建立{resource}",
"edit": "您無法編輯{resource}",
"delete": "您無法刪除{resource}",
"missing": "您的角色缺少對{resource}的「{permission}」權限。",
"parts": "您無法檢視此頁面的 {count} 個部分 | 您無法檢視此頁面的 {count} 個部分",
"missingIntro": "您的角色缺少以下權限:",
"more": "另外 {count} 項",
"showLess": "收合"
},
"chip": {
"label": "存取遭拒",
"ariaLabel": "存取遭拒:{title}",
"popoverLabel": "此頁面已隱藏的內容"
}
},
"common": {
"dismiss": "關閉",
"exitFullscreen": "退出全螢幕",
@@ -6978,7 +7047,6 @@
"publishFailed": "發布失敗",
"revokedToast": "公開連結已撤銷並刪除",
"revokeFailed": "撤銷並刪除連結失敗",
"noPublishPermission": "您沒有權限查看此儀表板的公開連結。請向管理員申請「公開儀表板」權限。",
"linkCopied": "已複製連結",
"copyLink": "複製連結",
"expired": "此連結已過期。請聯絡分享此連結的人延長期限。"
@@ -15185,6 +15253,10 @@
"reviewAndApplyFilters": "檢閱並套用篩選條件至追蹤",
"spansLabel": "spans",
"errorsLabel": "錯誤",
"errorStrip": {
"spanLabel": "{service} · {operation}",
"menuTitle": "錯誤 Span,最深的優先"
},
"previousMatch": "上一個符合",
"nextMatch": "下一個符合",
"searchInSpans": "在 spans 中搜尋",
@@ -15435,6 +15507,22 @@
"roleSystem": "系統",
"roleTool": "工具"
},
"results": {
"tracesWithMatchingSpan": "{count} 個含有相符 Span 的追蹤 | {count} 個含有相符 Span 的追蹤",
"spansColumnTooltip": "追蹤中的 Span 總數",
"fieldValueCountsCaption": "計數為目前時間範圍內具有此值的 Span 數",
"errorColumn": "錯誤",
"eventsColumn": "事件",
"eventExceptions": "{count} 個例外 | {count} 個例外",
"eventLogs": "{count} 筆日誌 | {count} 筆日誌",
"eventCounts": "{exceptions} · {logs}",
"eventsHoverTitle": "{operation} 上的事件",
"eventsHoverOrder": "{count},依時間順序",
"eventKindException": "例外",
"eventKindLog": "日誌",
"eventOffset": "+{offset}",
"moreEvents": "還有 {count} 個"
},
"tableColumns": {
"service": "服務",
"operationName": "作業名稱",
@@ -22000,27 +22088,6 @@
"refresh": "重新整理",
"invalidSqlSyntax": "無效的 SQL 語法",
"pleaseWaitWhileLoadingStreams": "正在載入串流,請稍候...",
"copied": "已複製!",
"copyDetails": "複製詳細資訊",
"accessRequiredTitle": "需要存取權限",
"accessRequiredMessage": "某些區段無法載入,因為您沒有必要的權限。如果您認為您應該有存取權,請聯絡您的管理員。",
"resourceFallback": "資源",
"resources": {
"dashboards": "儀表板",
"alerts": "警示",
"streams": "串流",
"functions": "函式",
"savedviews": "已儲存的檢視",
"users": "使用者",
"roles": "角色",
"organizations": "組織",
"pipelines": "管線",
"destinations": "目標",
"templates": "範本",
"reports": "報表",
"llmModelPricing": "LLM 模型定價",
"passcode": "資料擷取權杖"
},
"noPermissionToChangeFavorites": "您沒有權限變更我的最愛",
"couldNotUpdateFavoriteDashboards": "無法更新我的最愛儀表板"
},
+3
View File
@@ -568,6 +568,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="toolbarRightRef"
class="flex flex-shrink-0 items-center gap-1 max-md:w-full max-md:justify-end"
>
<OPageHeaderStatus />
<template v-if="searchObj.meta.showTransformEditor && !shouldMoveShareToMenu">
<FunctionSelector
:function-options="functionOptions"
@@ -1695,6 +1696,7 @@ import useBreakpoint from "@/composables/useBreakpoint";
import DateTime from "@/components/DateTime.vue";
import ShareButton from "@/components/common/ShareButton.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OPageHeaderStatus from "@/lib/core/PageHeader/OPageHeaderStatus.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import RecentSearchesButton from "@/components/queryHistory/RecentSearchesButton.vue";
import RecentSearchesDrawer from "@/components/queryHistory/RecentSearchesDrawer.vue";
@@ -1889,6 +1891,7 @@ const replaceExistingFieldCondition = (
export default defineComponent({
name: "ComponentSearchSearchBar",
components: {
OPageHeaderStatus,
CreateAlertAction,
OSeparator,
OSplitter,
-1
View File
@@ -582,7 +582,6 @@ export default defineComponent({
}
} catch (error: any) {
forbidden.value = error?.response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
toast({
variant: "error",
@@ -99,6 +99,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="flex-1 max-md:hidden" />
<div class="flex shrink-0 items-center gap-2 max-md:ms-auto max-md:gap-1">
<OPageHeaderStatus />
<!-- Explore keeps Apply: a date change there re-queries the whole card grid. -->
<DateTimePickerDashboard
ref="dateTimePickerRef"
@@ -736,6 +737,7 @@ import { isEqual, debounce } from "lodash-es";
import DateTimePickerDashboard from "@/components/DateTimePickerDashboard.vue";
import AutoRefreshInterval from "@/components/AutoRefreshInterval.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OPageHeaderStatus from "@/lib/core/PageHeader/OPageHeaderStatus.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
import OCheckboxGroup from "@/lib/forms/Checkbox/OCheckboxGroup.vue";
@@ -829,6 +831,7 @@ const ROW_HEIGHT = CARD_HEIGHT + ROW_GAP;
export default defineComponent({
name: "MetricsExplorer",
components: {
OPageHeaderStatus,
FirstDataPanel,
DateTimePickerDashboard,
AutoRefreshInterval,
+5
View File
@@ -296,6 +296,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
class="flex items-center gap-1.5 max-lg:w-full max-lg:flex-wrap max-lg:justify-end max-lg:gap-y-1"
>
<OPageHeaderStatus />
<DateTime
ref="dateTimeRef"
auto-apply
@@ -442,6 +443,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Service Graph right toolbar: DateTime, Refresh, Tree/Graph tabs, Layout -->
<div v-if="searchObj.meta.searchMode === 'service-graph'" class="ms-auto max-lg:ms-0">
<div class="flex items-center gap-2 max-lg:w-full max-lg:justify-end max-lg:gap-1.5">
<OPageHeaderStatus />
<DateTime
ref="dateTimeRef"
auto-apply
@@ -495,6 +497,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Services Catalog right toolbar: DateTime, Refresh -->
<div v-if="searchObj.meta.searchMode === 'services-catalog'" class="ms-auto">
<div class="flex items-center gap-2">
<OPageHeaderStatus />
<DateTime
ref="dateTimeRef"
auto-apply
@@ -616,6 +619,7 @@ import ShareButton from "@/components/common/ShareButton.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OPageHeaderStatus from "@/lib/core/PageHeader/OPageHeaderStatus.vue";
import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
@@ -686,6 +690,7 @@ import {
export default defineComponent({
name: "ComponentSearchSearchBar",
components: {
OPageHeaderStatus,
OSeparator,
DateTime,
ShareButton,
-2
View File
@@ -98,8 +98,6 @@ export const downtimesListQuery = (org: string) =>
),
staleTime: LIVE_STALE_TIME,
});
/** The list for surfaces that only name a muting downtime: a 403 there shows no access toast. */
export const downtimesLookupQuery = (org: string) =>
queryOptions({
queryKey: downtimeKeys.lookup(org),
+45 -13
View File
@@ -46,8 +46,17 @@ vi.mock("@/utils/zincutils", () => ({
vi.mock("axios");
const { addUnauthorizedError } = vi.hoisted(() => ({ addUnauthorizedError: vi.fn() }));
vi.mock("@/composables/useUnauthorizedErrorGrouper", () => ({ addUnauthorizedError }));
const { recordForbidden, noteAccessSuccess, currentAccessRouteKey } = vi.hoisted(() => ({
recordForbidden: vi.fn(),
noteAccessSuccess: vi.fn(),
currentAccessRouteKey: vi.fn(() => undefined),
}));
vi.mock("@/composables/useAccessStore", () => ({
recordForbidden,
noteAccessSuccess,
currentAccessRouteKey,
resetAccess: vi.fn(),
}));
import config from "../aws-exports";
import store from "../stores";
@@ -186,6 +195,7 @@ describe("http 403 interceptor", () => {
let onError: (error: any) => Promise<unknown> = () => Promise.resolve();
vi.mocked(axios.create).mockReturnValue({
interceptors: {
request: { use: vi.fn() },
response: {
use: (_ok: unknown, err: (error: any) => Promise<unknown>) => {
onError = err;
@@ -213,18 +223,35 @@ describe("http 403 interceptor", () => {
(config as any).isEnterprise = "false";
});
it("adds a 403 to the Access Required toast", async () => {
it("records a 403 with the access boundary", async () => {
const error = forbidden();
await expect(errorHandler()(error)).rejects.toBe(error);
expect(addUnauthorizedError).toHaveBeenCalledWith(
"http://localhost:5080/api/v2/org/folders/alerts",
);
expect(recordForbidden).toHaveBeenCalledWith({
url: "http://localhost:5080/api/v2/org/folders/alerts",
method: undefined,
routeKey: undefined,
});
});
it("leaves a 403 out of the toast when the request opts out, and still rejects", async () => {
it("stamps a new request with the current page but keeps a retried request's stamp", () => {
let stamp: (config: any) => any = (c) => c;
vi.mocked(axios.create).mockReturnValue({
interceptors: {
request: { use: (fn: (config: any) => any) => (stamp = fn) },
response: { use: vi.fn() },
},
} as any);
http();
currentAccessRouteKey.mockReturnValue("acme|settings" as any);
expect(stamp({}).accessRouteKey).toBe("acme|settings");
expect(stamp({ accessRouteKey: "acme|users" }).accessRouteKey).toBe("acme|users");
currentAccessRouteKey.mockReturnValue(undefined);
});
it("leaves a 403 unrecorded when the request opts out, and still rejects", async () => {
const error = forbidden({ skipAccessToast: true });
await expect(errorHandler()(error)).rejects.toBe(error);
expect(addUnauthorizedError).not.toHaveBeenCalled();
expect(recordForbidden).not.toHaveBeenCalled();
});
});
@@ -237,9 +264,10 @@ describe("403 handling", () => {
});
beforeEach(() => {
addUnauthorizedError.mockClear();
recordForbidden.mockClear();
vi.mocked(axios.create).mockReturnValue({
interceptors: {
request: { use: vi.fn() },
response: { use: (_ok: unknown, rejected: typeof onRejected) => (onRejected = rejected) },
},
} as any);
@@ -255,17 +283,21 @@ describe("403 handling", () => {
["enterprise", "isEnterprise"],
["cloud", "isCloud"],
])(
"on %s, toasts an unflagged 403 and stays silent on a silentForbidden one",
"on %s, records an unflagged 403 and stays silent on a silentForbidden one",
async (_, flag) => {
(config as any)[flag] = "true";
await expect(onRejected(forbidden({}))).rejects.toBeDefined();
expect(addUnauthorizedError).toHaveBeenCalledWith("http://localhost:5080/api/acme/x");
expect(recordForbidden).toHaveBeenCalledWith({
url: "http://localhost:5080/api/acme/x",
method: undefined,
routeKey: undefined,
});
addUnauthorizedError.mockClear();
recordForbidden.mockClear();
const silent = forbidden({ silentForbidden: true });
await expect(onRejected(silent)).rejects.toBe(silent);
expect(addUnauthorizedError).not.toHaveBeenCalled();
expect(recordForbidden).not.toHaveBeenCalled();
},
);
});
+25 -4
View File
@@ -18,16 +18,22 @@ import type { AxiosInstance } from "axios";
import axios from "axios";
import config from "../aws-exports";
import { expireSession } from "@/utils/sessionServices";
import { addUnauthorizedError } from "@/composables/useUnauthorizedErrorGrouper";
import {
currentAccessRouteKey,
noteAccessSuccess,
recordForbidden,
} from "@/composables/useAccessStore";
import { usePasswordReset } from "@/composables/usePasswordReset";
import { isPasswordResetError } from "@/utils/passwordResetErrors";
declare module "axios" {
interface AxiosRequestConfig {
/** The caller renders a 403 itself, so it is left out of the grouped "Access Required" toast. */
/** The caller renders a 403 itself, so the access boundary does not record it. */
skipAccessToast?: boolean;
/** Skip the global Unauthorized toast on a 403; the caller handles the refusal itself. */
/** The caller handles the refusal itself, so the access boundary does not record the 403. */
silentForbidden?: boolean;
/** Route the request started on, so a late 403 is not pinned on the page the user moved to. */
accessRouteKey?: string;
}
}
@@ -99,8 +105,19 @@ const http = ({ headers } = {} as any) => {
headers,
});
instance.interceptors.request.use((requestConfig) => {
// A retry after a token refresh reuses its config, so it keeps the page that first asked.
requestConfig.accessRouteKey ??= currentAccessRouteKey();
return requestConfig;
});
instance.interceptors.response.use(
function (response) {
noteAccessSuccess({
url: response.request?.responseURL || response.config?.url,
method: response.config?.method,
routeKey: response.config?.accessRouteKey,
});
return response;
},
function (error) {
@@ -136,7 +153,11 @@ const http = ({ headers } = {} as any) => {
!error.config?.silentForbidden
) {
const responseUrl = error.request?.responseURL || error.config?.url || "";
addUnauthorizedError(responseUrl);
recordForbidden({
url: responseUrl,
method: error.config?.method,
routeKey: error.config?.accessRouteKey,
});
}
break;
case 404:
-339
View File
@@ -1,339 +0,0 @@
// Copyright 2026 OpenObserve Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the store before importing httpsearch so its state is available
vi.mock("../stores", () => ({
default: {
state: {
API_ENDPOINT: "http://localhost:5080",
},
dispatch: vi.fn(),
},
}));
// Mock the router (httpsearch imports it but does not use it directly)
vi.mock("../router", () => ({
default: {},
}));
// Mock axios so we can control the interceptor chain
vi.mock("axios", () => {
const mockInterceptors = {
request: { use: vi.fn() },
response: { use: vi.fn() },
};
const mockInstance = {
interceptors: mockInterceptors,
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
return {
default: {
create: vi.fn(() => mockInstance),
},
};
});
import axios from "axios";
import store from "../stores";
import httpsearch from "./httpsearch";
describe("httpsearch service", () => {
let mockAxiosInstance: any;
beforeEach(() => {
vi.clearAllMocks();
mockAxiosInstance = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
(axios.create as any).mockReturnValue(mockAxiosInstance);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("factory function", () => {
it("should call axios.create to build an instance", () => {
httpsearch();
expect(axios.create).toHaveBeenCalledTimes(1);
});
it("should pass the store API_ENDPOINT as baseURL", () => {
httpsearch();
expect(axios.create).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "http://localhost:5080" }),
);
});
it("should return the axios instance", () => {
const instance = httpsearch();
expect(instance).toBe(mockAxiosInstance);
});
it("should register a response interceptor on each call", () => {
httpsearch();
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalledTimes(1);
});
it("should register the response interceptor with a success handler and an error handler", () => {
httpsearch();
const [successHandler, errorHandler] =
mockAxiosInstance.interceptors.response.use.mock.calls[0];
expect(typeof successHandler).toBe("function");
expect(typeof errorHandler).toBe("function");
});
it("should create a fresh axios instance on every invocation", () => {
httpsearch();
httpsearch();
httpsearch();
expect(axios.create).toHaveBeenCalledTimes(3);
});
});
describe("response interceptor — success handler", () => {
it("should pass through a successful response unchanged", () => {
httpsearch();
const [successHandler] = mockAxiosInstance.interceptors.response.use.mock.calls[0];
const mockResponse = { status: 200, data: { streams: [] } };
const result = successHandler(mockResponse);
expect(result).toBe(mockResponse);
});
it("should not call axios.create for a successful response", () => {
httpsearch();
const [successHandler] = mockAxiosInstance.interceptors.response.use.mock.calls[0];
vi.clearAllMocks();
successHandler({ status: 200, data: {} });
expect(axios.create).not.toHaveBeenCalled();
});
});
describe("response interceptor — error handler", () => {
let errorHandler: (error: any) => Promise<any>;
beforeEach(() => {
httpsearch();
errorHandler = mockAxiosInstance.interceptors.response.use.mock.calls[0][1];
});
it("should reject with the error when there is no response object", async () => {
const rawError = new Error("Network failure");
await expect(errorHandler(rawError)).rejects.toThrow("Network failure");
});
it("should reject with the error when the error has no status", async () => {
const errorWithoutStatus = { response: { data: {} } };
await expect(errorHandler(errorWithoutStatus)).rejects.toEqual(errorWithoutStatus);
});
describe("400 Bad Request", () => {
it("should call notify with the error message", async () => {
const error = {
response: { status: 400, data: { error: "Invalid query parameter" } },
};
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should fall back to 'Bad Request' when error field is absent", async () => {
const error = { response: { status: 400, data: {} } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should surface the raw server error message for 400", async () => {
const error = {
response: { status: 400, data: { error: "Some bad request detail" } },
};
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should show the notification in the bottom-right with progress", async () => {
const error = { response: { status: 400, data: { error: "Bad param" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
});
describe("401 Unauthorized", () => {
it("should show error notification with error message", async () => {
const error = {
response: { status: 401, data: { error: "Token expired" } },
};
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should fall back to 'Invalid credentials' when error field is absent", async () => {
const error = { response: { status: 401, data: {} } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should dispatch the logout action to the store", async () => {
const error = { response: { status: 401, data: { error: "Unauthorized" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
expect(store.dispatch).toHaveBeenCalledWith("logout");
});
it("clears localStorage but keeps every user's drafts", async () => {
localStorage.setItem("userInfo", "token");
localStorage.setItem("o2_draft:function:o:f:me%40x.io", "{}");
localStorage.setItem("o2_draft_index", "{}");
localStorage.setItem("o2_panel_draft:me@x.io:o:d:p", "{}");
const error = { response: { status: 401, data: {} } };
await expect(errorHandler(error)).rejects.toEqual(error);
expect(localStorage.getItem("userInfo")).toBeNull();
expect(Object.keys(localStorage).sort()).toEqual([
"o2_draft:function:o:f:me%40x.io",
"o2_draft_index",
"o2_panel_draft:me@x.io:o:d:p",
]);
localStorage.clear();
});
it("should show the notification in the bottom-right with progress", async () => {
const error = { response: { status: 401, data: { error: "Unauthorized" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
});
describe("404 Not Found", () => {
it("should show error notification with error message", async () => {
const error = {
response: { status: 404, data: { error: "Stream not found" } },
};
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should fall back to 'Not Found' when error field is absent", async () => {
const error = { response: { status: 404, data: {} } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should show the notification in the bottom-right with progress", async () => {
const error = { response: { status: 404, data: { error: "Not Found" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
});
describe("500 Internal Server Error", () => {
it("should show the server error message notification", async () => {
const error = {
response: { status: 500, data: { error: "Database connection failed" } },
};
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should fall back to 'Internal ServerError' when error field is absent", async () => {
const error = { response: { status: 500, data: {} } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should surface the raw server error message for 500", async () => {
const error = {
response: { status: 500, data: { error: "Something went wrong" } },
};
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should show the notification in the bottom-right with progress", async () => {
const error = { response: { status: 500, data: { error: "Server error" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
});
describe("unhandled status codes", () => {
it("should not call notify for a 403 error", async () => {
const error = { response: { status: 403, data: { error: "Forbidden" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should not call notify for a 429 error", async () => {
const error = { response: { status: 429, data: { error: "Too Many Requests" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should not call notify for a 503 error", async () => {
const error = { response: { status: 503, data: { error: "Service Unavailable" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
it("should still reject the promise for unhandled status codes", async () => {
const error = { response: { status: 422, data: { error: "Unprocessable Entity" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
});
});
describe("always rejects", () => {
it("should always return a rejected promise regardless of status", async () => {
const statuses = [400, 401, 404, 500, 200, 201, 403];
for (const status of statuses) {
const error = { response: { status, data: { error: "msg" } } };
await expect(errorHandler(error)).rejects.toEqual(error);
}
});
});
});
});
-72
View File
@@ -1,72 +0,0 @@
// 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 store from "../stores";
import axios from "axios";
import { toast } from "@/lib/feedback/Toast/useToast";
import { gt } from "@/types/i18n";
import { expireSession } from "@/utils/sessionServices";
const http = () => {
const instance = axios.create({
// timeout: 10000,
baseURL: store.state.API_ENDPOINT,
headers: {},
});
instance.interceptors.response.use(
function (response) {
return response;
},
function (error) {
if (error && error.response && error.response.status) {
switch (error.response.status) {
case 400:
toast({
message: error.response.data["error"] || gt("common.badRequest"),
variant: "error",
});
break;
case 401:
toast({
message: error.response.data["error"] || gt("common.invalidCredentials"),
variant: "error",
});
expireSession(store, { clearLocalStorage: true });
break;
case 404:
toast({
message: error.response.data["error"] || gt("common.notFound"),
variant: "error",
});
break;
case 500:
toast({
message: error.response.data["error"] || gt("common.internalServerError"),
variant: "error",
});
break;
default:
// noop
}
}
return Promise.reject(error);
},
);
return instance;
};
export default http;
-2
View File
@@ -40,8 +40,6 @@ export const reportsQuery = (org: string, filters: ReportListFilters) =>
).data ?? [],
staleTime: NORMAL_STALE_TIME,
});
/** Background callers pass `silentForbidden` so a 403 stays out of the grouped access toast. */
export const reportByIdQuery = (
org: string,
reportId: string,
-1
View File
@@ -33,7 +33,6 @@ const client = (http as unknown as ReturnType<typeof vi.fn>)();
const ORG = "acme";
const ID = "inst-1";
const BASE = `/api/${ORG}/slack`;
// The pages calling these render a 403 themselves, so it stays out of the grouped access toast.
const SILENT_403 = { silentForbidden: true };
type Method = "get" | "post" | "put" | "delete";
-2
View File
@@ -60,8 +60,6 @@ import type {
SlackTestResponse,
SlackUninstallResponse,
} from "@/types/slack";
/** Background callers pass `silentForbidden` so a 403 stays out of the grouped access toast. */
export interface SlackRequestOptions {
silentForbidden?: boolean;
}
+2
View File
@@ -18,5 +18,7 @@ import "vue-router";
declare module "vue-router" {
interface RouteMeta {
allowOnEmptyData?: boolean;
/** Resource key of this route's main list; a 403 on it replaces the page with the no-access state. */
accessResource?: string;
}
}
+3 -1
View File
@@ -16,6 +16,7 @@
import { openobserveRum } from "@openobserve/browser-rum";
import { closeSearchSocket } from "@/composables/useSearchWebSocket";
import { dismissAll } from "@/lib/feedback/Toast/useToast";
import { resetAccess } from "@/composables/useAccessStore";
import { PANEL_DRAFT_PREFIX } from "@/composables/dashboard/usePanelDraft";
import { EDITOR_DRAFT_INDEX_KEY, EDITOR_DRAFT_PREFIX } from "@/utils/drafts/editorDraftStore";
import { DRAFT_SIGNED_OUT_KEY } from "@/utils/drafts/draftSession";
@@ -36,12 +37,13 @@ interface ExpiringStore extends SessionServicesStore {
dispatch: (type: "logout") => unknown;
}
/** Stops what outlives a page change: the search socket, AI chat streams, open toasts and the RUM user. */
/** Stops what outlives a page change: the search socket, AI chat streams, open toasts, access denials and the RUM user. */
export const stopSessionServices = (store: SessionServicesStore): void => {
closeSearchSocket();
// AI chat streams outlive their component by design, so sign-out stops them explicitly.
window.dispatchEvent(new Event("o2:abort-ai-streams"));
dismissAll();
resetAccess();
if (store.state.zoConfig?.rum?.enabled) {
openobserveRum.stopSessionReplayRecording();
openobserveRum.clearUser();
+13 -6
View File
@@ -1006,7 +1006,7 @@ export default defineComponent({
try {
await getFoldersList(store);
} catch {
// Already reported by the grouped access toast; the empty rail stands in.
// The empty rail stands in; the access boundary reports a 403.
}
// Load favorites BEFORE picking the landing view — the favorites-first
@@ -1084,7 +1084,6 @@ export default defineComponent({
if (isCancelledError(error)) return;
console.error("Error loading dashboards:", error);
forbidden.value = asCaughtError(error).response?.status === 403;
// The grouped access toast already reports a 403; a second red toast adds nothing.
if (!forbidden.value) {
showErrorNotification(
raw(asCaughtError(error).message || t("dashboard.dashboards.failedToLoadFolder")),
@@ -1362,6 +1361,8 @@ export default defineComponent({
// on screen keeps them and spins the button instead.
refreshing.value = true;
loading.value = dashboardList.value.length === 0;
forbidden.value = false;
const target = `${showFavoritesOnly.value}|${activeFolderId.value}`;
try {
if (showFavoritesOnly.value) {
// Refresh in the favorites view: re-read the favorites setting and
@@ -1402,9 +1403,14 @@ export default defineComponent({
stampFolders([folderId]);
}
} catch (err) {
showErrorNotification(
raw(asCaughtError(err).message || t("dashboard.dashboards.failedToLoad")),
);
// A late failure for a folder the user already left must not lock the folder now on screen.
if (target !== `${showFavoritesOnly.value}|${activeFolderId.value}`) return;
forbidden.value = asCaughtError(err).response?.status === 403;
if (!forbidden.value) {
showErrorNotification(
raw(asCaughtError(err).message || t("dashboard.dashboards.failedToLoad")),
);
}
} finally {
dismiss();
loading.value = false;
@@ -1632,6 +1638,7 @@ export default defineComponent({
return migratedDashboards;
} catch (error) {
if (asCaughtError(error).response?.status === 403) return;
showErrorNotification(
raw(asCaughtError(error).message ?? t("dashboard.dashboards.errorFetchingSearch")),
);
@@ -1918,7 +1925,7 @@ export default defineComponent({
caught.response?.data?.message ||
caught.message ||
t("dashboard.dashboards.errorDeleting");
if (caught.response?.status != 403 || caught.status != 403) {
if (caught.response?.status !== 403 && caught.status !== 403) {
toast({
variant: "error",
message: raw(errorMessage),
+11 -3
View File
@@ -202,10 +202,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<div class="relative min-h-0 min-w-0 flex-1">
<OEmptyState
<OAccessLock
v-if="!loaded && loadForbidden"
size="block"
preset="no-access"
:denials="loadDenials"
data-test="fleet-config-editor-forbidden"
/>
<OEmptyState
@@ -420,6 +419,7 @@ import OTabs from "@/lib/navigation/Tabs/OTabs.vue";
import OTab from "@/lib/navigation/Tabs/OTab.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OAccessLock from "@/components/access/OAccessLock.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
import { toast } from "@/lib/feedback/Toast/useToast";
@@ -463,6 +463,7 @@ import {
useFleetConfigVersion,
} from "@/components/fleet/useFleetApi";
import { useOrgId } from "@/composables/query/useOrgId";
import { failedRequests, viewDenial } from "@/composables/useAccessStore";
import { useEditorDraft } from "@/composables/useEditorDraft";
import { deleteEditorDraftsFor } from "@/utils/drafts/editorDraftStore";
import useBreakpoint from "@/composables/useBreakpoint";
@@ -543,6 +544,13 @@ const loaded = computed(() => isNew.value || loadedFor.value === id.value);
const loadForbidden = computed(
() => isForbidden(config.error.value) || isForbidden(savedVersion.error.value),
);
const loadDenials = computed(() => [
viewDenial(
"fleet_configs",
"get",
failedRequests(...[config.error.value, savedVersion.error.value].filter(isForbidden)),
),
]);
// A failed background refetch keeps the config that already loaded.
const loadFailed = computed(
() =>
+5 -1
View File
@@ -158,7 +158,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<RouterView v-slot="{ Component }">
<component :is="Component" class="h-full" @sendToAiChat="sendToAiChat" />
<OAccessBoundary>
<component :is="Component" class="h-full" @sendToAiChat="sendToAiChat" />
</OAccessBoundary>
</RouterView>
</div>
</OPageLayout>
@@ -168,6 +170,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import OPageHeader from "@/lib/core/PageHeader/OPageHeader.vue";
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import PipelineSectionTabs from "@/components/pipeline/PipelineSectionTabs.vue";
import OAccessBoundary from "@/components/access/OAccessBoundary.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
@@ -185,6 +188,7 @@ export default defineComponent({
OPageHeader,
OPageLayout,
PipelineSectionTabs,
OAccessBoundary,
OButton,
ODropdown,
ODropdownItem,
+6 -1
View File
@@ -22,7 +22,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<SectionRail :groups="sectionGroups" :active-key="activeSection" :title="t('menu.iam')" />
</template>
<section class="h-full min-h-0 min-w-0 overflow-y-auto">
<RouterView />
<RouterView v-slot="{ Component }">
<OAccessBoundary>
<component :is="Component" />
</OAccessBoundary>
</RouterView>
</section>
</OPageLayout>
</template>
@@ -30,6 +34,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script setup lang="ts">
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
import SectionRail from "@/components/common/SectionRail.vue";
import OAccessBoundary from "@/components/access/OAccessBoundary.vue";
import { type SectionHubGroup } from "@/components/common/SectionHub.vue";
import { computed, watch } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";