Files
openobserve/web/src/composables/useUnauthorizedErrorGrouper.ts
T
2026-08-05 10:16:00 +00:00

204 lines
7.3 KiB
TypeScript

// 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",
"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;
}