From 2fb597911806d38e64155e65b806eba95cdb1c4e Mon Sep 17 00:00:00 2001
From: Diego Rodrigues de Sa e Souza
<8016841+diegosouzapw@users.noreply.github.com>
Date: Sat, 30 May 2026 21:18:25 -0300
Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20v3.8.8=20screen=20fixes=20?=
=?UTF-8?q?=E2=80=94=20agent-bridge=20SSR=20+=20audit/logs/memory/playgrou?=
=?UTF-8?q?nd=20(#2944)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Integrated into release/v3.8.8
---
.../dashboard/audit/A2aAuditTab.tsx | 2 +-
.../dashboard/audit/ComplianceTab.tsx | 2 +-
.../costs/quota-share/components/PoolCard.tsx | 11 +-
.../hooks/usePoolsUsageAggregate.ts | 4 +-
src/app/(dashboard)/dashboard/logs/page.tsx | 45 +--
.../memory/components/MemoryEngineStatus.tsx | 7 +-
.../memory/components/tabs/MemoriesTab.tsx | 10 +
src/app/(dashboard)/dashboard/memory/page.tsx | 50 ++-
.../components/StudioConfigPane.tsx | 59 +++-
.../playground/components/tabs/BuildTab.tsx | 250 ++++++---------
.../playground/components/tabs/CompareTab.tsx | 58 +++-
.../components/tabs/build/BuildWizard.tsx | 294 ++++++++++++++++++
.../components/SearchToolsTopBar.tsx | 8 +-
.../components/tabs/CompareTab.tsx | 286 ++++++++++-------
.../agent-bridge/AgentBridgePageClient.tsx | 4 +-
.../agent-bridge/components/AgentCard.tsx | 4 +-
.../agent-bridge/components/AgentList.tsx | 4 +-
.../agent-bridge/components/SetupWizard.tsx | 4 +-
.../dashboard/tools/agent-bridge/page.tsx | 2 +-
src/i18n/messages/en.json | 74 ++++-
src/i18n/messages/pt-BR.json | 74 ++++-
src/mitm/types.ts | 7 +
src/server/authz/policies/management.ts | 22 +-
src/server/authz/routeGuard.ts | 33 ++
src/shared/components/Select.tsx | 19 +-
.../agent-bridge-targets-serializable.test.ts | 33 ++
tests/unit/audit-eventtype-i18n.test.ts | 27 ++
tests/unit/route-guard-private-lan.test.ts | 57 ++++
tests/unit/v388-phase1-screen-fixes.test.ts | 35 +++
tests/unit/v388-phase3-memory.test.ts | 37 +++
tests/unit/v388-phase4-playground.test.ts | 41 +++
.../unit/v388-quota-share-usage-guard.test.ts | 23 ++
32 files changed, 1204 insertions(+), 382 deletions(-)
create mode 100644 src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx
create mode 100644 tests/unit/agent-bridge-targets-serializable.test.ts
create mode 100644 tests/unit/audit-eventtype-i18n.test.ts
create mode 100644 tests/unit/route-guard-private-lan.test.ts
create mode 100644 tests/unit/v388-phase1-screen-fixes.test.ts
create mode 100644 tests/unit/v388-phase3-memory.test.ts
create mode 100644 tests/unit/v388-phase4-playground.test.ts
create mode 100644 tests/unit/v388-quota-share-usage-guard.test.ts
diff --git a/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx
index 1cd2f7c2dc..2c390c68cd 100644
--- a/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx
+++ b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx
@@ -182,7 +182,7 @@ export default function A2aAuditTab() {
- {task.state}
+ {t(`a2aState${task.state.charAt(0).toUpperCase()}${task.state.slice(1)}`)}
{taskDuration(task)} |
diff --git a/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx b/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx
index f2a0dc4329..a8f86190cc 100644
--- a/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx
+++ b/src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx
@@ -330,7 +330,7 @@ export default function ComplianceTab() {
- {entry.action}
+ {t.has(`eventTypes.${entry.action}`) ? t(`eventTypes.${entry.action}`) : entry.action}
|
diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx
index 6bae71c651..bd131e0841 100644
--- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx
+++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx
@@ -24,8 +24,9 @@ export interface PoolCardProps {
}
function computeStatus(usage: PoolUsageSnapshot | null): "green" | "amber" | "red" {
- if (!usage || usage.dimensions.length === 0) return "green";
- const utilizations = usage.dimensions.map((d) =>
+ const dims = usage?.dimensions ?? [];
+ if (dims.length === 0) return "green";
+ const utilizations = dims.map((d) =>
d.limit > 0 ? (d.consumedTotal / d.limit) * 100 : 0
);
const avg = utilizations.reduce((s, u) => s + u, 0) / utilizations.length;
@@ -54,7 +55,7 @@ export default function PoolCard({
const { icon: statusIcon, cls: statusCls } = STATUS_ICONS[status];
// Check for plan dimensions from usage
- const hasDimensions = usage && usage.dimensions.length > 0;
+ const hasDimensions = !!usage?.dimensions?.length;
return (
@@ -103,10 +104,10 @@ export default function PoolCard({
- {usage.dimensions.map((dim, i) => (
+ {(usage?.dimensions ?? []).map((dim, i) => (
0) {
totalUtil += (dim.consumedTotal / dim.limit) * 100;
utilCount += 1;
}
- for (const key of dim.perKey) {
+ for (const key of dim.perKey ?? []) {
if (key.borrowing) borrowing += 1;
}
}
diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx
index 5ef549d357..40b1271958 100644
--- a/src/app/(dashboard)/dashboard/logs/page.tsx
+++ b/src/app/(dashboard)/dashboard/logs/page.tsx
@@ -1,9 +1,7 @@
"use client";
import { useState, useRef, useEffect } from "react";
-import { useSearchParams } from "next/navigation";
-import { ConfirmModal, RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
-import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
+import { ConfirmModal, RequestLoggerV2 } from "@/shared/components";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel";
import { useTranslations } from "next-intl";
@@ -15,18 +13,7 @@ const TIME_RANGES = [
{ label: "24h", hours: 24 },
];
-const TAB_TO_LOG_TYPE: Record = {
- "request-logs": "request-logs",
- "proxy-logs": "proxy-logs",
- console: "call-logs",
-};
-
export default function LogsPage() {
- const searchParams = useSearchParams();
- const requestedTab = searchParams.get("tab");
- const [activeTab, setActiveTab] = useState(
- requestedTab && TAB_TO_LOG_TYPE[requestedTab] ? requestedTab : "request-logs"
- );
const [showExport, setShowExport] = useState(false);
const [exporting, setExporting] = useState(false);
const [showCleanHistory, setShowCleanHistory] = useState(false);
@@ -36,12 +23,6 @@ export default function LogsPage() {
const dropdownRef = useRef(null);
const t = useTranslations("logs");
- useEffect(() => {
- if (requestedTab && TAB_TO_LOG_TYPE[requestedTab] && requestedTab !== activeTab) {
- setActiveTab(requestedTab);
- }
- }, [activeTab, requestedTab]);
-
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
@@ -56,7 +37,7 @@ export default function LogsPage() {
setExporting(true);
setShowExport(false);
try {
- const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs";
+ const logType = "request-logs";
const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`);
if (!res.ok) throw new Error(t("exportFailed"));
const blob = await res.blob();
@@ -108,15 +89,7 @@ export default function LogsPage() {
return (
-
+ {t("requestLogs")}
@@ -211,14 +184,10 @@ export default function LogsPage() {
)}
- {activeTab === "request-logs" && (
-
- )}
- {activeTab === "proxy-logs" && }
- {activeTab === "console" && }
+
0 ? (
+ status.vectorStore.backend === "none" ? (
+
+ terminal
+ {t("engine.vectorStoreInstallHint")}
+
+ ) : status.vectorStore.needsReindex > 0 ? (
warning
{t("engine.needsReindex", { count: status.vectorStore.needsReindex })}
diff --git a/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx b/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx
index 458c45c453..5df03c94b2 100644
--- a/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx
+++ b/src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx
@@ -236,6 +236,16 @@ export default function MemoriesTab() {
}
};
+ // Auto-run health check on mount + poll every 30s, so the indicator reflects
+ // engine health without requiring a manual click.
+ useEffect(() => {
+ void checkHealth();
+ const id = setInterval(() => {
+ void checkHealth();
+ }, 30_000);
+ return () => clearInterval(id);
+ }, []);
+
const openEdit = (m: Memory) => {
setEditTarget(m);
setEditOpen(true);
diff --git a/src/app/(dashboard)/dashboard/memory/page.tsx b/src/app/(dashboard)/dashboard/memory/page.tsx
index 6d1c382e47..cf228ede40 100644
--- a/src/app/(dashboard)/dashboard/memory/page.tsx
+++ b/src/app/(dashboard)/dashboard/memory/page.tsx
@@ -7,15 +7,18 @@ import MemoryConceptCard from "./components/MemoryConceptCard";
import MemoriesTab from "./components/tabs/MemoriesTab";
import PlaygroundTab from "./components/tabs/PlaygroundTab";
import EngineTab from "./components/tabs/EngineTab";
+import { useMemorySettings } from "./hooks/useMemorySettings";
type TabId = "memories" | "playground" | "engine";
-const TABS: TabId[] = ["memories", "playground", "engine"];
+const TABS: TabId[] = ["memories", "engine", "playground"];
function MemoryPageContent() {
const t = useTranslations("memory");
const searchParams = useSearchParams();
const router = useRouter();
+ const { settings, save } = useMemorySettings();
+ const memoryEnabled = settings?.enabled ?? true;
const rawTab = searchParams.get("tab") ?? "";
const activeTab: TabId = TABS.includes(rawTab as TabId) ? (rawTab as TabId) : "memories";
@@ -31,23 +34,44 @@ function MemoryPageContent() {
{/* Concept card */}
- {/* Tab navigation */}
-
- {TABS.map((tab) => (
+ {/* Tab navigation + memory enable toggle */}
+
+
+ {TABS.map((tab) => (
+
+ ))}
+
+
{/* Tab content */}
diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
index decb51ed73..20440a705a 100644
--- a/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
@@ -8,11 +8,14 @@ import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
import { endpointToPath } from "@/lib/playground/codeExport";
import PresetPicker from "./PresetPicker";
import ImprovePromptButton from "./ImprovePromptButton";
+import { useProviderOptions } from "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions";
+import { useAvailableModels } from "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels";
export interface ConfigState {
endpoint: PlaygroundEndpoint;
baseUrl: string;
model: string;
+ provider?: string;
systemPrompt: string;
params: PlaygroundParams;
}
@@ -46,6 +49,10 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
*/
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
const [collapsed, setCollapsed] = useState(false);
+ const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions(
+ configState.provider ?? ""
+ );
+ const { availableModels, loading: loadingModels } = useAvailableModels();
function update (key: K, value: ConfigState[K]) {
setConfigState({ ...configState, [key]: value });
@@ -108,18 +115,56 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
+ {/* Provider */}
+
+
+
+
+
{/* Model */}
- update("model", e.target.value)}
- placeholder="e.g. openai/gpt-4o"
- className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
- />
+ {availableModels.length > 0 ? (
+
+ ) : (
+ update("model", e.target.value)}
+ placeholder="e.g. openai/gpt-4o"
+ className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
+ />
+ )}
{/* System prompt */}
diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx
index 40bfb78c98..a3a5c521a1 100644
--- a/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx
+++ b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx
@@ -6,9 +6,8 @@ import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useToolsBuilder } from "../../hooks/useToolsBuilder";
import { useStructuredOutput } from "../../hooks/useStructuredOutput";
-import ToolsBuilder from "../ToolsBuilder";
-import StructuredOutputEditor from "../StructuredOutputEditor";
import MarkdownMessage from "../MarkdownMessage";
+import BuildWizard from "./build/BuildWizard";
import type { ConfigState } from "../StudioConfigPane";
interface BuildTabProps {
@@ -225,177 +224,104 @@ export default function BuildTab({ configState }: BuildTabProps) {
await runRequest(newMessages);
}
- function clearConversation() {
- setMessages([]);
- setToolCalls([]);
- setToolResultDrafts([]);
- setValidationResult(null);
- setPrompt("");
- }
-
- return (
-
- {/* Left panel: conversation + run */}
-
- {/* Toolbar */}
-
-
)}
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx
index 91184a9b8e..925f761952 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx
@@ -8,7 +8,7 @@ import { AgentBridgeServerCard } from "./components/AgentBridgeServerCard";
import { AgentList } from "./components/AgentList";
import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders";
import { useAgentBridgeState } from "./hooks/useAgentBridgeState";
-import type { MitmTarget } from "@/mitm/types";
+import type { MitmTargetView } from "@/mitm/types";
import type { MappingRow } from "./components/ModelMappingTable";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -43,7 +43,7 @@ export interface AgentBridgePageData {
interface AgentBridgePageClientProps {
initialData: AgentBridgePageData;
- targets: MitmTarget[];
+ targets: MitmTargetView[];
hasProviders: boolean;
}
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx
index b3be7f29bf..0d1bf052aa 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentCard.tsx
@@ -7,7 +7,7 @@ import { DnsStatusBadge } from "./shared/DnsStatusBadge";
import { ModelMappingTable } from "./ModelMappingTable";
import { SetupWizard } from "./SetupWizard";
import { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
-import type { MitmTarget } from "@/mitm/types";
+import type { MitmTargetView } from "@/mitm/types";
import type { AgentStateEntry } from "../AgentBridgePageClient";
import type { MappingRow } from "./ModelMappingTable";
@@ -23,7 +23,7 @@ function hasAcceptedRisk(agentId: string): boolean {
interface AgentCardProps {
- target: MitmTarget;
+ target: MitmTargetView;
agentState: AgentStateEntry | undefined;
serverRunning: boolean;
mappings: MappingRow[];
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx
index d2f97e9bf0..d2fca8c359 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentList.tsx
@@ -3,12 +3,12 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { AgentCard } from "./AgentCard";
-import type { MitmTarget } from "@/mitm/types";
+import type { MitmTargetView } from "@/mitm/types";
import type { AgentStateEntry, AgentMappingsMap } from "../AgentBridgePageClient";
import type { MappingRow } from "./ModelMappingTable";
interface AgentListProps {
- targets: MitmTarget[];
+ targets: MitmTargetView[];
agentStates: AgentStateEntry[];
serverRunning: boolean;
mappingsMap: AgentMappingsMap;
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx
index dac0f8a7ad..59111ec325 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/SetupWizard.tsx
@@ -3,10 +3,10 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { AgentStateEntry } from "../AgentBridgePageClient";
-import type { MitmTarget } from "@/mitm/types";
+import type { MitmTargetView } from "@/mitm/types";
interface SetupWizardProps {
- target: MitmTarget;
+ target: MitmTargetView;
agentState: AgentStateEntry | undefined;
serverRunning: boolean;
onClose: () => void;
diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
index 1280339fdc..f72049d483 100644
--- a/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
+++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx
@@ -54,7 +54,7 @@ export default async function AgentBridgePage() {
return (
rest)}
hasProviders={hasProviders}
/>
);
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 17156a6e8d..cdf202d968 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1283,7 +1283,45 @@
"a2aNoTasks": "No A2A tasks recorded.",
"a2aLoadingTasks": "Loading A2A tasks...",
"actor": "Actor",
- "actorPlaceholder": "Filter by actor"
+ "actorPlaceholder": "Filter by actor",
+ "eventTypes": {
+ "apiKey.activate": "API Key Activated",
+ "apiKey.ban": "API Key Banned",
+ "apiKey.deactivate": "API Key Deactivated",
+ "apiKey.regenerate": "API Key Regenerated",
+ "apiKey.scopes.grant": "API Key Scopes Granted",
+ "apiKey.scopes.revoke": "API Key Scopes Revoked",
+ "apiKey.scopes.update": "API Key Scopes Updated",
+ "apiKey.unban": "API Key Unbanned",
+ "auth.login.error": "Login Error",
+ "auth.login.failed": "Login Failed",
+ "auth.login.locked": "Login Locked",
+ "auth.login.misconfigured": "Login Misconfigured",
+ "auth.login.setup_required": "Login Setup Required",
+ "auth.login.success": "Login Success",
+ "auth.logout.success": "Logout Success",
+ "compliance.cleanup": "Compliance Cleanup",
+ "provider.credentials.applied": "Provider Credentials Applied",
+ "provider.credentials.batch_revoked": "Provider Credentials Batch Revoked",
+ "provider.credentials.bulk_created": "Provider Credentials Bulk Created",
+ "provider.credentials.bulk_imported": "Provider Credentials Bulk Imported",
+ "provider.credentials.created": "Provider Credentials Created",
+ "provider.credentials.imported": "Provider Credentials Imported",
+ "provider.credentials.revoked": "Provider Credentials Revoked",
+ "provider.credentials.updated": "Provider Credentials Updated",
+ "provider.validation.ssrf_blocked": "Provider SSRF Blocked",
+ "quota.plan.updated": "Quota Plan Updated",
+ "quota.pool.created": "Quota Pool Created",
+ "quota.pool.deleted": "Quota Pool Deleted",
+ "quota.pool.updated": "Quota Pool Updated",
+ "quota.store.driver_changed": "Quota Store Driver Changed",
+ "server.start": "Server Start",
+ "service.reveal_api_key": "Service API Key Revealed",
+ "settings.update": "Settings Updated",
+ "settings.update_failed": "Settings Update Failed",
+ "sync.token.created": "Sync Token Created",
+ "sync.token.revoked": "Sync Token Revoked"
+ }
},
"themesPage": {
"title": "Themes",
@@ -3254,7 +3292,8 @@
"qdrantOk": "Healthy ({latencyMs}ms)",
"qdrantError": "Connection error",
"needsReindex": "{count} memory(ies) need reindexing",
- "configureCta": "Configure"
+ "configureCta": "Configure",
+ "vectorStoreInstallHint": "To enable vector search: npm install sqlite-vec (needs native Node.js, not WASM), then restart."
},
"episodic": "Episodic",
"export": "Export",
@@ -3365,7 +3404,8 @@
"semantic": "Concepts, preferences, and domain knowledge"
},
"totalEntries": "Total Entries",
- "type": "Type"
+ "type": "Type",
+ "memoryEnabled": "Memory enabled"
},
"skills": {
"title": "Skills",
@@ -5643,7 +5683,11 @@
"vercelRelayProjectNameLabel": "Vercel Project Name",
"vercelRelayFreeTierNote": "Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.",
"vercelRelayDeploying": "Deploying...",
- "vercelRelayDeploy": "Deploy"
+ "vercelRelayDeploy": "Deploy",
+ "proxyGlobalConfigTab": "Global Config",
+ "proxyPoolTab": "Proxy Pool",
+ "freePoolTab": "Free Pool",
+ "proxyDocumentationTab": "Documentation"
},
"contextRtk": {
"title": "RTK Engine",
@@ -7500,7 +7544,27 @@
"invalidJson": "Invalid JSON",
"running": "Running…",
"runLabel": "Run",
- "enterToolResult": "Enter tool result…"
+ "enterToolResult": "Enter tool result…",
+ "build": {
+ "step1Label": "What to test?",
+ "step2Label": "Configure",
+ "step3Label": "Run",
+ "step1Title": "What do you want to test?",
+ "step1Subtitle": "Choose the capability you want to explore in this session.",
+ "step2Title": "Configure",
+ "step2Subtitle": "Set up the tools or JSON schema that will be used in the request.",
+ "step3Title": "Run",
+ "modeToolsTitle": "Tools",
+ "modeToolsDesc": "Test function calling — define tools and see how the model invokes them.",
+ "modeJsonTitle": "JSON",
+ "modeJsonDesc": "Test structured output — constrain the response to a JSON schema.",
+ "modeBothTitle": "Tools + JSON",
+ "modeBothDesc": "Combine function calling and structured output in a single request.",
+ "backButton": "Back",
+ "nextButton": "Next",
+ "runButton": "Run",
+ "promptPlaceholder": "Enter your message… (Enter to send, Shift+Enter for newline)"
+ }
},
"miniPlayground": {
"endpoint": "Endpoint",
diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json
index 589a6400ef..5dcfa9391f 100644
--- a/src/i18n/messages/pt-BR.json
+++ b/src/i18n/messages/pt-BR.json
@@ -2465,7 +2465,45 @@
"a2aNoTasks": "Nenhuma tarefa A2A registrada.",
"a2aLoadingTasks": "Carregando tarefas A2A...",
"actor": "Autor",
- "actorPlaceholder": "Filtrar por autor"
+ "actorPlaceholder": "Filtrar por autor",
+ "eventTypes": {
+ "apiKey.activate": "API Key Ativada",
+ "apiKey.ban": "API Key Banida",
+ "apiKey.deactivate": "API Key Desativada",
+ "apiKey.regenerate": "API Key Regenerada",
+ "apiKey.scopes.grant": "Escopos de API Key Concedidos",
+ "apiKey.scopes.revoke": "Escopos de API Key Revogados",
+ "apiKey.scopes.update": "Escopos de API Key Atualizados",
+ "apiKey.unban": "API Key Desbanida",
+ "auth.login.error": "Erro de Login",
+ "auth.login.failed": "Login Falhou",
+ "auth.login.locked": "Login Bloqueado",
+ "auth.login.misconfigured": "Login Mal Configurado",
+ "auth.login.setup_required": "Configuração de Login Necessária",
+ "auth.login.success": "Login com Sucesso",
+ "auth.logout.success": "Logout com Sucesso",
+ "compliance.cleanup": "Limpeza de Conformidade",
+ "provider.credentials.applied": "Credenciais do Provedor Aplicadas",
+ "provider.credentials.batch_revoked": "Credenciais do Provedor Revogadas em Lote",
+ "provider.credentials.bulk_created": "Credenciais do Provedor Criadas em Massa",
+ "provider.credentials.bulk_imported": "Credenciais do Provedor Importadas em Massa",
+ "provider.credentials.created": "Credenciais do Provedor Criadas",
+ "provider.credentials.imported": "Credenciais do Provedor Importadas",
+ "provider.credentials.revoked": "Credenciais do Provedor Revogadas",
+ "provider.credentials.updated": "Credenciais do Provedor Atualizadas",
+ "provider.validation.ssrf_blocked": "SSRF do Provedor Bloqueado",
+ "quota.plan.updated": "Plano de Cota Atualizado",
+ "quota.pool.created": "Pool de Cota Criado",
+ "quota.pool.deleted": "Pool de Cota Excluído",
+ "quota.pool.updated": "Pool de Cota Atualizado",
+ "quota.store.driver_changed": "Driver do Store de Cota Alterado",
+ "server.start": "Servidor Iniciado",
+ "service.reveal_api_key": "API Key do Serviço Revelada",
+ "settings.update": "Configurações Atualizadas",
+ "settings.update_failed": "Falha ao Atualizar Configurações",
+ "sync.token.created": "Token de Sincronização Criado",
+ "sync.token.revoked": "Token de Sincronização Revogado"
+ }
},
"contextCaveman": {
"title": "Motor Caveman",
@@ -3841,7 +3879,8 @@
"qdrantOk": "Saudável ({latencyMs}ms)",
"qdrantError": "Erro de conexão",
"needsReindex": "{count} memória(s) precisam de reindexação",
- "configureCta": "Configurar"
+ "configureCta": "Configurar",
+ "vectorStoreInstallHint": "Para ativar a busca vetorial: npm install sqlite-vec (requer Node.js nativo, não WASM) e reinicie."
},
"episodic": "Episódica",
"export": "Exportar",
@@ -3952,7 +3991,8 @@
"semantic": "Conceitos, preferências e conhecimento de domínio"
},
"totalEntries": "Total de Entradas",
- "type": "Tipo"
+ "type": "Tipo",
+ "memoryEnabled": "Memória ativada"
},
"miniPlayground": {
"endpoint": "Endpoint",
@@ -4261,7 +4301,27 @@
"invalidJson": "JSON inválido",
"running": "Executando…",
"runLabel": "Executar",
- "enterToolResult": "Insira o resultado da ferramenta…"
+ "enterToolResult": "Insira o resultado da ferramenta…",
+ "build": {
+ "step1Label": "O que testar?",
+ "step2Label": "Configurar",
+ "step3Label": "Rodar",
+ "step1Title": "O que você quer testar?",
+ "step1Subtitle": "Escolha a funcionalidade que deseja explorar nesta sessão.",
+ "step2Title": "Configurar",
+ "step2Subtitle": "Configure as ferramentas ou o esquema JSON que serão usados na requisição.",
+ "step3Title": "Rodar",
+ "modeToolsTitle": "Ferramentas",
+ "modeToolsDesc": "Teste function calling — defina ferramentas e veja como o modelo as invoca.",
+ "modeJsonTitle": "JSON",
+ "modeJsonDesc": "Teste saída estruturada — restrinja a resposta a um esquema JSON.",
+ "modeBothTitle": "Ferramentas + JSON",
+ "modeBothDesc": "Combine function calling e saída estruturada em uma única requisição.",
+ "backButton": "Voltar",
+ "nextButton": "Próximo",
+ "runButton": "Executar",
+ "promptPlaceholder": "Digite sua mensagem… (Enter para enviar, Shift+Enter para nova linha)"
+ }
},
"pricingModal": {
"title": "Configuração de preços",
@@ -6628,7 +6688,11 @@
"homeQuickStart": "Quick Start",
"homeQuickStartDesc": "Show the Quick Start panel on the Home page.",
"homeProviderTopology": "Provider Topology",
- "homeProviderTopologyDesc": "Show the Provider Topology on the Home page."
+ "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.",
+ "proxyGlobalConfigTab": "Configuração Global",
+ "proxyPoolTab": "Pool de Proxy",
+ "freePoolTab": "Pool Gratuito",
+ "proxyDocumentationTab": "Documentação"
},
"sidebar": {
"home": "Início",
diff --git a/src/mitm/types.ts b/src/mitm/types.ts
index 2ed90b65dc..a153cd6d93 100644
--- a/src/mitm/types.ts
+++ b/src/mitm/types.ts
@@ -38,6 +38,13 @@ export interface MitmTarget {
viability?: "investigating" | "supported" | "deprecated"; // Trae = "investigating"
}
+/**
+ * Serializable view of a MitmTarget for Server→Client Component props.
+ * Omits `handler` (a function): Next.js forbids passing functions across the
+ * Server/Client boundary, and the UI never invokes it. See agent-bridge/page.tsx.
+ */
+export type MitmTargetView = Omit;
+
export const MitmTargetSchema = z.object({
id: z.enum([
"antigravity", "kiro", "copilot", "codex", "cursor", "zed",
diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts
index 27efce2aaa..e4832d078a 100644
--- a/src/server/authz/policies/management.ts
+++ b/src/server/authz/policies/management.ts
@@ -13,12 +13,22 @@ import {
isLocalOnlyBypassableByManageScope,
isLocalOnlyPath,
isLoopbackHost,
+ isPrivateLanHost,
} from "../routeGuard";
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
function requestPeerAddress(ctx: PolicyContext): string | null {
- return ctx.request.ip || ctx.request.socket?.remoteAddress || null;
+ // In the Next middleware runtime (proxy.ts → runAuthzPipeline), ctx.request is
+ // a NextRequest with no socket/.ip, so the only locality signal is the Host
+ // header — which is exactly what isLoopbackHost/isPrivateLanHost parse (they
+ // strip :port). This both fixes the loopback gate (previously the null socket
+ // made every LOCAL_ONLY request 403, even from localhost) and enables the
+ // owner-authorized private-LAN carve-out. Fall back to .ip/.socket for any
+ // non-middleware caller that provides them. Spawn-capable endpoints still
+ // require manage-scope auth after this gate.
+ const hostHeader = ctx.request.headers?.get?.("host") ?? null;
+ return hostHeader || ctx.request.ip || ctx.request.socket?.remoteAddress || null;
}
function isLoopbackRequest(ctx: PolicyContext): boolean {
@@ -26,6 +36,14 @@ function isLoopbackRequest(ctx: PolicyContext): boolean {
return peerAddress ? isLoopbackHost(peerAddress) : false;
}
+// Owner-authorized (2026-05-30): allow LOCAL_ONLY *paths* from a trusted private
+// LAN, based on the real socket peer IP (not spoofable). Does NOT relax the
+// CLI-token gate, which stays strictly loopback.
+function isPrivateLanRequest(ctx: PolicyContext): boolean {
+ const peerAddress = requestPeerAddress(ctx);
+ return peerAddress ? isPrivateLanHost(peerAddress) : false;
+}
+
function hasValidCliToken(ctx: PolicyContext): boolean {
if (!isLoopbackRequest(ctx)) return false;
const headers = ctx.request.headers;
@@ -70,7 +88,7 @@ export const managementPolicy: RoutePolicy = {
//
// Anonymous (no Bearer / invalid key / wrong scope / no session) requests
// still hit the same 403 LOCAL_ONLY they did before.
- if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx)) {
+ if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx) && !isPrivateLanRequest(ctx)) {
if (isLocalOnlyBypassableByManageScope(path)) {
const apiKey = extractApiKey(ctx.request as unknown as Request);
if (apiKey) {
diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts
index 5cf979986b..44ec9409cf 100644
--- a/src/server/authz/routeGuard.ts
+++ b/src/server/authz/routeGuard.ts
@@ -89,6 +89,39 @@ export function isLoopbackHost(hostHeader: string | null): boolean {
return LOOPBACK_HOSTS.has(host.toLowerCase());
}
+/**
+ * Private-LAN ranges (RFC 1918 IPv4 + IPv6 ULA/link-local). Matched against the
+ * real socket peer address (NOT the spoofable Host header), so a public-internet
+ * client — which presents a public source IP — never matches.
+ */
+const PRIVATE_LAN_PATTERNS: ReadonlyArray = [
+ /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
+ /^192\.168\.\d{1,3}\.\d{1,3}$/,
+ /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/,
+ /^f[cd][0-9a-f]{2}:/i, // IPv6 ULA fc00::/7
+ /^fe80:/i, // IPv6 link-local
+];
+
+/**
+ * True when the peer address is a private-LAN address. Used to widen the
+ * LOCAL_ONLY tier to a trusted private network (owner-authorized 2026-05-30 for
+ * a LAN-deployed instance). Loopback-only surfaces that do NOT use this (e.g.
+ * the CLI-token path) remain strictly loopback.
+ */
+export function isPrivateLanHost(hostHeader: string | null): boolean {
+ if (!hostHeader) return false;
+ let host = hostHeader.trim();
+ if (host.startsWith("[")) {
+ const bracketEnd = host.indexOf("]");
+ host = bracketEnd >= 0 ? host.slice(1, bracketEnd) : host.slice(1);
+ }
+ host = host.replace(/^::ffff:/i, "");
+ // Strip :port only for IPv4 / hostname (a lone colon); leave IPv6 intact.
+ if ((host.match(/:/g) || []).length === 1) host = host.split(":")[0];
+ host = host.toLowerCase();
+ return PRIVATE_LAN_PATTERNS.some((re) => re.test(host));
+}
+
export function isLocalOnlyPath(path: string): boolean {
return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p));
}
diff --git a/src/shared/components/Select.tsx b/src/shared/components/Select.tsx
index 5284aecdee..8abcfd7755 100644
--- a/src/shared/components/Select.tsx
+++ b/src/shared/components/Select.tsx
@@ -30,6 +30,7 @@ export default function Select({
className,
selectClassName,
id: externalId,
+ children,
...props
}: SelectProps) {
const generatedId = useId();
@@ -71,14 +72,18 @@ export default function Select({
)}
{...props}
>
-
- {options.map((option) => (
-
- ))}
+ )}
+ {!children &&
+ options.map((option) => (
+
+ ))}
+ {children}
Promise<...>` function. Next.js forbids passing functions
+// across the Server/Client boundary, raising at runtime:
+// "Functions cannot be passed directly to Client Components ..."
+// which broke SSR for the whole page. The fix sanitizes the array via
+// ALL_TARGETS.map(({ handler, ...rest }) => rest)
+// (a MitmTargetView). These tests pin both halves of that contract.
+
+test("agent-bridge: sanitized targets (no handler) are fully serializable for Client Components", () => {
+ const views = ALL_TARGETS.map(({ handler, ...rest }) => rest);
+ assert.ok(views.length > 0, "expected at least one MITM target");
+ for (const v of views) {
+ const id = (v as { id?: string }).id ?? " ";
+ assert.equal("handler" in v, false, `${id}: handler must be stripped before crossing to a Client Component`);
+ for (const [key, value] of Object.entries(v)) {
+ assert.notEqual(typeof value, "function", `${id}.${key} must not be a function (non-serializable)`);
+ }
+ assert.doesNotThrow(() => JSON.parse(JSON.stringify(v)), `${id} must be JSON-serializable`);
+ }
+});
+
+test("agent-bridge: raw ALL_TARGETS still carry a handler function (so the sanitization is required)", () => {
+ for (const t of ALL_TARGETS) {
+ assert.equal(typeof t.handler, "function", `${t.id} should expose a lazy handler function`);
+ }
+});
diff --git a/tests/unit/audit-eventtype-i18n.test.ts b/tests/unit/audit-eventtype-i18n.test.ts
new file mode 100644
index 0000000000..6b1d4ca302
--- /dev/null
+++ b/tests/unit/audit-eventtype-i18n.test.ts
@@ -0,0 +1,27 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+const root = join(import.meta.dirname, "../..");
+const read = (p: string) => readFileSync(join(root, p), "utf8");
+const en = JSON.parse(read("src/i18n/messages/en.json"));
+const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
+
+test("audit: compliance.eventTypes exists with en/pt-BR parity and key coverage", () => {
+ const enKeys = Object.keys(en.compliance?.eventTypes ?? {});
+ const ptKeys = Object.keys(pt.compliance?.eventTypes ?? {});
+ assert.ok(enKeys.length >= 30, `expected >=30 event-type labels, got ${enKeys.length}`);
+ assert.deepEqual(enKeys.sort(), ptKeys.sort(), "en/pt-BR eventTypes keys must match");
+ for (const k of ["provider.credentials.created", "auth.login.success", "quota.pool.created", "sync.token.revoked"]) {
+ assert.ok(en.compliance.eventTypes[k], `en missing eventTypes.${k}`);
+ assert.ok(pt.compliance.eventTypes[k], `pt-BR missing eventTypes.${k}`);
+ }
+});
+
+test("audit: ComplianceTab translates action and A2aAuditTab translates task state", () => {
+ const ct = read("src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx");
+ const a2a = read("src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx");
+ assert.ok(ct.includes("eventTypes.${entry.action}"), "ComplianceTab uses eventTypes i18n lookup");
+ assert.ok(a2a.includes("a2aState${task.state"), "A2aAuditTab uses a2aState i18n lookup");
+});
diff --git a/tests/unit/route-guard-private-lan.test.ts b/tests/unit/route-guard-private-lan.test.ts
new file mode 100644
index 0000000000..9117af0b75
--- /dev/null
+++ b/tests/unit/route-guard-private-lan.test.ts
@@ -0,0 +1,57 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { isPrivateLanHost, isLoopbackHost, isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
+
+test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)", () => {
+ for (const h of [
+ "192.168.0.15",
+ "192.168.0.15:54321",
+ "10.0.0.5",
+ "172.16.0.9",
+ "172.31.255.254",
+ "::ffff:192.168.1.20",
+ ]) {
+ assert.equal(isPrivateLanHost(h), true, `expected private-LAN: ${h}`);
+ }
+});
+
+test("isPrivateLanHost: accepts IPv6 ULA / link-local", () => {
+ assert.equal(isPrivateLanHost("fd12:3456::1"), true);
+ assert.equal(isPrivateLanHost("fe80::1"), true);
+});
+
+test("isPrivateLanHost: rejects public IPs, loopback and junk", () => {
+ for (const h of [
+ "8.8.8.8",
+ "69.164.221.35", // public VPS
+ "172.32.0.1", // just outside 172.16/12
+ "127.0.0.1",
+ "::1",
+ "example.com",
+ "",
+ null,
+ ]) {
+ assert.equal(isPrivateLanHost(h), false, `expected NOT private-LAN: ${h}`);
+ }
+});
+
+test("isLoopbackHost stays loopback-only (unchanged)", () => {
+ assert.equal(isLoopbackHost("127.0.0.1"), true);
+ assert.equal(isLoopbackHost("localhost:20128"), true);
+ assert.equal(isLoopbackHost("192.168.0.15"), false);
+});
+
+test("services + traffic-inspector remain LOCAL_ONLY paths", () => {
+ assert.equal(isLocalOnlyPath("/api/services/9router/status"), true);
+ assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true);
+});
+
+test("management policy derives locality from the Host header (middleware socket is null)", () => {
+ const src = readFileSync(
+ join(import.meta.dirname, "../../src/server/authz/policies/management.ts"),
+ "utf8"
+ );
+ assert.ok(src.includes('headers?.get?.("host")'), "requestPeerAddress must read the Host header");
+});
diff --git a/tests/unit/v388-phase1-screen-fixes.test.ts b/tests/unit/v388-phase1-screen-fixes.test.ts
new file mode 100644
index 0000000000..680c691e9b
--- /dev/null
+++ b/tests/unit/v388-phase1-screen-fixes.test.ts
@@ -0,0 +1,35 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+// Regression guards for v3.8.8 screen-fix Phase 1 (quick wins).
+const root = join(import.meta.dirname, "../..");
+const read = (p: string) => readFileSync(join(root, p), "utf8");
+
+test("search-tools: export modal mounts on exportOpen (not by default) without invalid isOpen prop", () => {
+ const src = read("src/app/(dashboard)/dashboard/search-tools/components/SearchToolsTopBar.tsx");
+ assert.ok(src.includes("{exportOpen && exportState != null && ("), "modal guarded by exportOpen");
+ assert.equal(/]*\bisOpen=/.test(src), false, "no invalid isOpen prop on ExportCodeModal");
+});
+
+test("memory: tabs ordered memories -> engine -> playground", () => {
+ const src = read("src/app/(dashboard)/dashboard/memory/page.tsx");
+ assert.ok(src.includes('["memories", "engine", "playground"]'), "TABS order is memories, engine, playground");
+});
+
+test("shared Select: renders children and guards placeholder/options when children present", () => {
+ const src = read("src/shared/components/Select.tsx");
+ assert.ok(src.includes("{children}"), "renders children passed by callers");
+ assert.ok(src.includes("!children && placeholder"), "placeholder guarded by !children");
+ assert.ok(src.includes("!children &&\n options.map") || src.includes("!children &&"), "options guarded by !children");
+});
+
+test("logs: proxy/console tabs removed (dedicated menu pages exist)", () => {
+ const src = read("src/app/(dashboard)/dashboard/logs/page.tsx");
+ assert.equal(/value:\s*"proxy-logs"/.test(src), false, "proxy-logs tab removed");
+ assert.equal(/value:\s*"console"/.test(src), false, "console tab removed");
+ assert.equal(src.includes(" readFileSync(join(root, p), "utf8");
+const en = JSON.parse(read("src/i18n/messages/en.json"));
+const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
+
+test("memory: health auto-checks on mount + 30s polling", () => {
+ const src = read("src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx");
+ assert.ok(src.includes("void checkHealth();"), "calls checkHealth from an effect");
+ assert.ok(src.includes("setInterval"), "polls health periodically");
+});
+
+test("memory: page wires enable/disable toggle via useMemorySettings", () => {
+ const src = read("src/app/(dashboard)/dashboard/memory/page.tsx");
+ assert.ok(src.includes("useMemorySettings"), "uses the settings hook");
+ assert.ok(/role="switch"/.test(src), "renders a switch control");
+ assert.ok(src.includes("save({ enabled:"), "persists enabled via save()");
+});
+
+test("memory: vector store shows install hint when backend is none", () => {
+ const src = read("src/app/(dashboard)/dashboard/memory/components/MemoryEngineStatus.tsx");
+ assert.ok(src.includes('status.vectorStore.backend === "none"'), "branches on backend none");
+ assert.ok(src.includes("engine.vectorStoreInstallHint"), "renders the install-hint key");
+});
+
+test("memory i18n: memoryEnabled + engine.vectorStoreInstallHint present in en + pt-BR", () => {
+ assert.ok(en.memory?.memoryEnabled && pt.memory?.memoryEnabled, "memoryEnabled in both locales");
+ assert.ok(
+ en.memory?.engine?.vectorStoreInstallHint && pt.memory?.engine?.vectorStoreInstallHint,
+ "vectorStoreInstallHint in both locales"
+ );
+});
diff --git a/tests/unit/v388-phase4-playground.test.ts b/tests/unit/v388-phase4-playground.test.ts
new file mode 100644
index 0000000000..40530c5196
--- /dev/null
+++ b/tests/unit/v388-phase4-playground.test.ts
@@ -0,0 +1,41 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+// Regression guards for v3.8.8 Playground screen fixes (Phase 4).
+const root = join(import.meta.dirname, "../..");
+const read = (p: string) => readFileSync(join(root, p), "utf8");
+
+test("playground config: adds Provider + Model selects reusing translator hooks", () => {
+ const src = read("src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx");
+ assert.ok(src.includes("useProviderOptions"), "reuses useProviderOptions");
+ assert.ok(src.includes("useAvailableModels"), "reuses useAvailableModels");
+ assert.ok(src.includes('update("provider"'), "writes provider into ConfigState");
+ assert.ok(src.includes("provider?: string"), "ConfigState gains provider");
+});
+
+test("playground compare: prompt input + rAF throttle + user message in request", () => {
+ const src = read("src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx");
+ assert.ok(src.includes("requestAnimationFrame"), "throttles stream updates via rAF");
+ assert.ok(/role:\s*"user"/.test(src), "request body includes a user message");
+ assert.ok(src.includes("setPrompt"), "has a prompt input control");
+});
+
+test("playground build: wizard with 3 modes reusing editors; BuildTab keeps handlers", () => {
+ const wiz = read("src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx");
+ assert.ok(wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), "three modes");
+ assert.ok(wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), "reuses both editors");
+ const tab = read("src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx");
+ assert.ok(tab.includes(" {
+ const en = JSON.parse(read("src/i18n/messages/en.json"));
+ const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
+ const ek = Object.keys(en.playground?.build ?? {});
+ const pk = Object.keys(pt.playground?.build ?? {});
+ assert.ok(ek.length >= 10, `expected >=10 build keys, got ${ek.length}`);
+ assert.deepEqual(ek.sort(), pk.sort(), "en/pt-BR playground.build keys must match");
+});
diff --git a/tests/unit/v388-quota-share-usage-guard.test.ts b/tests/unit/v388-quota-share-usage-guard.test.ts
new file mode 100644
index 0000000000..206379e22c
--- /dev/null
+++ b/tests/unit/v388-quota-share-usage-guard.test.ts
@@ -0,0 +1,23 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+// Regression guard: quota-share crashed with "Cannot read properties of undefined
+// (reading 'length')" because PoolCard/aggregate read usage.dimensions without a
+// guard when the usage snapshot came back without a dimensions array.
+const root = join(import.meta.dirname, "../..");
+const read = (p: string) => readFileSync(join(root, p), "utf8");
+
+test("quota-share PoolCard guards usage.dimensions", () => {
+ const pc = read("src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx");
+ assert.ok(pc.includes("usage?.dimensions ?? []"), "computeStatus normalizes dimensions to []");
+ assert.ok(pc.includes("!!usage?.dimensions?.length"), "hasDimensions guards dimensions");
+ assert.equal(/[^?.]usage\.dimensions\.length/.test(pc), false, "no unguarded usage.dimensions.length");
+});
+
+test("quota-share aggregate hook guards dimensions/perKey", () => {
+ const agg = read("src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate.ts");
+ assert.ok(agg.includes("usage.dimensions ?? []"), "iterates dimensions with ?? []");
+ assert.ok(agg.includes("dim.perKey ?? []"), "iterates perKey with ?? []");
+});
|