// 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 . 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 = { 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 | 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; }