chore(release): merge v3.4.1 stabilization fixes
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
This commit is contained in:
@@ -2,12 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.4.1] - 2026-03-31
|
||||
|
||||
> [!WARNING]
|
||||
> **BREAKING CHANGE: request logging, retention, and logging environment variables have been redesigned.**
|
||||
> On the first startup after upgrading, OmniRoute archives legacy request logs from `DATA_DIR/logs/`, legacy `DATA_DIR/call_logs/`, and `DATA_DIR/log.txt` into `DATA_DIR/log_archives/*.zip`, then removes the deprecated layout and switches to the new unified artifact format under `DATA_DIR/call_logs/`.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **.ENV Migration Utility:** Included `scripts/migrate-env.mjs` to seamlessly migrate `<v3.3` configurations to `v3.4.x` strict security validation constraints (FASE-01), repairing startup crashes caused by short `JWT_SECRET` instances.
|
||||
- **Kiro AI Cache Optimization:** Implemented deterministic `conversationId` generation (uuidv5) to enable AWS Builder ID Prompt Caching properly across invocations (#814).
|
||||
- **Dashboard UI Restoration & Consolidation:** Resolved sidebar logic omitting the Debug section, and cleared Nextjs routing warnings by moving standalone `/dashboard/mcp` and `/dashboard/a2a` pages explicitly into embedded Endpoint Proxy UI components.
|
||||
- **Unified Request Log Artifacts:** Request logging now stores one SQLite index row plus one JSON artifact per request under `DATA_DIR/call_logs/`, with optional pipeline capture embedded in the same file.
|
||||
- **Language:** Improved the Chinese translation (#855)
|
||||
- **Opencode-Zen Models:** Added 4 free models to opencode-zen registry (#854)
|
||||
@@ -21,6 +26,10 @@
|
||||
- **Provider Quota & Token Parsing:** Switched Antigravity limits to use `retrieveUserQuota` natively and correctly mapped Claude token refresh payloads to URL-encoded forms (#862)
|
||||
- **Rate-Limiting Stability:** Universalized the 429 Retry-After parsing architecture to cap provider-induced cooldowns at 24 hours max (#862)
|
||||
- **Dashboard Limit Rendering:** Re-architected `/dashboard/limits` quota mapping to render immediately inside chunks, fixing a major UI freezing delay on accounts exceeding 70 active connections (#784)
|
||||
- **QWEN OAuth Authorization:** Mapped the OIDC `id_token` as the primary API Bearer token for Dashscope requests, fixing immediate 401 Unauthorized errors after connecting accounts or refreshing tokens (#864)
|
||||
- **ZAI API Stability:** Hardened Server-Sent Events compiler to gracefully fallback to empty strings when DeepSeek providers stream mathematically null content during reasoning phases (#871)
|
||||
- **Claude Code/Codex Translations:** Protected non-streaming payload conversions against empty responses from upstream Codex tools, avoiding catastrophic TypeErrors (#866)
|
||||
- **NVIDIA NIM Rendering:** Conditionally stripped identical provider prefixes dynamically pushed by audio models, eliminating duplicate `nim/nim` tag structures throwing 404 on the Media Playground (#872)
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.3.11",
|
||||
"version": "3.4.1",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
@@ -341,7 +341,7 @@ export function getAllAudioModels() {
|
||||
for (const [providerId, config] of Object.entries(AUDIO_TRANSCRIPTION_PROVIDERS)) {
|
||||
for (const model of config.models) {
|
||||
models.push({
|
||||
id: `${providerId}/${model.id}`,
|
||||
id: model.id.startsWith(`${providerId}/`) ? model.id : `${providerId}/${model.id}`,
|
||||
name: model.name,
|
||||
provider: providerId,
|
||||
subtype: "transcription",
|
||||
@@ -352,7 +352,7 @@ export function getAllAudioModels() {
|
||||
for (const [providerId, config] of Object.entries(AUDIO_SPEECH_PROVIDERS)) {
|
||||
for (const model of config.models) {
|
||||
models.push({
|
||||
id: `${providerId}/${model.id}`,
|
||||
id: model.id.startsWith(`${providerId}/`) ? model.id : `${providerId}/${model.id}`,
|
||||
name: model.name,
|
||||
provider: providerId,
|
||||
subtype: "speech",
|
||||
|
||||
@@ -188,7 +188,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
parseRetryFromErrorMessage(errorMessage) {
|
||||
if (!errorMessage || typeof errorMessage !== "string") return null;
|
||||
|
||||
const match = errorMessage.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i);
|
||||
const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i);
|
||||
if (!match) return null;
|
||||
|
||||
let totalMs = 0;
|
||||
@@ -242,6 +242,29 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
const errorJson = JSON.parse(errorBody);
|
||||
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
|
||||
retryMs = this.parseRetryFromErrorMessage(errorMessage);
|
||||
|
||||
if (!retryMs) {
|
||||
// Dynamic quota interpretation logic for Free vs Pro accounts
|
||||
const lowerMsg = errorMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerMsg.includes("free tier") ||
|
||||
lowerMsg.includes("exhausted your capacity") ||
|
||||
lowerMsg.includes("daily limit") ||
|
||||
lowerMsg.includes("quota exceeded")
|
||||
) {
|
||||
// Hard limit hit for Free accounts (or exhausting general capacity), fallback immediately.
|
||||
// Setting a massive retryMs forces an instant fallback.
|
||||
retryMs = 24 * 60 * 60 * 1000; // 24 hours
|
||||
} else if (
|
||||
lowerMsg.includes("pro") ||
|
||||
lowerMsg.includes("per minute") ||
|
||||
lowerMsg.includes("rpm")
|
||||
) {
|
||||
// RPM limit for Pro counts, backoff up to 1 minute, then fallback
|
||||
retryMs = 60 * 1000; // 60s
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors, will fall back to exponential backoff
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ export class KiroExecutor extends BaseExecutor {
|
||||
...this.config.headers,
|
||||
"Amz-Sdk-Request": "attempt=1; max=3",
|
||||
"Amz-Sdk-Invocation-Id": uuidv4(),
|
||||
"x-amzn-bedrock-cache-control": "enable",
|
||||
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||
};
|
||||
|
||||
if (credentials.accessToken) {
|
||||
@@ -358,11 +360,25 @@ export class KiroExecutor extends BaseExecutor {
|
||||
? ((metrics as JsonRecord).outputTokens as number)
|
||||
: 0;
|
||||
|
||||
const cacheReadTokens =
|
||||
typeof (metrics as JsonRecord).cacheReadTokens === "number"
|
||||
? ((metrics as JsonRecord).cacheReadTokens as number)
|
||||
: 0;
|
||||
|
||||
const cacheCreationTokens =
|
||||
typeof (metrics as JsonRecord).cacheCreationTokens === "number"
|
||||
? ((metrics as JsonRecord).cacheCreationTokens as number)
|
||||
: 0;
|
||||
|
||||
if (inputTokens > 0 || outputTokens > 0) {
|
||||
state.usage = {
|
||||
prompt_tokens: inputTokens,
|
||||
completion_tokens: outputTokens,
|
||||
total_tokens: inputTokens + outputTokens,
|
||||
...(cacheReadTokens > 0 && { cache_read_input_tokens: cacheReadTokens }),
|
||||
...(cacheCreationTokens > 0 && {
|
||||
cache_creation_input_tokens: cacheCreationTokens,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,11 +402,14 @@ export function translateNonStreamingResponse(
|
||||
* Helper to convert an OpenAI chat.completion JSON object to Claude format for non-streaming.
|
||||
*/
|
||||
function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonRecord {
|
||||
const choice = Array.isArray(openaiResponse.choices) ? openaiResponse.choices[0] : null;
|
||||
if (!choice) return openaiResponse; // If it doesn't look like OpenAI, return as-is
|
||||
const isChoicesArray = Array.isArray(openaiResponse.choices);
|
||||
if (!isChoicesArray && openaiResponse.object !== "chat.completion") {
|
||||
return openaiResponse; // If it doesn't look like OpenAI, return as-is
|
||||
}
|
||||
|
||||
const choiceObj = toRecord(choice);
|
||||
const messageObj = toRecord(choiceObj.message);
|
||||
const choice = isChoicesArray ? openaiResponse.choices[0] : null;
|
||||
const choiceObj = choice ? toRecord(choice) : {};
|
||||
const messageObj = choiceObj.message ? toRecord(choiceObj.message) : {};
|
||||
|
||||
const content: JsonRecord[] = [];
|
||||
|
||||
|
||||
@@ -53,7 +53,11 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
reasoningParts.push(delta.reasoning_content);
|
||||
}
|
||||
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
|
||||
if (typeof delta.reasoning === "string" && delta.reasoning.length > 0 && !delta.reasoning_content) {
|
||||
if (
|
||||
typeof delta.reasoning === "string" &&
|
||||
delta.reasoning.length > 0 &&
|
||||
!delta.reasoning_content
|
||||
) {
|
||||
reasoningParts.push(delta.reasoning);
|
||||
}
|
||||
|
||||
@@ -98,11 +102,11 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
}
|
||||
}
|
||||
|
||||
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
|
||||
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : "";
|
||||
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: joinedContent || null,
|
||||
content: joinedContent,
|
||||
};
|
||||
if (joinedReasoning) {
|
||||
message.reasoning_content = joinedReasoning;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.3.11",
|
||||
"version": "3.4.1",
|
||||
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -324,7 +324,7 @@ export async function refreshQwenToken(refreshToken, log) {
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
accessToken: tokens.id_token || tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
|
||||
@@ -287,7 +287,7 @@ export function buildKiroPayload(model, body, stream, credentials) {
|
||||
} = {
|
||||
conversationState: {
|
||||
chatTriggerType: "MANUAL",
|
||||
conversationId: uuidv4(),
|
||||
conversationId: uuidv4(), // We must override this with deterministic ID
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: finalContent,
|
||||
@@ -302,6 +302,20 @@ export function buildKiroPayload(model, body, stream, credentials) {
|
||||
},
|
||||
};
|
||||
|
||||
// Determistic session caching for Kiro
|
||||
const NAMESPACE_KIRO = "34f7193f-561d-4050-bc84-9547d953d6bf";
|
||||
const firstContent =
|
||||
history.length > 0 && history[0].userInputMessage?.content
|
||||
? history[0].userInputMessage.content
|
||||
: finalContent;
|
||||
|
||||
// Use uuidv5 with the hash of the system prompt / first message to maintain AWS Builder ID context cache
|
||||
const { v5: uuidv5 } = require("uuid");
|
||||
payload.conversationState.conversationId = uuidv5(
|
||||
(firstContent || "").substring(0, 4000),
|
||||
NAMESPACE_KIRO
|
||||
);
|
||||
|
||||
if (profileArn) {
|
||||
payload.profileArn = profileArn;
|
||||
}
|
||||
|
||||
Generated
+6
-2
@@ -26,7 +26,6 @@
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"keytar": "^7.9.0",
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.0.10",
|
||||
@@ -77,6 +76,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0 <24.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -12613,6 +12615,7 @@
|
||||
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^4.3.0",
|
||||
"prebuild-install": "^7.0.1"
|
||||
@@ -12622,7 +12625,8 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
|
||||
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
|
||||
+3
-1
@@ -97,7 +97,6 @@
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"keytar": "^7.9.0",
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.0.10",
|
||||
@@ -119,6 +118,9 @@
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* OmniRoute v3.3 -> v3.4 Environment Migration Script
|
||||
* Resolves breaking changes in environment variables format and validation.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
console.log("No .env file found. Migration skipped.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(envPath, "utf8");
|
||||
let modified = false;
|
||||
|
||||
// 1. Migrate NEXTAUTH_SECRET to JWT_SECRET if missing
|
||||
const nextAuthMatch = content.match(/^NEXTAUTH_SECRET=(.+)$/m);
|
||||
const jwtMatch = content.match(/^JWT_SECRET=(.+)$/m);
|
||||
|
||||
if (nextAuthMatch && !jwtMatch) {
|
||||
console.log("Migrating NEXTAUTH_SECRET to JWT_SECRET...");
|
||||
let newJwt = nextAuthMatch[1].trim();
|
||||
|
||||
// Enforce 32 char minimum for secretsValidator.ts
|
||||
if (newJwt.length < 32) {
|
||||
console.warn(
|
||||
`Original NEXTAUTH_SECRET was too short (${newJwt.length} chars). Generating a secure one...`
|
||||
);
|
||||
newJwt = crypto.randomBytes(48).toString("base64");
|
||||
}
|
||||
|
||||
content += `\n# Migrated from NEXTAUTH_SECRET\nJWT_SECRET=${newJwt}\n`;
|
||||
modified = true;
|
||||
} else if (jwtMatch && jwtMatch[1].trim().length < 32) {
|
||||
console.warn(
|
||||
`JWT_SECRET is too short (${jwtMatch[1].trim().length} chars). Generating a secure one for v3.4.0+...`
|
||||
);
|
||||
const newJwt = crypto.randomBytes(48).toString("base64");
|
||||
content = content.replace(/^JWT_SECRET=(.*)$/m, `JWT_SECRET=${newJwt}`);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// 2. Ensure API_KEY_SECRET exists (required in 3.4.0)
|
||||
if (!content.match(/^API_KEY_SECRET=/m)) {
|
||||
console.log("Adding required API_KEY_SECRET for v3.4.0...");
|
||||
const newApiSecret = crypto.randomBytes(32).toString("hex");
|
||||
content += `\n# Required for v3.4.0 API Key HMAC\nAPI_KEY_SECRET=${newApiSecret}\n`;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
// Backup old .env
|
||||
fs.writeFileSync(envPath + ".bak", fs.readFileSync(envPath));
|
||||
console.log("Created backup at .env.bak");
|
||||
|
||||
// Write new .env
|
||||
fs.writeFileSync(envPath, content, "utf8");
|
||||
console.log("Successfully migrated .env file for OmniRoute 3.4.x.");
|
||||
} else {
|
||||
console.log(".env file is already compatible with OmniRoute 3.4.x.");
|
||||
}
|
||||
@@ -146,7 +146,11 @@ export function isNativeBinaryCompatible(
|
||||
const target = readNativeBinaryTarget(binaryPath);
|
||||
|
||||
if (target) {
|
||||
if (target.platform !== runtimePlatform || !target.architectures.includes(runtimeArch)) {
|
||||
if (
|
||||
(target.platform !== runtimePlatform &&
|
||||
!(target.platform === "linux" && runtimePlatform === "android")) ||
|
||||
!target.architectures.includes(runtimeArch)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (runtimePlatform !== PUBLISHED_BUILD_PLATFORM || runtimeArch !== PUBLISHED_BUILD_ARCH) {
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Audit Log Viewer — P-2
|
||||
*
|
||||
* Dashboard page for viewing administrative audit log entries.
|
||||
* Fetches from /api/compliance/audit-log with filter support.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AuditEntry {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
actor: string;
|
||||
target: string | null;
|
||||
details: any;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const t = useTranslations("auditLog");
|
||||
const tc = useTranslations("common");
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
const [actorFilter, setActorFilter] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (actionFilter) params.set("action", actionFilter);
|
||||
if (actorFilter) params.set("actor", actorFilter);
|
||||
params.set("limit", String(PAGE_SIZE + 1));
|
||||
params.set("offset", String(offset));
|
||||
|
||||
const res = await fetch(`/api/compliance/audit-log?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: AuditEntry[] = await res.json();
|
||||
|
||||
setHasMore(data.length > PAGE_SIZE);
|
||||
setEntries(data.slice(0, PAGE_SIZE));
|
||||
} catch (err: any) {
|
||||
setError(err.message || t("failedFetchAuditLog"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [actionFilter, actorFilter, offset, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
}, [fetchEntries]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setOffset(0);
|
||||
fetchEntries();
|
||||
};
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
};
|
||||
|
||||
const actionBadgeColor = (action: string) => {
|
||||
if (action.includes("delete") || action.includes("remove"))
|
||||
return "bg-red-500/15 text-red-400 border-red-500/20";
|
||||
if (action.includes("create") || action.includes("add"))
|
||||
return "bg-green-500/15 text-green-400 border-green-500/20";
|
||||
if (action.includes("update") || action.includes("change"))
|
||||
return "bg-blue-500/15 text-blue-400 border-blue-500/20";
|
||||
if (action.includes("login") || action.includes("auth"))
|
||||
return "bg-purple-500/15 text-purple-400 border-purple-500/20";
|
||||
return "bg-gray-500/15 text-gray-400 border-gray-500/20";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">{t("title")}</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("description")}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchEntries}
|
||||
disabled={loading}
|
||||
aria-label={t("refreshAuditLogAria")}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? tc("loading") : tc("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div
|
||||
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
|
||||
role="search"
|
||||
aria-label={t("filterEntriesAria")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("filterByAction")}
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
aria-label={t("filterByActionTypeAria")}
|
||||
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("filterByActor")}
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
aria-label={t("filterByActorAria")}
|
||||
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
|
||||
>
|
||||
{tc("search")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm" role="table" aria-label={t("tableAria")}>
|
||||
<thead>
|
||||
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("timestamp")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("action")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("actor")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("target")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{tc("details")}
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
|
||||
{t("ipAddress")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
|
||||
{t("noEntries")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-[var(--color-text-muted)] font-mono text-xs">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${actionBadgeColor(entry.action)}`}
|
||||
>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
|
||||
{entry.target || t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
|
||||
{entry.details ? JSON.stringify(entry.details) : t("notAvailable")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
|
||||
{entry.ip_address || t("notAvailable")}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{t("showing", { count: entries.length, offset })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
← {t("previous")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
disabled={!hasMore}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
|
||||
>
|
||||
{tc("next")} →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { SegmentedControl } from "@/shared/components";
|
||||
import EndpointPageClient from "./EndpointPageClient";
|
||||
import McpDashboardPage from "../mcp/page";
|
||||
import A2ADashboardPage from "../a2a/page";
|
||||
import McpDashboardPage from "./components/MCPDashboard";
|
||||
import A2ADashboardPage from "./components/A2ADashboard";
|
||||
import ApiEndpointsTab from "./ApiEndpointsTab";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ProfilePage() {
|
||||
redirect("/dashboard/settings");
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState } from "react";
|
||||
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
|
||||
|
||||
export default function UsagePage() {
|
||||
const t = useTranslations("usage");
|
||||
const [activeTab, setActiveTab] = useState("logs");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "logs", label: t("loggerTab") },
|
||||
{ value: "proxy-logs", label: t("proxyTab") },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === "logs" && <RequestLoggerV2 />}
|
||||
{activeTab === "proxy-logs" && <ProxyLogger />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export const qwen = {
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
accessToken: tokens.id_token || tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
idToken: tokens.id_token,
|
||||
|
||||
@@ -4,6 +4,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"api-manager",
|
||||
"providers",
|
||||
"combos",
|
||||
"auto-combo",
|
||||
"costs",
|
||||
"analytics",
|
||||
"limits",
|
||||
@@ -49,6 +50,7 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
{ id: "api-manager", href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" },
|
||||
{ id: "providers", href: "/dashboard/providers", i18nKey: "providers", icon: "dns" },
|
||||
{ id: "combos", href: "/dashboard/combos", i18nKey: "combos", icon: "layers" },
|
||||
{ id: "auto-combo", href: "/dashboard/auto-combo", i18nKey: "autoCombo", icon: "auto_awesome" },
|
||||
{ id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
|
||||
{ id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
|
||||
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
|
||||
|
||||
@@ -525,34 +525,20 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
|
||||
if (isWindows()) {
|
||||
const located = await runProcess("where", [command], { env, timeoutMs: 3000 });
|
||||
if (!located.ok || !located.stdout) {
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
if (located.ok && located.stdout) {
|
||||
return { installed: true, commandPath: command, reason: null };
|
||||
}
|
||||
const lines = located.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => normalizeMsys2Path(line.trim()))
|
||||
.filter(Boolean);
|
||||
|
||||
// Issue #809: Prioritize executable wrappers (.cmd, .exe, .bat) over extensionless bash scripts
|
||||
// that NPM often drops alongside the wrappers in global installs.
|
||||
const first = lines.find((line) => /\.(cmd|exe|bat)$/i.test(line)) || lines[0] || null;
|
||||
|
||||
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
}
|
||||
|
||||
const located = await runProcess("sh", ["-c", 'command -v -- "$1"', "sh", command], {
|
||||
env,
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
if (!located.ok || !located.stdout) {
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
if (located.ok && located.stdout) {
|
||||
return { installed: true, commandPath: command, reason: null };
|
||||
}
|
||||
const first =
|
||||
located.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => normalizeMsys2Path(line.trim()))
|
||||
.find(Boolean) || null;
|
||||
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user