feat(usage): add SQLite usage records and portable tooling
This commit is contained in:
1 parent
41efeebcbb
commit
c6ea0fecd9
13 files changed
+3630
-83
No files matched your search
@@ -0,0 +1,94 @@
|
||||
name: Build portable releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
os: linux
|
||||
arch: amd64
|
||||
suffix: tar.gz
|
||||
- runner: ubuntu-24.04-arm
|
||||
os: linux
|
||||
arch: aarch64
|
||||
suffix: tar.gz
|
||||
- runner: macos-15-intel
|
||||
os: darwin
|
||||
arch: amd64
|
||||
suffix: tar.gz
|
||||
- runner: macos-15
|
||||
os: darwin
|
||||
arch: aarch64
|
||||
suffix: tar.gz
|
||||
- runner: windows-2025
|
||||
os: windows
|
||||
arch: amd64
|
||||
suffix: zip
|
||||
- runner: windows-11-arm
|
||||
os: windows
|
||||
arch: aarch64
|
||||
suffix: zip
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Build GUI
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun tauri build --no-bundle
|
||||
shell: bash
|
||||
- name: Prepare portable directory
|
||||
run: >-
|
||||
node scripts/prepare-portable.mjs
|
||||
--binary "src-tauri/target/release/cpa-gui${{ matrix.os == 'windows' && '.exe' || '' }}"
|
||||
--output "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}"
|
||||
--os "${{ matrix.os }}"
|
||||
--arch "${{ matrix.arch }}"
|
||||
--download true
|
||||
--clean-core true
|
||||
shell: bash
|
||||
- name: Archive Unix release
|
||||
if: matrix.os != 'windows'
|
||||
run: tar -czf "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" -C release "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}"
|
||||
- name: Archive Windows release
|
||||
if: matrix.os == 'windows'
|
||||
shell: pwsh
|
||||
run: Compress-Archive -Path "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" -DestinationPath "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.zip"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.${{ matrix.suffix }}
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release create "${GITHUB_REF_NAME}" artifacts/* --generate-notes --verify-tag
|
||||
@@ -24,7 +24,9 @@ if [ ! -x "$APP_BIN" ]; then
|
||||
fi
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
cp -f "$APP_BIN" "$BIN_OUT"
|
||||
bun "$ROOT_DIR/scripts/prepare-portable.mjs" \
|
||||
--binary "$APP_BIN" \
|
||||
--output "$BIN_DIR"
|
||||
chmod +x "$BIN_OUT"
|
||||
|
||||
echo "Built: $APP_BIN"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
7.2.83
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 2) {
|
||||
args.set(process.argv[index], process.argv[index + 1]);
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const output = resolve(args.get('--output') ?? join(root, 'bin-work'));
|
||||
const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', 'release', process.platform === 'win32' ? 'cpa-gui.exe' : 'cpa-gui'));
|
||||
const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform];
|
||||
const targetArch = args.get('--arch') ?? ({ x64: 'amd64', arm64: 'aarch64' })[process.arch];
|
||||
const shouldDownload = args.get('--download') === 'true';
|
||||
const cleanCore = args.get('--clean-core') === 'true';
|
||||
|
||||
if (!targetOS || !targetArch) throw new Error(`Unsupported target: ${process.platform}/${process.arch}`);
|
||||
if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`);
|
||||
|
||||
const rawVersion = (await readFile(join(root, 'core-version.txt'), 'utf8')).trim();
|
||||
if (!/^v?\d+(?:\.\d+)+$/.test(rawVersion)) throw new Error(`Invalid core-version.txt: ${rawVersion}`);
|
||||
const version = rawVersion.replace(/^v/i, '');
|
||||
const extension = targetOS === 'windows' ? 'zip' : 'tar.gz';
|
||||
const assetName = `CLIProxyAPI_${version}_${targetOS}_${targetArch}.${extension}`;
|
||||
const sourceDir = join(root, 'cpa-core');
|
||||
const sourceArchive = join(sourceDir, assetName);
|
||||
const checksumsPath = join(sourceDir, 'checksums.txt');
|
||||
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
if (!existsSync(sourceArchive)) {
|
||||
if (!shouldDownload) {
|
||||
console.warn(`Built-in core archive not found; portable output keeps the existing core: ${sourceArchive}`);
|
||||
await mkdir(output, { recursive: true });
|
||||
const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui');
|
||||
await copyFile(binary, outputBinary);
|
||||
if (targetOS !== 'windows') await chmod(outputBinary, 0o755);
|
||||
await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt'));
|
||||
process.exit(0);
|
||||
}
|
||||
const tag = `v${version}`;
|
||||
const releaseBase = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${tag}`;
|
||||
const [archiveResponse, checksumsResponse] = await Promise.all([
|
||||
fetch(`${releaseBase}/${assetName}`),
|
||||
fetch(`${releaseBase}/checksums.txt`),
|
||||
]);
|
||||
if (!archiveResponse.ok) throw new Error(`Download ${assetName} failed: HTTP ${archiveResponse.status}`);
|
||||
if (!checksumsResponse.ok) throw new Error(`Download checksums.txt failed: HTTP ${checksumsResponse.status}`);
|
||||
await writeFile(sourceArchive, Buffer.from(await archiveResponse.arrayBuffer()));
|
||||
await writeFile(checksumsPath, await checksumsResponse.text());
|
||||
}
|
||||
|
||||
if (existsSync(checksumsPath)) {
|
||||
const checksums = await readFile(checksumsPath, 'utf8');
|
||||
const expected = checksums.split(/\r?\n/).map((line) => line.trim().split(/\s+/)).find((parts) => parts[1]?.replace(/^\*/, '') === assetName)?.[0]?.toLowerCase();
|
||||
if (!expected) throw new Error(`checksums.txt does not contain ${assetName}`);
|
||||
const actual = createHash('sha256').update(await readFile(sourceArchive)).digest('hex');
|
||||
if (actual !== expected) throw new Error(`SHA-256 mismatch for ${assetName}`);
|
||||
}
|
||||
|
||||
await mkdir(output, { recursive: true });
|
||||
const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui');
|
||||
await copyFile(binary, outputBinary);
|
||||
if (targetOS !== 'windows') await chmod(outputBinary, 0o755);
|
||||
await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt'));
|
||||
|
||||
const coreOutput = join(output, 'cpa-core');
|
||||
if (cleanCore) await rm(coreOutput, { recursive: true, force: true });
|
||||
await mkdir(coreOutput, { recursive: true });
|
||||
const extraction = spawnSync('tar', ['-xf', sourceArchive, '-C', coreOutput], { stdio: 'inherit' });
|
||||
if (extraction.status !== 0) throw new Error(`Failed to extract ${assetName}`);
|
||||
await copyFile(sourceArchive, join(coreOutput, assetName));
|
||||
if (existsSync(checksumsPath)) await copyFile(checksumsPath, join(coreOutput, 'checksums.txt'));
|
||||
await writeFile(join(coreOutput, 'cpa-gui-meta.json'), `${JSON.stringify({
|
||||
version: `v${version}`,
|
||||
assetName,
|
||||
installedAtUnix: Math.floor(Date.now() / 1000),
|
||||
}, null, 2)}\n`);
|
||||
|
||||
console.log(`Prepared portable directory: ${output}`);
|
||||
console.log(`Bundled core: ${basename(sourceArchive)}`);
|
||||
Generated
+91
@@ -8,6 +8,18 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -342,8 +354,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -417,11 +431,13 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
name = "cpa-gui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"json5",
|
||||
"libc",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_norway",
|
||||
@@ -807,6 +823,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -1284,6 +1312,9 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -1291,6 +1322,15 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -1784,6 +1824,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2736,6 +2787,20 @@ dependencies = [
|
||||
"text-size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -4098,6 +4163,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -4892,6 +4963,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@@ -9,10 +9,12 @@ edition = "2021"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
json5 = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
rusqlite = { version = "0.32.1", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_norway = "0.9"
|
||||
|
||||
+842
-58
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bot, FileKey, Gauge, GitFork, LogIn, Network, ServerCog, Settings } from 'lucide-react';
|
||||
import { Bot, FileKey, Gauge, GitFork, History, LogIn, Network, ServerCog, Settings } from 'lucide-react';
|
||||
import appLogo from './assets/logo.jpg';
|
||||
import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime';
|
||||
import { ConfigPanelPage } from './pages/ConfigPanel';
|
||||
@@ -10,6 +10,7 @@ import { OAuthLoginPage } from './pages/ManagementPages';
|
||||
import { QuotaPage } from './pages/QuotaPage';
|
||||
import { AgentsPage } from './pages/AgentsPage';
|
||||
import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage';
|
||||
import { UsageRecordsPage } from './pages/UsageRecordsPage';
|
||||
|
||||
const pages = [
|
||||
{
|
||||
@@ -54,6 +55,12 @@ const pages = [
|
||||
icon: Gauge,
|
||||
component: QuotaPage,
|
||||
},
|
||||
{
|
||||
id: 'usage-records',
|
||||
label: '使用记录',
|
||||
icon: History,
|
||||
component: UsageRecordsPage,
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
label: '智能体',
|
||||
|
||||
+47
-14
@@ -96,7 +96,7 @@ const agentDefinitions: AgentDefinition[] = [
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
icon: claudeIcon,
|
||||
description: '使用 CPA 3P 推理网关,仅支持 Windows 和 macOS',
|
||||
description: '使用 CPA 3P 推理网关,支持 Windows、macOS 和 Linux Beta',
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
@@ -125,6 +125,29 @@ const agentDefinitions: AgentDefinition[] = [
|
||||
];
|
||||
|
||||
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
|
||||
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
|
||||
|
||||
const readSelectedAgentClient = (): AgentClientId => {
|
||||
const fallback = agentDefinitions[0].id;
|
||||
if (typeof window === 'undefined') return fallback;
|
||||
try {
|
||||
const saved = window.localStorage.getItem(AGENT_SELECTED_CLIENT_KEY);
|
||||
return agentDefinitions.some((agent) => agent.id === saved)
|
||||
? (saved as AgentClientId)
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const writeSelectedAgentClient = (client: AgentClientId) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(AGENT_SELECTED_CLIENT_KEY, client);
|
||||
} catch {
|
||||
// Keep the current in-memory selection when persistent storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const readAgentModelSelections = (): Partial<Record<AgentClientId, string>> => {
|
||||
if (typeof window === 'undefined') return {};
|
||||
@@ -365,7 +388,7 @@ function AgentModelPicker({
|
||||
}
|
||||
|
||||
export function AgentsPage() {
|
||||
const [selected, setSelected] = useState<AgentClientId>('claude-code');
|
||||
const [selected, setSelected] = useState<AgentClientId>(readSelectedAgentClient);
|
||||
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
|
||||
@@ -419,6 +442,10 @@ export function AgentsPage() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
writeSelectedAgentClient(selected);
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
setLaunchTargetByClient((current) => agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>(
|
||||
(next, definition) => {
|
||||
@@ -460,6 +487,8 @@ export function AgentsPage() {
|
||||
const canLaunch = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
&& activeStatus.installed
|
||||
&& activeStatus.modificationEnabled
|
||||
&& activeStatus.modificationState === 'active'
|
||||
&& selectedLaunchTarget,
|
||||
);
|
||||
|
||||
@@ -513,7 +542,7 @@ export function AgentsPage() {
|
||||
}
|
||||
setRestoreConflict(null);
|
||||
setNotice(result.enabled
|
||||
? `${activeDefinition.name} 已启用配置修改,请重启客户端`
|
||||
? `${activeDefinition.name} 已启用配置修改;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
: `${activeDefinition.name} 的原配置已恢复,请重启客户端`);
|
||||
await loadStatuses();
|
||||
} catch (requestError) {
|
||||
@@ -529,10 +558,10 @@ export function AgentsPage() {
|
||||
setNotice('');
|
||||
try {
|
||||
let appliedBeforeLaunch = false;
|
||||
const shouldSyncCodexCatalog = Boolean(
|
||||
selected === 'codex' && activeStatus?.modificationEnabled && selectedModelOption,
|
||||
const shouldSyncModelCatalog = Boolean(
|
||||
activeStatus?.modificationEnabled && selectedModelOption,
|
||||
);
|
||||
if (draftChanged || shouldSyncCodexCatalog) {
|
||||
if (draftChanged || shouldSyncModelCatalog) {
|
||||
if (activeStatus?.modificationState !== 'active') {
|
||||
throw new Error('当前配置存在冲突或恢复任务,暂时不能应用新模型');
|
||||
}
|
||||
@@ -547,9 +576,11 @@ export function AgentsPage() {
|
||||
await invoke('launch_agent', { client: selected, target: selectedLaunchTarget?.id });
|
||||
setNotice(appliedBeforeLaunch
|
||||
? draftChanged
|
||||
? `${activeDefinition.name} 已应用所选模型并启动`
|
||||
: `${activeDefinition.name} 已同步模型目录并启动`
|
||||
: `${activeDefinition.name} 已启动`);
|
||||
? `${activeDefinition.name} 已应用模型并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
: `${activeDefinition.name} 已同步模型目录并启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
: activeStatus?.modificationEnabled
|
||||
? `${activeDefinition.name} 已启动;退出软件前建议关闭“修改智能体配置”,恢复原配置文件`
|
||||
: `${activeDefinition.name} 已启动`);
|
||||
if (appliedBeforeLaunch) await loadStatuses();
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
@@ -655,7 +686,7 @@ export function AgentsPage() {
|
||||
|
||||
<section className="agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>使用模型</strong><span>只能选择内核当前实际开放的模型</span></div>
|
||||
<div><strong>使用模型</strong><span>同步内核当前开放的模型,并将所选项设为默认模型</span></div>
|
||||
{draftChanged ? <span className="agent-pending-badge">尚未应用</span> : null}
|
||||
</div>
|
||||
<AgentModelPicker
|
||||
@@ -689,7 +720,7 @@ export function AgentsPage() {
|
||||
: activeStatus.modificationState === 'recovery'
|
||||
? '上次操作未完成,关闭开关可尝试恢复原配置'
|
||||
: '原配置已备份,当前由 CPA 管理'
|
||||
: '开启后先备份原配置,再写入 CPA 配置'}</span>
|
||||
: '启动客户端前必须开启;程序会先备份原配置,再写入 CPA 配置'}</span>
|
||||
</div>
|
||||
<label className="switch-control" title="修改智能体配置">
|
||||
<input
|
||||
@@ -708,7 +739,7 @@ export function AgentsPage() {
|
||||
<span>{activeStatus?.modificationEnabled
|
||||
? draftChanged
|
||||
? '模型选择已变化,启动时会先应用新模型'
|
||||
: '关闭开关会恢复开启前的原始配置;可直接启动智能体'
|
||||
: '退出软件前建议关闭“修改智能体配置”,恢复开启前的原配置文件'
|
||||
: !activeStatus?.supportedPlatform
|
||||
? '当前系统无法配置该客户端'
|
||||
: !activeStatus.installed
|
||||
@@ -717,7 +748,7 @@ export function AgentsPage() {
|
||||
? '检测到配置文件,但没有找到客户端命令'
|
||||
: !activeStatus.configValid
|
||||
? '原配置格式异常,无法安全修改'
|
||||
: '未开启修改配置时,将使用客户端原配置启动'}</span>
|
||||
: '请先开启“修改智能体配置”,确保 CPA 模型配置生效后再启动客户端'}</span>
|
||||
</div>
|
||||
<div className="agent-launch-actions">
|
||||
{activeLaunchTargets.length > 1 ? (
|
||||
@@ -748,7 +779,9 @@ export function AgentsPage() {
|
||||
|| !canLaunch
|
||||
|| (draftChanged && activeStatus?.modificationState !== 'active')
|
||||
}
|
||||
title={selectedLaunchTarget?.detail}
|
||||
title={activeStatus?.modificationEnabled
|
||||
? selectedLaunchTarget?.detail
|
||||
: '请先开启“修改智能体配置”'}
|
||||
>
|
||||
{busy ? <LoaderCircle size={16} className="spin" /> : <Play size={16} />}
|
||||
{busy ? '启动中' : selectedLaunchTarget ? `启动 ${selectedLaunchTarget.label}` : '无法启动'}
|
||||
|
||||
+101
-7
@@ -21,6 +21,12 @@ type CoreLatest = {
|
||||
assetName: string;
|
||||
};
|
||||
|
||||
type BundledCoreInfo = {
|
||||
version: string;
|
||||
assetName: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
type CoreInstallResult = {
|
||||
version: string;
|
||||
assetName: string;
|
||||
@@ -76,6 +82,17 @@ function requestLatestCore() {
|
||||
return latestCheckPromise;
|
||||
}
|
||||
|
||||
const compareVersions = (left: string, right: string) => {
|
||||
const parse = (value: string) => value.replace(/^v/i, '').split('.').map((part) => Number(part) || 0);
|
||||
const leftParts = parse(left);
|
||||
const rightParts = parse(right);
|
||||
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
||||
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function KernelPage() {
|
||||
const { publishStatus } = useCoreRuntime();
|
||||
const [coreStatus, setCoreStatus] = useState<CoreStatus | null>(null);
|
||||
@@ -84,6 +101,8 @@ export function KernelPage() {
|
||||
const [platformError, setPlatformError] = useState('');
|
||||
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
|
||||
const [latestError, setLatestError] = useState(cachedLatestError);
|
||||
const [bundledCore, setBundledCore] = useState<BundledCoreInfo | null>(null);
|
||||
const [bundledCoreError, setBundledCoreError] = useState('');
|
||||
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
|
||||
const [allowLanAccess, setAllowLanAccess] = useState(false);
|
||||
const [customPort, setCustomPort] = useState('8317');
|
||||
@@ -179,6 +198,7 @@ export function KernelPage() {
|
||||
|
||||
loadCoreStatus();
|
||||
loadPlatform();
|
||||
loadBundledCore();
|
||||
loadInstallTask();
|
||||
loadGuiSettings();
|
||||
|
||||
@@ -394,6 +414,17 @@ export function KernelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadBundledCore = async () => {
|
||||
try {
|
||||
const result = await invoke<BundledCoreInfo | null>('detect_bundled_core');
|
||||
setBundledCore(result);
|
||||
setBundledCoreError('');
|
||||
} catch (error) {
|
||||
setBundledCore(null);
|
||||
setBundledCoreError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const checkLatest = async () => {
|
||||
setCheckingLatest(true);
|
||||
setLatestError('');
|
||||
@@ -452,7 +483,7 @@ export function KernelPage() {
|
||||
message: `${result.version} 安装完成`,
|
||||
result,
|
||||
});
|
||||
await loadCoreStatus();
|
||||
await Promise.all([loadCoreStatus(), loadBundledCore()]);
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
setMessage(errorMessage);
|
||||
@@ -472,6 +503,55 @@ export function KernelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const installBundledCore = async () => {
|
||||
if (!bundledCore) return;
|
||||
const comparison = currentVersion ? compareVersions(currentVersion, bundledCore.version) : 0;
|
||||
const prompt = comparison > 0
|
||||
? `当前内核 ${currentVersion} 高于内置版本 ${bundledCore.version},继续会降级。是否确认?`
|
||||
: currentVersion === bundledCore.version
|
||||
? `将使用内置压缩包重新安装 ${bundledCore.version},是否继续?`
|
||||
: `将安装内置内核 ${bundledCore.version},是否继续?`;
|
||||
if (!window.confirm(prompt)) return;
|
||||
|
||||
setInstalling(true);
|
||||
setMessage(`正在安装内置内核 ${bundledCore.version}`);
|
||||
setMessageType('info');
|
||||
setCancellingInstall(false);
|
||||
setInstallDialogOpen(true);
|
||||
setProgress({
|
||||
running: true,
|
||||
cancellable: false,
|
||||
phase: '准备内置内核',
|
||||
downloaded: 0,
|
||||
total: bundledCore.sizeBytes,
|
||||
percent: 0,
|
||||
message: null,
|
||||
result: null,
|
||||
});
|
||||
try {
|
||||
const result = await invoke<CoreInstallResult>('install_bundled_core');
|
||||
setMessage(`${result.version} 内置内核安装完成`);
|
||||
setMessageType('success');
|
||||
await Promise.all([loadCoreStatus(), loadBundledCore()]);
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
setMessage(errorMessage);
|
||||
setMessageType('error');
|
||||
setProgress((current) => ({
|
||||
running: false,
|
||||
cancellable: false,
|
||||
phase: '安装失败',
|
||||
downloaded: current?.downloaded ?? 0,
|
||||
total: current?.total ?? null,
|
||||
percent: current?.percent ?? null,
|
||||
message: errorMessage,
|
||||
result: null,
|
||||
}));
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelInstall = async () => {
|
||||
if (cancellingInstall || !progress?.running || !progress.cancellable) {
|
||||
return;
|
||||
@@ -750,8 +830,11 @@ export function KernelPage() {
|
||||
|
||||
<div className="panel version-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>版本管理</h2>
|
||||
<div className="version-heading-inline">
|
||||
<h2>内核版本管理</h2>
|
||||
<span className="version-offline-hint">
|
||||
GitHub 无法连接时,可使用离线安装使用内置内核。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -765,12 +848,14 @@ export function KernelPage() {
|
||||
<dd>{latestLabel}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>操作系统</dt>
|
||||
<dd title={platformError || undefined}>{platformOsLabel}</dd>
|
||||
<dt>内置版本</dt>
|
||||
<dd title={bundledCoreError || bundledCore?.assetName}>
|
||||
{bundledCore?.version ?? (bundledCoreError ? '检测失败' : '未包含')}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>系统架构</dt>
|
||||
<dd title={platformError || undefined}>{platformArchLabel}</dd>
|
||||
<dt>运行平台</dt>
|
||||
<dd title={platformError || undefined}>{platformOsLabel} / {platformArchLabel}</dd>
|
||||
</div>
|
||||
<div className="panel-detail-row">
|
||||
<dt>更新状态</dt>
|
||||
@@ -807,6 +892,15 @@ export function KernelPage() {
|
||||
>
|
||||
重新安装
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
title={(bundledCore?.assetName ?? bundledCoreError) || '当前发行包未包含内置内核'}
|
||||
disabled={!bundledCore || installDisabled}
|
||||
onClick={() => void installBundledCore()}
|
||||
>
|
||||
离线安装
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { Activity, BarChart3, Clock3, Database, List, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
|
||||
|
||||
type UsageTab = 'overview' | 'analysis' | 'events';
|
||||
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
|
||||
|
||||
type CollectorStatus = {
|
||||
state: 'waiting-core' | 'collecting' | 'error';
|
||||
message: string;
|
||||
lastCollectedAt: string | null;
|
||||
totalRecords: number;
|
||||
};
|
||||
|
||||
type TimelinePoint = {
|
||||
hour: string;
|
||||
requests: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
type UsageOverview = {
|
||||
totalRequests: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
totalTokens: number;
|
||||
rpm: number;
|
||||
tpm: number;
|
||||
averageLatencyMs: number;
|
||||
timeline: TimelinePoint[];
|
||||
};
|
||||
|
||||
type UsageCategory = {
|
||||
key: string;
|
||||
label: string;
|
||||
requests: number;
|
||||
failures: number;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
type UsageAnalysis = {
|
||||
models: UsageCategory[];
|
||||
providers: UsageCategory[];
|
||||
sources: UsageCategory[];
|
||||
apiKeys: UsageCategory[];
|
||||
};
|
||||
|
||||
type UsageRecord = {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
latency_ms: number;
|
||||
ttft_ms: number | null;
|
||||
source: string;
|
||||
failed: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
alias: string;
|
||||
reasoning_effort: string;
|
||||
endpoint: string;
|
||||
api_key_hash: string;
|
||||
api_key_display: string;
|
||||
api_key_remark: string;
|
||||
tokens: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
reasoning_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_creation_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
};
|
||||
|
||||
type UsageEventPage = {
|
||||
items: UsageRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type UsageQuery = {
|
||||
start?: string;
|
||||
end?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
source?: string;
|
||||
api_key_hash?: string;
|
||||
failed?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
|
||||
const TAB_KEY = 'cpa-gui.usage-records-tab.v1';
|
||||
const RANGE_KEY = 'cpa-gui.usage-records-range.v1';
|
||||
const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], apiKeys: [] };
|
||||
|
||||
const loadTab = (): UsageTab => {
|
||||
try {
|
||||
const saved = localStorage.getItem(TAB_KEY);
|
||||
return saved === 'analysis' || saved === 'events' ? saved : 'overview';
|
||||
} catch {
|
||||
return 'overview';
|
||||
}
|
||||
};
|
||||
|
||||
const loadRange = (): UsageRange => {
|
||||
try {
|
||||
const saved = localStorage.getItem(RANGE_KEY) as UsageRange | null;
|
||||
return ['4h', '24h', 'today', '7d', '30d', 'all', 'custom'].includes(saved ?? '')
|
||||
? saved as UsageRange
|
||||
: '24h';
|
||||
} catch {
|
||||
return '24h';
|
||||
}
|
||||
};
|
||||
|
||||
const rangeQuery = (range: UsageRange, customStart: string, customEnd: string): Pick<UsageQuery, 'start' | 'end'> => {
|
||||
const now = new Date();
|
||||
if (range === 'all') return {};
|
||||
if (range === 'custom') {
|
||||
const start = customStart ? new Date(customStart) : null;
|
||||
const end = customEnd ? new Date(customEnd) : null;
|
||||
return {
|
||||
start: start && !Number.isNaN(start.getTime()) ? start.toISOString() : undefined,
|
||||
end: end && !Number.isNaN(end.getTime()) ? end.toISOString() : undefined,
|
||||
};
|
||||
}
|
||||
if (range === 'today') {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
return { start: start.toISOString(), end: now.toISOString() };
|
||||
}
|
||||
const hours = range === '4h' ? 4 : range === '24h' ? 24 : range === '7d' ? 24 * 7 : 24 * 30;
|
||||
return { start: new Date(now.getTime() - hours * 3_600_000).toISOString(), end: now.toISOString() };
|
||||
};
|
||||
|
||||
const compactNumber = (value: number) => new Intl.NumberFormat('zh-CN', {
|
||||
notation: value >= 10_000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(Number.isFinite(value) ? value : 0);
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const filterOptions = (items: UsageCategory[]) => items.filter((item) => item.key && item.label);
|
||||
|
||||
export function UsageRecordsPage() {
|
||||
const [activeTab, setActiveTab] = useState<UsageTab>(loadTab);
|
||||
const [range, setRange] = useState<UsageRange>(loadRange);
|
||||
const [customStart, setCustomStart] = useState('');
|
||||
const [customEnd, setCustomEnd] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [provider, setProvider] = useState('');
|
||||
const [source, setSource] = useState('');
|
||||
const [apiKeyHash, setApiKeyHash] = useState('');
|
||||
const [result, setResult] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<CollectorStatus | null>(null);
|
||||
const [overview, setOverview] = useState<UsageOverview | null>(null);
|
||||
const [analysis, setAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
|
||||
const [optionsAnalysis, setOptionsAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
|
||||
const [events, setEvents] = useState<UsageEventPage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(TAB_KEY, activeTab); } catch { /* Keep the in-memory tab. */ }
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(RANGE_KEY, range); } catch { /* Keep the in-memory range. */ }
|
||||
}, [range]);
|
||||
|
||||
const buildQueries = useCallback(() => {
|
||||
const nextTimeQuery = rangeQuery(range, customStart, customEnd);
|
||||
return {
|
||||
timeQuery: nextTimeQuery,
|
||||
query: {
|
||||
...nextTimeQuery,
|
||||
model: model || undefined,
|
||||
provider: provider || undefined,
|
||||
source: source || undefined,
|
||||
api_key_hash: apiKeyHash || undefined,
|
||||
failed: result === 'failed' ? true : result === 'success' ? false : undefined,
|
||||
} satisfies UsageQuery,
|
||||
};
|
||||
}, [apiKeyHash, customEnd, customStart, model, provider, range, result, source]);
|
||||
|
||||
const loadData = useCallback(async (quiet = false) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const { timeQuery, query } = buildQueries();
|
||||
if (!quiet) setLoading(true);
|
||||
try {
|
||||
const statusRequest = invoke<CollectorStatus>('get_usage_collector_status');
|
||||
const optionsRequest = invoke<UsageAnalysis>('get_usage_analysis', { query: timeQuery });
|
||||
if (activeTab === 'overview') {
|
||||
const [nextStatus, nextOptions, nextOverview] = await Promise.all([
|
||||
statusRequest,
|
||||
optionsRequest,
|
||||
invoke<UsageOverview>('get_usage_overview', { query }),
|
||||
]);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setOverview(nextOverview);
|
||||
} else if (activeTab === 'analysis') {
|
||||
const [nextStatus, nextOptions, nextOverview, nextAnalysis] = await Promise.all([
|
||||
statusRequest,
|
||||
optionsRequest,
|
||||
invoke<UsageOverview>('get_usage_overview', { query }),
|
||||
invoke<UsageAnalysis>('get_usage_analysis', { query }),
|
||||
]);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setOverview(nextOverview);
|
||||
setAnalysis(nextAnalysis);
|
||||
} else {
|
||||
const [nextStatus, nextOptions, nextEvents] = await Promise.all([
|
||||
statusRequest,
|
||||
optionsRequest,
|
||||
invoke<UsageEventPage>('get_usage_events', {
|
||||
query: { ...query, page, page_size: 50 },
|
||||
}),
|
||||
]);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setEvents(nextEvents);
|
||||
}
|
||||
setError('');
|
||||
} catch (requestError) {
|
||||
if (requestId === requestIdRef.current) setError(String(requestError));
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setLoading(false);
|
||||
}
|
||||
}, [activeTab, buildQueries, page]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
const refresh = () => {
|
||||
if (!disposed && !document.hidden) void loadData(true);
|
||||
};
|
||||
listen('usage-records-updated', refresh).then((stop) => {
|
||||
if (disposed) stop(); else unlisten = stop;
|
||||
}).catch(() => {});
|
||||
const timer = window.setInterval(refresh, 5_000);
|
||||
const refreshWhenVisible = () => {
|
||||
if (!document.hidden) refresh();
|
||||
};
|
||||
window.addEventListener('focus', refresh);
|
||||
document.addEventListener('visibilitychange', refreshWhenVisible);
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener('focus', refresh);
|
||||
document.removeEventListener('visibilitychange', refreshWhenVisible);
|
||||
};
|
||||
}, [loadData]);
|
||||
|
||||
const changeFilter = (setter: (value: string) => void, value: string) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const collectorTone = status?.state === 'error' ? 'error' : status?.state === 'collecting' ? 'success' : '';
|
||||
const showInitialLoading = loading && (
|
||||
(activeTab === 'overview' && !overview)
|
||||
|| (activeTab === 'analysis' && !overview)
|
||||
|| (activeTab === 'events' && !events)
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page management-page usage-records-page">
|
||||
<header className="management-header usage-records-header">
|
||||
<div>
|
||||
<span>Local Usage</span>
|
||||
<h1>使用记录</h1>
|
||||
</div>
|
||||
<div className={`usage-collector-state ${collectorTone}`} title={status?.message}>
|
||||
<span className="status-dot" />
|
||||
<div>
|
||||
<strong>{status?.state === 'collecting' ? '本地采集中' : status?.state === 'error' ? '采集异常' : '等待内核'}</strong>
|
||||
<span>{compactNumber(status?.totalRecords ?? 0)} 条长期记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
|
||||
<div className="usage-tabs" role="tablist" aria-label="使用记录页面">
|
||||
<button type="button" className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}><Activity size={15} />总览</button>
|
||||
<button type="button" className={activeTab === 'analysis' ? 'active' : ''} onClick={() => setActiveTab('analysis')}><BarChart3 size={15} />分析</button>
|
||||
<button type="button" className={activeTab === 'events' ? 'active' : ''} onClick={() => setActiveTab('events')}><List size={15} />请求明细</button>
|
||||
</div>
|
||||
|
||||
<section className="panel usage-filter-panel">
|
||||
<select value={range} onChange={(event) => { setRange(event.currentTarget.value as UsageRange); setPage(1); }} aria-label="时间范围">
|
||||
<option value="4h">最近 4 小时</option><option value="24h">最近 24 小时</option><option value="today">今天</option><option value="7d">最近 7 天</option><option value="30d">最近 30 天</option><option value="all">全部时间</option><option value="custom">自定义</option>
|
||||
</select>
|
||||
<select value={model} onChange={(event) => changeFilter(setModel, event.currentTarget.value)} aria-label="模型"><option value="">全部模型</option>{filterOptions(optionsAnalysis.models).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={provider} onChange={(event) => changeFilter(setProvider, event.currentTarget.value)} aria-label="Provider"><option value="">全部 Provider</option>{filterOptions(optionsAnalysis.providers).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={source} onChange={(event) => changeFilter(setSource, event.currentTarget.value)} aria-label="来源"><option value="">全部来源</option>{filterOptions(optionsAnalysis.sources).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={apiKeyHash} onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)} aria-label="API Key"><option value="">全部密钥</option>{filterOptions(optionsAnalysis.apiKeys).map((item) => <option value={item.key} key={item.key}>{item.label}</option>)}</select>
|
||||
<select value={result} onChange={(event) => changeFilter(setResult, event.currentTarget.value)} aria-label="请求结果"><option value="all">全部结果</option><option value="success">成功</option><option value="failed">失败</option></select>
|
||||
{range === 'custom' ? <div className="usage-custom-range"><input type="datetime-local" value={customStart} onChange={(event) => setCustomStart(event.currentTarget.value)} aria-label="开始时间" /><span>至</span><input type="datetime-local" value={customEnd} onChange={(event) => setCustomEnd(event.currentTarget.value)} aria-label="结束时间" /></div> : null}
|
||||
</section>
|
||||
|
||||
{showInitialLoading ? <div className="usage-initial-loading"><Database size={22} /><span>正在读取本地 SQLite 使用记录</span></div> : null}
|
||||
|
||||
{activeTab === 'overview' && overview ? <OverviewView overview={overview} /> : null}
|
||||
{activeTab === 'analysis' ? <AnalysisView analysis={analysis} overview={overview} /> : null}
|
||||
{activeTab === 'events' && events ? <EventsView events={events} onPage={setPage} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewView({ overview }: { overview: UsageOverview }) {
|
||||
const cards = [
|
||||
{ icon: Activity, label: '请求总数', value: compactNumber(overview.totalRequests), meta: `${overview.successCount} 成功 · ${overview.failureCount} 失败` },
|
||||
{ icon: Sparkles, label: 'Token 总量', value: compactNumber(overview.totalTokens), meta: `输入 ${compactNumber(overview.inputTokens)} · 输出 ${compactNumber(overview.outputTokens)}` },
|
||||
{ icon: ShieldCheck, label: '成功率', value: `${overview.successRate.toFixed(1)}%`, meta: `思考 ${compactNumber(overview.reasoningTokens)} Token` },
|
||||
{ icon: Clock3, label: '平均延迟', value: `${Math.round(overview.averageLatencyMs)} ms`, meta: `${overview.rpm.toFixed(2)} RPM · ${compactNumber(overview.tpm)} TPM` },
|
||||
];
|
||||
return <div className="usage-overview-layout">
|
||||
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small>{meta}</small></article>)}</div>
|
||||
<section className="panel usage-trend-panel"><div className="usage-section-heading"><div><strong>请求与 Token 趋势</strong><span>按系统本地小时聚合</span></div></div>{overview.timeline.length ? <UsageTrend points={overview.timeline} /> : <UsageEmpty />}</section>
|
||||
<section className="panel usage-health-panel"><div className="usage-section-heading"><div><strong>Token 构成</strong><span>缓存、思考与生成消耗</span></div></div><div className="usage-token-breakdown"><TokenMetric label="输入" value={overview.inputTokens} total={overview.totalTokens} /><TokenMetric label="输出" value={overview.outputTokens} total={overview.totalTokens} /><TokenMetric label="思考" value={overview.reasoningTokens} total={overview.totalTokens} /><TokenMetric label="缓存读取" value={overview.cacheReadTokens} total={overview.totalTokens} /><TokenMetric label="缓存创建" value={overview.cacheCreationTokens} total={overview.totalTokens} /></div></section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function UsageTrend({ points }: { points: TimelinePoint[] }) {
|
||||
const recent = points.slice(-48);
|
||||
const max = Math.max(...recent.map((point) => point.requests), 1);
|
||||
const polyline = recent.map((point, index) => `${recent.length === 1 ? 50 : index * 100 / (recent.length - 1)},${28 - point.requests * 24 / max}`).join(' ');
|
||||
return <div className="usage-trend"><svg viewBox="0 0 100 32" preserveAspectRatio="none" aria-label="请求趋势"><polyline points={polyline} fill="none" vectorEffect="non-scaling-stroke" /></svg><div className="usage-trend-labels"><span>{recent[0]?.hour ?? ''}</span><strong>{compactNumber(recent.reduce((sum, point) => sum + point.tokens, 0))} Token</strong><span>{recent[recent.length - 1]?.hour ?? ''}</span></div></div>;
|
||||
}
|
||||
|
||||
function TokenMetric({ label, value, total }: { label: string; value: number; total: number }) {
|
||||
const percent = total ? Math.min(value * 100 / total, 100) : 0;
|
||||
return <div><span><strong>{label}</strong><small>{compactNumber(value)}</small></span><i><b style={{ width: `${percent}%` }} /></i></div>;
|
||||
}
|
||||
|
||||
function AnalysisView({ analysis, overview }: { analysis: UsageAnalysis; overview: UsageOverview | null }) {
|
||||
const hours = (overview?.timeline ?? []).map((point) => ({
|
||||
key: point.hour,
|
||||
label: point.hour,
|
||||
requests: point.requests,
|
||||
failures: point.failure,
|
||||
tokens: point.tokens,
|
||||
})).sort((left, right) => right.tokens - left.tokens);
|
||||
return <div className="usage-analysis-grid"><CategoryPanel title="模型使用" items={analysis.models} /><CategoryPanel title="Provider" items={analysis.providers} /><CategoryPanel title="请求来源" items={analysis.sources} /><CategoryPanel title="鉴权密钥" items={analysis.apiKeys} /><CategoryPanel title="时段分布" items={hours} /></div>;
|
||||
}
|
||||
|
||||
function CategoryPanel({ title, items }: { title: string; items: UsageCategory[] }) {
|
||||
const max = Math.max(...items.map((item) => item.tokens), 1);
|
||||
const total = items.reduce((sum, item) => sum + item.tokens, 0);
|
||||
return <section className="panel usage-category-panel"><div className="usage-section-heading"><div><strong>{title}</strong><span>按 Token 排序</span></div></div>{items.length ? <div className="usage-category-list">{items.slice(0, 10).map((item) => <div key={item.key}><span><strong title={item.label}>{item.label}</strong><small>{compactNumber(item.requests)} 次 · {total ? (item.tokens * 100 / total).toFixed(1) : '0.0'}% · {compactNumber(item.tokens)} Token</small></span><i><b style={{ width: `${item.tokens * 100 / max}%` }} /></i></div>)}</div> : <UsageEmpty />}</section>;
|
||||
}
|
||||
|
||||
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
|
||||
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>共 {compactNumber(events.total)} 条记录</span><span>第 {events.page} / {events.totalPages} 页</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>时间</th><th>模型</th><th>Provider</th><th>来源</th><th>API Key</th><th>结果</th><th>延迟</th><th>TTFT</th><th>输入</th><th>输出</th><th>思考</th><th>缓存</th><th>总计</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source}>{record.source || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || '未备注'}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? '失败' : '成功'}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>上一页</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>下一页</button></div></section>;
|
||||
}
|
||||
|
||||
function UsageEmpty() {
|
||||
return <div className="usage-empty"><TriangleAlert size={18} /><span>当前筛选范围没有使用记录</span></div>;
|
||||
}
|
||||
+478
-2
@@ -388,6 +388,32 @@ input:focus-visible {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.version-heading-inline {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.version-heading-inline h2 {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.version-offline-hint {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kernel-layout > .version-panel > .panel-heading {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.client-api-panel {
|
||||
margin-top: 18px;
|
||||
}
|
||||
@@ -610,7 +636,7 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.version-action-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.info-list {
|
||||
@@ -3790,6 +3816,413 @@ input:focus-visible {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.usage-records-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.usage-records-header {
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.usage-collector-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 164px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 8px;
|
||||
background: #fffdf8;
|
||||
}
|
||||
|
||||
.usage-collector-state .status-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: 0 0 0 4px rgba(155, 149, 140, 0.12);
|
||||
}
|
||||
|
||||
.usage-collector-state.success .status-dot {
|
||||
background: #2f6b3f;
|
||||
box-shadow: 0 0 0 4px rgba(47, 107, 63, 0.12);
|
||||
}
|
||||
|
||||
.usage-collector-state.error .status-dot {
|
||||
background: #9f2d20;
|
||||
box-shadow: 0 0 0 4px rgba(159, 45, 32, 0.12);
|
||||
}
|
||||
|
||||
.usage-collector-state > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.usage-collector-state strong {
|
||||
color: #2d2a26;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-collector-state span {
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.usage-tabs {
|
||||
display: inline-grid;
|
||||
grid-auto-flow: column;
|
||||
justify-self: start;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid #d5d2cb;
|
||||
border-radius: 8px;
|
||||
background: #eeece6;
|
||||
}
|
||||
|
||||
.usage-tabs button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 104px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #7a746c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.usage-tabs button.active {
|
||||
background: #fffdf8;
|
||||
color: #2d2a26;
|
||||
box-shadow: 0 1px 4px rgba(45, 42, 38, 0.14);
|
||||
}
|
||||
|
||||
.usage-filter-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.usage-filter-panel select,
|
||||
.usage-filter-panel input {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #d5d2cb;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #2d2a26;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-custom-range {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(170px, 1fr) auto minmax(170px, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #8a847c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-initial-loading,
|
||||
.usage-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 120px;
|
||||
color: #8a847c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-overview-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.6fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-stat-grid {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.usage-stat-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.usage-stat-card > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #7a746c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.usage-stat-card > strong {
|
||||
overflow: hidden;
|
||||
color: #2d2a26;
|
||||
font-size: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-stat-card > small {
|
||||
overflow: hidden;
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-trend-panel,
|
||||
.usage-health-panel,
|
||||
.usage-category-panel,
|
||||
.usage-events-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.usage-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 36px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.usage-section-heading > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.usage-section-heading strong {
|
||||
color: #2d2a26;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.usage-section-heading span {
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.usage-trend {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-trend svg {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
overflow: visible;
|
||||
border-bottom: 1px solid #e3e1db;
|
||||
background: repeating-linear-gradient(to bottom, transparent 0, transparent 43px, #eeeae2 44px);
|
||||
}
|
||||
|
||||
.usage-trend polyline {
|
||||
stroke: #3f6f98;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.usage-trend-labels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #8a847c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.usage-trend-labels strong {
|
||||
color: #5f5a53;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.usage-token-breakdown,
|
||||
.usage-category-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-token-breakdown > div,
|
||||
.usage-category-list > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-token-breakdown span,
|
||||
.usage-category-list span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-token-breakdown strong,
|
||||
.usage-category-list strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #5f5a53;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-token-breakdown small,
|
||||
.usage-category-list small {
|
||||
flex: none;
|
||||
color: #8a847c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.usage-token-breakdown i,
|
||||
.usage-category-list i {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: #eeece6;
|
||||
}
|
||||
|
||||
.usage-token-breakdown b,
|
||||
.usage-category-list b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #6684a1;
|
||||
}
|
||||
|
||||
.usage-analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-category-panel {
|
||||
min-height: 290px;
|
||||
}
|
||||
|
||||
.usage-events-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.usage-events-summary,
|
||||
.usage-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #8a847c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.usage-table-wrap {
|
||||
overflow: auto;
|
||||
min-height: 360px;
|
||||
border: 1px solid #e3e1db;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.usage-events-table {
|
||||
width: 100%;
|
||||
min-width: 1320px;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.usage-events-table th,
|
||||
.usage-events-table td {
|
||||
height: 42px;
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid #eeeae2;
|
||||
color: #5f5a53;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-events-table th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
background: #f4f1ea;
|
||||
color: #7a746c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.usage-events-table th:first-child,
|
||||
.usage-events-table th:nth-child(2),
|
||||
.usage-events-table th:nth-child(3),
|
||||
.usage-events-table th:nth-child(4),
|
||||
.usage-events-table th:nth-child(5),
|
||||
.usage-events-table td:first-child,
|
||||
.usage-events-table td:nth-child(2),
|
||||
.usage-events-table td:nth-child(3),
|
||||
.usage-events-table td:nth-child(4),
|
||||
.usage-events-table td:nth-child(5) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.usage-events-table .usage-stacked-cell {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.usage-events-table td strong,
|
||||
.usage-events-table td small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.usage-events-table td small {
|
||||
color: #8a847c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.usage-result {
|
||||
display: inline-flex;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.usage-result.success {
|
||||
background: #eef7f0;
|
||||
color: #2f6b3f;
|
||||
}
|
||||
|
||||
.usage-result.failed {
|
||||
background: #fff1ee;
|
||||
color: #9f2d20;
|
||||
}
|
||||
|
||||
.usage-pagination {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.usage-pagination button {
|
||||
min-width: 78px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.real-auth-files-panel {
|
||||
display: block;
|
||||
height: auto;
|
||||
@@ -4388,6 +4821,14 @@ input:focus-visible {
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.usage-filter-panel {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.usage-stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.config-side-stack {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: 206px;
|
||||
@@ -4503,6 +4944,15 @@ input:focus-visible {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-overview-layout,
|
||||
.usage-analysis-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-filter-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.thinking-alias-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -4614,7 +5064,14 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.kernel-layout .version-action-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.kernel-layout > .version-panel {
|
||||
grid-template-rows: auto 232px auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.management-header {
|
||||
@@ -4622,6 +5079,25 @@ input:focus-visible {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-tabs {
|
||||
width: 100%;
|
||||
grid-auto-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-tabs button {
|
||||
min-width: 0;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.usage-filter-panel,
|
||||
.usage-stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-custom-range {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-category-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user