mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
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:
co-authored by
github-actions[bot]
parent
59ad6d006a
commit
ea263b344f
@@ -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/**",
|
||||
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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": "تعذر تحديث لوحات المعلومات المفضلة"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "お気に入りのダッシュボードを更新できませんでした"
|
||||
},
|
||||
|
||||
@@ -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": "즐겨찾기 대시보드를 업데이트하지 못했습니다."
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Не удалось обновить избранные дашборды"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "无法更新收藏的仪表盘"
|
||||
},
|
||||
|
||||
@@ -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": "無法更新我的最愛儀表板"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user