feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,166 @@
import { Alert, Checkbox, Modal, Space, Table, Typography, theme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type {
CodexSessionVisibilityStatus,
CodexSessionVisibilityStatusRow,
} from '@/types';
const { Text } = Typography;
type StatusRow = CodexSessionVisibilityStatusRow & {
key: string;
};
interface CodexSessionVisibilityModalProps {
open: boolean;
loading: boolean;
repairing: boolean;
status: CodexSessionVisibilityStatus | null;
createBackup: boolean;
onCreateBackupChange: (checked: boolean) => void;
onCancel: () => void;
onRepair: () => void;
}
export function CodexSessionVisibilityModal({
open,
loading,
repairing,
status,
createBackup,
onCreateBackupChange,
onCancel,
onRepair,
}: CodexSessionVisibilityModalProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const rows = useMemo<StatusRow[]>(
() => status?.statusRows.map((row, index) => ({
...row,
key: `${row.scope}-${row.provider ?? 'workspace'}-${index}`,
})) ?? [],
[status],
);
const columns = useMemo<ColumnsType<StatusRow>>(
() => [
{
title: t('gateway.codexRepairVisibilityTableScope'),
dataIndex: 'scope',
key: 'scope',
},
{
title: t('gateway.codexRepairVisibilityTableProvider'),
dataIndex: 'provider',
key: 'provider',
render: (value: string | null) => value || '-',
},
{
title: t('gateway.codexRepairVisibilityTableCount'),
dataIndex: 'count',
key: 'count',
width: 96,
},
{
title: t('gateway.codexRepairVisibilityTableMismatch'),
dataIndex: 'mismatchedCount',
key: 'mismatchedCount',
width: 112,
},
{
title: t('gateway.codexRepairVisibilityTableStatus'),
dataIndex: 'status',
key: 'status',
width: 132,
render: (_: string, row) => (
<span style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 4,
padding: '2px 8px',
border: `1px solid ${row.mismatchedCount > 0 ? token.colorWarningBorder : token.colorSuccessBorder}`,
background: row.mismatchedCount > 0 ? token.colorWarningBg : token.colorSuccessBg,
color: row.mismatchedCount > 0 ? token.colorWarningText : token.colorSuccessText,
}}>
{row.mismatchedCount > 0
? t('gateway.codexRepairVisibilityStatusNeedsRepair')
: t('gateway.codexRepairVisibilityStatusOk')}
</span>
),
},
],
[t, token],
);
return (
<Modal
title={t('gateway.codexRepairVisibilityModalTitle')}
aria-label={t('gateway.codexRepairVisibilityModalTitle')}
open={open}
width={760}
onCancel={onCancel}
onOk={onRepair}
okText={t('gateway.codexRepairVisibilityConfirmOk')}
cancelText={t('gateway.codexRepairVisibilityConfirmCancel')}
confirmLoading={repairing}
okButtonProps={{ disabled: loading || !status }}
>
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<div style={{
border: `1px solid ${token.colorBorderSecondary}`,
background: token.colorFillTertiary,
borderRadius: 6,
padding: 12,
}}>
<Text strong>{t('gateway.codexRepairVisibilityCurrentStatus')}</Text>
<div style={{ display: 'grid', gap: 4, marginTop: 8 }}>
<Text type="secondary">
{t('gateway.codexRepairVisibilityTargetProvider', {
provider: status?.targetProvider ?? '-',
})}
</Text>
<Text type="secondary">{status?.codexHome ?? '-'}</Text>
<Text>
{t('gateway.codexRepairVisibilityMismatchSummary', {
sessions: status?.mismatchedSessionFiles ?? 0,
total: status?.totalSessionFiles ?? 0,
sqlite: status?.sqliteMismatchedRows ?? 0,
workspace: status?.workspaceRootsNeedingUpdate ?? 0,
})}
</Text>
</div>
</div>
<Table<StatusRow>
size="small"
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
locale={{ emptyText: t('gateway.codexRepairVisibilityNoStatusRows') }}
/>
<Alert
type="warning"
showIcon
title={t('gateway.codexRepairVisibilityRiskTitle')}
description={t('gateway.codexRepairVisibilityRiskContent')}
/>
{status?.encryptedContentWarning && (
<Alert type="warning" showIcon description={status.encryptedContentWarning} />
)}
<Checkbox
checked={createBackup}
onChange={(event) => onCreateBackupChange(event.target.checked)}
>
{t('gateway.codexRepairVisibilityCreateBackup')}
</Checkbox>
</Space>
</Modal>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, Button, Tag, Empty, Popconfirm, Modal } from 'antd';
import { RefreshCw, Trash2 } from 'lucide-react';
import { useGatewayStore } from '@/stores/gatewayStore';
import type { GatewayRequestLog } from '@/types';
import type { ColumnsType } from 'antd/es/table';
import { formatTokenCount } from './tokenFormat';
export function GatewayDiagnostics() {
const { t } = useTranslation();
const { requestLogs, requestLogsLoading, fetchRequestLogs, clearRequestLogs } = useGatewayStore();
const [selectedErrorMessage, setSelectedErrorMessage] = useState<string | null>(null);
useEffect(() => {
void fetchRequestLogs();
}, [fetchRequestLogs]);
const columns: ColumnsType<GatewayRequestLog> = [
{
title: t('gateway.logTime'),
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (ts: number) => {
const d = new Date(ts * 1000);
return d.toLocaleString();
},
},
{
title: t('gateway.logMethod'),
dataIndex: 'method',
key: 'method',
width: 80,
render: (method: string) => (
<Tag color="blue">{method}</Tag>
),
},
{
title: t('gateway.logPath'),
dataIndex: 'path',
key: 'path',
width: 200,
ellipsis: true,
},
{
title: t('gateway.logModel'),
dataIndex: 'model',
key: 'model',
width: 160,
ellipsis: true,
render: (model: string | null) => model || '-',
},
{
title: t('gateway.logStatus'),
dataIndex: 'statusCode',
key: 'statusCode',
width: 80,
render: (code: number) => (
<Tag color={code >= 200 && code < 300 ? 'green' : code >= 400 ? 'red' : 'orange'}>
{code}
</Tag>
),
},
{
title: t('gateway.logDuration'),
dataIndex: 'durationMs',
key: 'durationMs',
width: 100,
render: (ms: number) => {
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${ms}ms`;
},
},
{
title: t('gateway.logRequestTokens'),
dataIndex: 'requestTokens',
key: 'requestTokens',
width: 140,
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.logResponseTokens'),
dataIndex: 'responseTokens',
key: 'responseTokens',
width: 140,
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.totalTokens'),
key: 'totalTokens',
width: 120,
render: (_: unknown, record: GatewayRequestLog) => {
const total = record.requestTokens + record.responseTokens;
return total === 0 ? '-' : formatTokenCount(total);
},
},
{
title: t('gateway.logKey'),
dataIndex: 'keyName',
key: 'keyName',
width: 120,
ellipsis: true,
},
{
title: t('gateway.logError'),
dataIndex: 'errorMessage',
key: 'errorMessage',
ellipsis: true,
render: (msg: string | null) =>
msg ? (
<button
type="button"
onClick={() => setSelectedErrorMessage(msg)}
style={{
border: 'none',
padding: 0,
background: 'transparent',
color: '#ff4d4f',
width: '100%',
textAlign: 'left',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{msg}
</button>
) : null,
},
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<Popconfirm
title={t('gateway.clearLogsConfirm')}
onConfirm={clearRequestLogs}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
>
<Button
icon={<Trash2 size={14} />}
danger
disabled={requestLogs.length === 0}
>
{t('gateway.clearLogs')}
</Button>
</Popconfirm>
<Button
icon={<RefreshCw size={14} />}
onClick={() => void fetchRequestLogs()}
>
{t('common.refresh')}
</Button>
</div>
{requestLogs.length === 0 && !requestLogsLoading ? (
<Empty
description={t('gateway.noLogs')}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<Table
dataSource={requestLogs}
columns={columns}
rowKey="id"
loading={requestLogsLoading}
pagination={{
pageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total} ${t('gateway.logTotalRecords')}`,
}}
size="small"
scroll={{ x: 1200 }}
/>
)}
<Modal
open={selectedErrorMessage != null}
title={t('gateway.logError')}
onCancel={() => setSelectedErrorMessage(null)}
footer={null}
>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: '#ff4d4f' }}>
{selectedErrorMessage}
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,223 @@
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Table,
Button,
Switch,
Modal,
Input,
message,
Popconfirm,
Typography,
Alert,
theme,
} from 'antd';
import { Plus, Trash2, Copy, Check, Search } from 'lucide-react';
import { useGatewayStore } from '@/stores/gatewayStore';
import { CopyButton } from '@/components/common/CopyButton';
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import type { GatewayKey } from '@/types';
const { Text } = Typography;
export function GatewayKeys() {
const { t } = useTranslation();
const { token } = theme.useToken();
const { keys, loading, fetchKeys, createKey, deleteKey, toggleKey, decryptKey } =
useGatewayStore();
const { copy: copyCreatedKey, isCopied: createdKeyCopied } = useCopyToClipboard();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [keyName, setKeyName] = useState('');
const [createdKey, setCreatedKey] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [searchText, setSearchText] = useState('');
useEffect(() => {
fetchKeys();
}, [fetchKeys]);
const filteredKeys = useMemo(() => {
if (!searchText.trim()) return keys;
const lower = searchText.toLowerCase();
return keys.filter(
(k) =>
k.name.toLowerCase().includes(lower) ||
k.key_prefix.toLowerCase().includes(lower)
);
}, [keys, searchText]);
const handleCreate = async () => {
if (!keyName.trim()) return;
setCreating(true);
try {
const result = await createKey(keyName.trim());
setCreatedKey(result.plain_key);
setKeyName('');
} catch (e) {
message.error(String(e));
} finally {
setCreating(false);
}
};
const handleCloseModal = () => {
setCreateModalOpen(false);
setCreatedKey(null);
setKeyName('');
};
const columns = [
{
title: t('gateway.keyName'),
dataIndex: 'name',
key: 'name',
},
{
title: t('settings.keyPrefix'),
dataIndex: 'key_prefix',
key: 'key_prefix',
render: (v: string) => <Text code>{v}</Text>,
},
{
title: t('common.enabled'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean, record: GatewayKey) => (
<Switch
checked={enabled}
onChange={(checked) => toggleKey(record.id, checked)}
size="small"
/>
),
},
{
title: t('gateway.created'),
dataIndex: 'created_at',
key: 'created_at',
render: (ts: number) => new Date(ts * 1000).toLocaleDateString(),
},
{
title: t('gateway.lastUsed'),
dataIndex: 'last_used_at',
key: 'last_used_at',
render: (ts: number | null) =>
ts ? new Date(ts * 1000).toLocaleDateString() : '-',
},
{
title: '',
key: 'actions',
width: 100,
render: (_: unknown, record: GatewayKey) => (
<div style={{ display: 'flex', gap: 4 }}>
{record.has_encrypted_key && (
<CopyButton
text={async () => decryptKey(record.id)}
size={14}
successMessage={t('common.copySuccess')}
onError={(e) => message.error(String(e))}
/>
)}
<Popconfirm
title={t('gateway.deleteKeyConfirm')}
onConfirm={() => deleteKey(record.id)}
>
<Button type="text" danger icon={<Trash2 size={14} />} size="small" />
</Popconfirm>
</div>
),
},
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<Button
type="primary"
icon={<Plus size={16} />}
onClick={() => setCreateModalOpen(true)}
>
{t('gateway.createKey')}
</Button>
<Input
placeholder={t('gateway.searchKeys')}
prefix={<Search size={14} style={{ opacity: 0.45 }} />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ maxWidth: 280 }}
/>
</div>
<Table
dataSource={filteredKeys}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
<Modal
title={createdKey ? t('gateway.keyCreated') : t('gateway.createKey')}
open={createModalOpen}
onCancel={handleCloseModal}
footer={
createdKey
? [
<Button key="copy" icon={createdKeyCopied ? <Check size={16} style={{ color: token.colorSuccess }} /> : <Copy size={16} />} onClick={async () => {
if (createdKey) {
const ok = await copyCreatedKey(createdKey);
if (ok) message.success(t('common.copySuccess'));
}
}}>
{t('gateway.copyKey')}
</Button>,
<Button key="close" type="primary" onClick={handleCloseModal}>
{t('common.confirm')}
</Button>,
]
: [
<Button key="cancel" onClick={handleCloseModal}>
{t('common.cancel')}
</Button>,
<Button
key="create"
type="primary"
onClick={handleCreate}
loading={creating}
disabled={!keyName.trim()}
>
{t('common.create')}
</Button>,
]
}
>
{createdKey ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Alert
message={t('gateway.copyWarning')}
type="warning"
showIcon
/>
<Input.TextArea
value={createdKey}
readOnly
autoSize={{ minRows: 2 }}
className="font-mono"
/>
</div>
) : (
<Input
placeholder={t('gateway.keyName')}
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
onPressEnter={handleCreate}
autoFocus
/>
)}
</Modal>
</div>
);
}
@@ -0,0 +1,138 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, Card } from 'antd';
import { useGatewayStore } from '@/stores/gatewayStore';
import { formatTokenCount } from './tokenFormat';
export function GatewayMetrics() {
const { t } = useTranslation();
const {
usageByDay,
usageByProvider,
usageByKey,
fetchUsageByDay,
fetchUsageByProvider,
fetchUsageByKey,
} = useGatewayStore();
useEffect(() => {
fetchUsageByDay(30);
fetchUsageByProvider();
fetchUsageByKey();
}, [fetchUsageByDay, fetchUsageByProvider, fetchUsageByKey]);
const dayColumns = [
{ title: t('gateway.date'), dataIndex: 'date', key: 'date' },
{
title: t('gateway.totalRequests'),
dataIndex: 'request_count',
key: 'request_count',
},
{
title: t('gateway.logRequestTokens'),
dataIndex: 'request_tokens',
key: 'request_tokens',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.logResponseTokens'),
dataIndex: 'response_tokens',
key: 'response_tokens',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.totalTokens'),
dataIndex: 'token_count',
key: 'token_count',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
];
const providerColumns = [
{ title: t('gateway.provider'), dataIndex: 'provider_name', key: 'provider_name' },
{
title: t('gateway.totalRequests'),
dataIndex: 'request_count',
key: 'request_count',
},
{
title: t('gateway.logRequestTokens'),
dataIndex: 'request_tokens',
key: 'request_tokens',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.logResponseTokens'),
dataIndex: 'response_tokens',
key: 'response_tokens',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.totalTokens'),
dataIndex: 'token_count',
key: 'token_count',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
];
const keyColumns = [
{ title: t('gateway.keyName'), dataIndex: 'key_name', key: 'key_name' },
{
title: t('gateway.totalRequests'),
dataIndex: 'request_count',
key: 'request_count',
},
{
title: t('gateway.logRequestTokens'),
dataIndex: 'request_tokens',
key: 'request_tokens',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.logResponseTokens'),
dataIndex: 'response_tokens',
key: 'response_tokens',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.totalTokens'),
dataIndex: 'token_count',
key: 'token_count',
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card size="small" title={t('gateway.usageByDay')}>
<Table
dataSource={usageByDay}
columns={dayColumns}
rowKey="date"
pagination={false}
size="small"
/>
</Card>
<Card size="small" title={t('gateway.usageByProvider')}>
<Table
dataSource={usageByProvider}
columns={providerColumns}
rowKey="provider_id"
pagination={false}
size="small"
/>
</Card>
<Card size="small" title={t('gateway.usageByKey')}>
<Table
dataSource={usageByKey}
columns={keyColumns}
rowKey="key_id"
pagination={false}
size="small"
/>
</Card>
</div>
);
}
@@ -0,0 +1,381 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Tag, Statistic, Row, Col, Empty, Table, theme, message } from 'antd';
import { PlayCircle, Power, Router, ArrowDown, ArrowUp, RefreshCw } from 'lucide-react';
import { useGatewayStore } from '@/stores/gatewayStore';
import type { GatewayRequestLog } from '@/types';
import type { ColumnsType } from 'antd/es/table';
import { formatTokenCount } from './tokenFormat';
import { CopyButton } from '@/components/common/CopyButton';
interface GatewayOverviewProps {
onViewMoreLogs?: () => void;
}
export function GatewayOverview({ onViewMoreLogs }: GatewayOverviewProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const {
status,
metrics,
startGateway,
stopGateway,
fetchStatus,
fetchMetrics,
listRequestLogs,
} = useGatewayStore();
const [recentLogs, setRecentLogs] = useState<GatewayRequestLog[]>([]);
const [recentLogsLoading, setRecentLogsLoading] = useState(false);
const loadRecentLogs = useCallback(async () => {
setRecentLogsLoading(true);
try {
const logs = await listRequestLogs(10);
setRecentLogs(logs.slice(0, 10));
} finally {
setRecentLogsLoading(false);
}
}, [listRequestLogs]);
useEffect(() => {
fetchStatus();
fetchMetrics();
void loadRecentLogs();
}, [fetchStatus, fetchMetrics, loadRecentLogs]);
useEffect(() => {
const interval = setInterval(() => {
fetchStatus();
fetchMetrics();
}, 5000);
return () => clearInterval(interval);
}, [fetchStatus, fetchMetrics]);
useEffect(() => {
if (!status.is_running) {
return;
}
const interval = setInterval(() => {
void loadRecentLogs();
}, 5000);
return () => clearInterval(interval);
}, [loadRecentLogs, status.is_running]);
const gatewayHost =
status.listen_address === '0.0.0.0'
? '127.0.0.1'
: status.listen_address === '::' || status.listen_address === '[::]'
? 'localhost'
: status.listen_address;
const httpUrl = `http://${gatewayHost}:${status.port}/v1`;
const httpsUrl = status.https_port != null
? `https://${gatewayHost}:${status.https_port}/v1`
: null;
const recentLogColumns: ColumnsType<GatewayRequestLog> = [
{
title: t('gateway.logTime'),
dataIndex: 'createdAt',
key: 'createdAt',
width: 170,
render: (ts: number) => new Date(ts * 1000).toLocaleString(),
},
{
title: t('gateway.logMethod'),
dataIndex: 'method',
key: 'method',
width: 90,
render: (method: string) => <Tag color="blue">{method}</Tag>,
},
{
title: t('gateway.logPath'),
dataIndex: 'path',
key: 'path',
ellipsis: true,
},
{
title: t('gateway.logStatus'),
dataIndex: 'statusCode',
key: 'statusCode',
width: 90,
render: (code: number) => (
<Tag color={code >= 200 && code < 300 ? 'green' : code >= 400 ? 'red' : 'orange'}>
{code}
</Tag>
),
},
{
title: t('gateway.logDuration'),
dataIndex: 'durationMs',
key: 'durationMs',
width: 110,
render: (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`),
},
{
title: t('gateway.logRequestTokens'),
dataIndex: 'requestTokens',
key: 'requestTokens',
width: 140,
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.logResponseTokens'),
dataIndex: 'responseTokens',
key: 'responseTokens',
width: 140,
render: (value: number) => (value === 0 ? '-' : formatTokenCount(value)),
},
{
title: t('gateway.totalTokens'),
key: 'totalTokens',
width: 120,
render: (_: unknown, record: GatewayRequestLog) => {
const total = record.requestTokens + record.responseTokens;
return total === 0 ? '-' : formatTokenCount(total);
},
},
];
const metricCardContentStyle = {
minHeight: 82,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
} as const;
const tokenValueRowStyle = {
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
} as const;
const handleToggle = async () => {
try {
if (status.is_running) {
await stopGateway();
} else {
await startGateway();
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
message.error(`${t('error.gatewayStartFailed')}: ${detail}`);
}
};
const handleOpenUrl = async (url: string) => {
try {
const { openUrl } = await import('@tauri-apps/plugin-opener');
await openUrl(url);
} catch {
// fallback for browser dev mode
window.open(url, '_blank', 'noopener,noreferrer');
}
};
const handleViewMoreLogs = useCallback(() => {
onViewMoreLogs?.();
}, [onViewMoreLogs]);
const handleRefreshRecentLogs = useCallback(() => {
void loadRecentLogs();
}, [loadRecentLogs]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Server Status */}
<Card size="small">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Router size={24} />
<div>
<Tag color={status.is_running ? 'green' : 'default'}>
{status.is_running ? t('gateway.running') : t('gateway.stopped')}
</Tag>
{status.is_running && (
<div className="text-xs flex flex-col gap-0.5 mt-1">
<div className="flex items-center gap-1">
<a
href="#"
onClick={(e) => { e.preventDefault(); handleOpenUrl(httpUrl); }}
style={{
fontFamily: 'monospace',
color: token.colorPrimary,
textDecoration: 'underline dashed',
textUnderlineOffset: 3,
userSelect: 'all',
}}
>
{httpUrl}
</a>
<CopyButton
text={httpUrl}
size={12}
successMessage={t('gateway.copySuccess')}
/>
</div>
{status.ssl_enabled && httpsUrl && (
<div className="flex items-center gap-1">
<a
href="#"
onClick={(e) => { e.preventDefault(); handleOpenUrl(httpsUrl); }}
style={{
fontFamily: 'monospace',
color: token.colorPrimary,
textDecoration: 'underline dashed',
textUnderlineOffset: 3,
userSelect: 'all',
}}
>
{httpsUrl}
</a>
<CopyButton
text={httpsUrl}
size={12}
successMessage={t('gateway.copySuccess')}
/>
</div>
)}
{status.ssl_enabled && status.force_ssl && (
<span style={{ color: token.colorWarning, fontSize: 11 }}>
{t('gateway.forceSslNotice')}
</span>
)}
</div>
)}
</div>
</div>
<Button
type={status.is_running ? 'default' : 'primary'}
danger={status.is_running}
icon={status.is_running ? <Power size={16} /> : <PlayCircle size={16} />}
onClick={handleToggle}
>
{status.is_running ? t('gateway.stop') : t('gateway.start')}
</Button>
</div>
</Card>
{/* Quick Metrics */}
<Row gutter={16}>
<Col span={6}>
<Card size="small">
<div style={metricCardContentStyle}>
<Statistic title={t('gateway.todayRequests')} value={metrics?.today_requests ?? 0} />
</div>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<div style={metricCardContentStyle}>
<div
style={{
color: token.colorTextDescription,
fontSize: token.fontSizeLG,
marginBottom: 8,
}}
>
{t('gateway.todayTokens')}
</div>
<div style={tokenValueRowStyle}>
<div
style={{
fontSize: 36,
lineHeight: 1.1,
fontWeight: 500,
fontVariantNumeric: 'tabular-nums',
}}
>
{formatTokenCount(metrics?.today_tokens ?? 0)}
</div>
<Tag icon={<ArrowUp size={12} />} color="blue">
{formatTokenCount(metrics?.today_request_tokens ?? 0)}
</Tag>
<Tag icon={<ArrowDown size={12} />} color="purple">
{formatTokenCount(metrics?.today_response_tokens ?? 0)}
</Tag>
</div>
</div>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<div style={metricCardContentStyle}>
<Statistic title={t('gateway.totalRequests')} value={metrics?.total_requests ?? 0} />
</div>
</Card>
</Col>
<Col span={6}>
<Card size="small">
<div style={metricCardContentStyle}>
<div
style={{
color: token.colorTextDescription,
fontSize: token.fontSizeLG,
marginBottom: 8,
}}
>
{t('gateway.totalTokens')}
</div>
<div style={tokenValueRowStyle}>
<div
style={{
fontSize: 36,
lineHeight: 1.1,
fontWeight: 500,
fontVariantNumeric: 'tabular-nums',
}}
>
{formatTokenCount(metrics?.total_tokens ?? 0)}
</div>
<Tag icon={<ArrowUp size={12} />} color="blue">
{formatTokenCount(metrics?.total_request_tokens ?? 0)}
</Tag>
<Tag icon={<ArrowDown size={12} />} color="purple">
{formatTokenCount(metrics?.total_response_tokens ?? 0)}
</Tag>
</div>
</div>
</Card>
</Col>
</Row>
{/* Recent Request Logs */}
<Card
size="small"
title={t('gateway.recentRequestLogs')}
extra={(
<Button
size="small"
icon={<RefreshCw size={14} />}
onClick={handleRefreshRecentLogs}
>
{t('common.refresh')}
</Button>
)}
>
{recentLogs.length === 0 && !recentLogsLoading ? (
<Empty description={t('gateway.noLogs')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Table
dataSource={recentLogs}
columns={recentLogColumns}
rowKey="id"
loading={recentLogsLoading}
pagination={false}
size="small"
scroll={{ x: 1080 }}
/>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<Button type="link" onClick={handleViewMoreLogs}>
{t('gateway.viewMoreLogs')}
</Button>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,361 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Divider, Switch, Input, InputNumber, Alert, Button, Radio, message, Tooltip, theme } from 'antd';
import { Info, Upload as UploadIcon, ShieldAlert } from 'lucide-react';
import { useGatewayStore } from '@/stores/gatewayStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { invoke } from '@/lib/invoke';
interface CertResult {
cert_path: string;
key_path: string;
}
export function GatewaySettings() {
const { t } = useTranslation();
const { token } = theme.useToken();
const { settings, fetchSettings, saveSettings } = useSettingsStore();
const { status, fetchStatus, stopGateway } = useGatewayStore();
useEffect(() => {
fetchSettings();
fetchStatus();
const interval = window.setInterval(() => {
fetchStatus();
}, 5000);
return () => window.clearInterval(interval);
}, [fetchSettings, fetchStatus]);
const settingsLocked = status.is_running;
const handleSave = async (partial: Parameters<typeof saveSettings>[0]) => {
try {
await saveSettings(partial);
} catch (e) {
message.error(String(e));
}
};
const handleSelectFile = async (field: 'gateway_ssl_cert_path' | 'gateway_ssl_key_path') => {
if (settingsLocked) return;
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({
multiple: false,
filters: field === 'gateway_ssl_cert_path'
? [{ name: 'Certificate', extensions: ['pem', 'crt', 'cer'] }]
: [{ name: 'Private Key', extensions: ['pem', 'key'] }],
});
if (selected) {
await handleSave({ [field]: selected });
}
} catch (e) {
message.error(String(e));
}
};
const handleGenerateSelfSigned = async () => {
if (settingsLocked) return;
try {
const result = await invoke<CertResult>('generate_self_signed_cert');
await handleSave({
gateway_ssl_cert_path: result.cert_path,
gateway_ssl_key_path: result.key_path,
});
message.success(t('gateway.sslGenerateSuccess'));
} catch (e) {
message.error(t('gateway.sslGenerateFailed') + ': ' + String(e));
}
};
const [sslPortValue, setSslPortValue] = useState<number>(settings.gateway_ssl_port ?? 8443);
const [sslPortError, setSslPortError] = useState(false);
const [portValue, setPortValue] = useState<number>(settings.gateway_port ?? 8080);
const [portError, setPortError] = useState(false);
const [listenAddressValue, setListenAddressValue] = useState<string>(settings.gateway_listen_address ?? '127.0.0.1');
// Track port values that were blocked from saving due to a conflict so they
// can be persisted as soon as the conflict is resolved.
const portPendingSave = useRef(false);
const sslPortPendingSave = useRef(false);
// Each field syncs independently so an unsaved local edit in one field is never
// clobbered when the other field's persisted value changes.
useEffect(() => {
setPortValue(settings.gateway_port ?? 8080);
}, [settings.gateway_port]);
useEffect(() => {
setSslPortValue(settings.gateway_ssl_port ?? 8443);
}, [settings.gateway_ssl_port]);
useEffect(() => {
setListenAddressValue(settings.gateway_listen_address ?? '127.0.0.1');
}, [settings.gateway_listen_address]);
// Recompute conflict errors whenever SSL is toggled or either local port value changes.
// If a conflict clears for a port that had a pending (blocked) save, flush it now.
useEffect(() => {
const sslEnabled = settings.gateway_ssl_enabled ?? false;
const newPortError = sslEnabled && portValue === sslPortValue;
const newSslPortError = sslEnabled && sslPortValue === portValue;
if (!newPortError && portPendingSave.current) {
portPendingSave.current = false;
handleSave({ gateway_port: portValue });
}
if (!newSslPortError && sslPortPendingSave.current) {
sslPortPendingSave.current = false;
handleSave({ gateway_ssl_port: sslPortValue });
}
setPortError(newPortError);
setSslPortError(newSslPortError);
}, [settings.gateway_ssl_enabled, portValue, sslPortValue]);
const handleSslPortChange = (val: number | null) => {
if (val == null) return;
setSslPortValue(val);
if (val === portValue) {
setSslPortError(true);
sslPortPendingSave.current = true;
} else {
setSslPortError(false);
sslPortPendingSave.current = false;
handleSave({ gateway_ssl_port: val });
}
};
const handlePortChange = (val: number | null) => {
if (val == null) return;
setPortValue(val);
if ((settings.gateway_ssl_enabled ?? false) && val === sslPortValue) {
setPortError(true);
portPendingSave.current = true;
} else {
setPortError(false);
portPendingSave.current = false;
handleSave({ gateway_port: val });
}
};
const handleListenAddressCommit = () => {
const trimmed = listenAddressValue.trim();
if (trimmed) handleSave({ gateway_listen_address: trimmed });
};
const handleStopNow = async () => {
try {
await stopGateway();
message.success(t('gateway.stopped'));
} catch (e) {
message.error(String(e));
}
};
return (
<div className="p-6 pb-12">
{settingsLocked && (
<Alert
type="warning"
showIcon
message={t('gateway.settingsLockedTitle')}
description={t('gateway.settingsLockedDesc')}
action={
<Button danger size="small" onClick={handleStopNow}>
{t('gateway.stopNow')}
</Button>
}
style={{ marginBottom: 16 }}
/>
)}
<Card size="small" title={t('gateway.settingsService')} style={{ marginBottom: 16 }}>
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
<span>{t('gateway.listenAddress')}</span>
<Input
value={listenAddressValue}
onChange={(e) => setListenAddressValue(e.target.value)}
onBlur={handleListenAddressCommit}
onPressEnter={handleListenAddressCommit}
style={{ width: 200 }}
disabled={settingsLocked}
/>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
<span>{t('gateway.port')}</span>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<InputNumber
value={portValue}
onChange={handlePortChange}
min={1}
max={65535}
style={{ width: 200 }}
disabled={settingsLocked}
status={portError ? 'error' : undefined}
/>
{portError && (
<span style={{ color: token.colorError, fontSize: 12, marginTop: 4 }}>
{t('gateway.sslPortConflict')}
</span>
)}
</div>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
<span>{t('gateway.autoStart')}</span>
<Switch
checked={settings.gateway_auto_start ?? false}
onChange={(checked) => handleSave({ gateway_auto_start: checked })}
/>
</div>
</Card>
<Card size="small" title={t('gateway.settingsSsl')} style={{ marginBottom: 16 }}>
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span>{t('gateway.sslEnable')}</span>
<Tooltip title={t('gateway.sslEnableTooltip')}>
<Info size={12} style={{ color: token.colorTextSecondary, cursor: 'help' }} />
</Tooltip>
</div>
<Switch
checked={settings.gateway_ssl_enabled ?? false}
onChange={(checked) => handleSave({ gateway_ssl_enabled: checked })}
disabled={settingsLocked}
/>
</div>
{settings.gateway_ssl_enabled && (
<>
<Divider style={{ margin: '8px 0' }} />
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
<span>{t('gateway.sslPort')}</span>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<InputNumber
value={sslPortValue}
onChange={handleSslPortChange}
min={1}
max={65535}
style={{ width: 200 }}
disabled={settingsLocked}
status={sslPortError ? 'error' : undefined}
/>
{sslPortError && (
<span style={{ color: token.colorError, fontSize: 12, marginTop: 4 }}>
{t('gateway.sslPortConflict')}
</span>
)}
</div>
</div>
<Divider style={{ margin: '4px 0' }} />
<div style={{ padding: '4px 0' }} className="flex items-center justify-between">
<div className="flex items-center gap-1">
<span>{t('gateway.forceSsl')}</span>
<Tooltip title={t('gateway.forceSslTooltip')}>
<Info size={12} style={{ color: token.colorTextSecondary, cursor: 'help' }} />
</Tooltip>
</div>
<Switch
checked={settings.gateway_force_ssl ?? false}
onChange={(checked) => handleSave({ gateway_force_ssl: checked })}
disabled={settingsLocked}
/>
</div>
<Divider style={{ margin: '8px 0' }} />
<Alert
type="warning"
showIcon
icon={<ShieldAlert size={16} />}
message={t('gateway.sslWarning')}
description={t('gateway.sslWarningDesc')}
style={{ marginBottom: 12 }}
/>
<Radio.Group
value={settings.gateway_ssl_mode ?? 'upload'}
onChange={(e) => handleSave({ gateway_ssl_mode: e.target.value })}
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
disabled={settingsLocked}
>
<Radio value="upload">
<span style={{ fontWeight: 500 }}>{t('gateway.sslUpload')}</span>
<div style={{ color: token.colorTextSecondary, fontSize: 12, marginTop: 2 }}>
{t('gateway.sslUploadDesc')}
</div>
</Radio>
{(settings.gateway_ssl_mode ?? 'upload') === 'upload' && (
<div style={{ paddingLeft: 24, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="flex items-center gap-2">
<span style={{ width: 80, flexShrink: 0 }}>{t('gateway.sslCertFile')}</span>
<Input
readOnly
value={settings.gateway_ssl_cert_path ?? ''}
placeholder={t('gateway.sslCertFilePlaceholder')}
style={{ flex: 1 }}
disabled={settingsLocked}
/>
<Button
icon={<UploadIcon size={14} />}
onClick={() => handleSelectFile('gateway_ssl_cert_path')}
disabled={settingsLocked}
>
{t('gateway.sslSelectFile')}
</Button>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 80, flexShrink: 0 }}>{t('gateway.sslKeyFile')}</span>
<Input
readOnly
value={settings.gateway_ssl_key_path ?? ''}
placeholder={t('gateway.sslKeyFilePlaceholder')}
style={{ flex: 1 }}
disabled={settingsLocked}
/>
<Button
icon={<UploadIcon size={14} />}
onClick={() => handleSelectFile('gateway_ssl_key_path')}
disabled={settingsLocked}
>
{t('gateway.sslSelectFile')}
</Button>
</div>
</div>
)}
<Radio value="selfsign">
<span style={{ fontWeight: 500 }}>{t('gateway.sslSelfSign')}</span>
<div style={{ color: token.colorTextSecondary, fontSize: 12, marginTop: 2 }}>
{t('gateway.sslSelfSignDesc')}
</div>
</Radio>
{(settings.gateway_ssl_mode ?? 'upload') === 'selfsign' && (
<div style={{ paddingLeft: 24 }}>
<Alert
type="error"
showIcon
message={t('gateway.sslSelfSignWarning')}
style={{ marginBottom: 8 }}
/>
<Button type="primary" onClick={handleGenerateSelfSigned} disabled={settingsLocked}>
{t('gateway.sslGenerateCert')}
</Button>
{settings.gateway_ssl_cert_path && settings.gateway_ssl_mode === 'selfsign' && (
<div style={{ marginTop: 8, fontSize: 12, color: token.colorTextSecondary }}>
{t('gateway.sslCertFile')}: {settings.gateway_ssl_cert_path}
</div>
)}
</div>
)}
</Radio.Group>
</>
)}
</Card>
</div>
);
}
@@ -0,0 +1,486 @@
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Button, Typography, App, Tag, Select, Dropdown } from 'antd';
import { Zap, ZapOff, RefreshCw, AlertCircle, Wrench } from 'lucide-react';
import { ClaudeCode } from '@lobehub/icons';
import { Codex } from '@lobehub/icons';
import { OpenCode } from '@lobehub/icons';
import { Gemini } from '@lobehub/icons';
import { Cursor } from '@lobehub/icons';
import { CodexSessionVisibilityModal } from './CodexSessionVisibilityModal';
import { useGatewayStore } from '@/stores/gatewayStore';
import type { CliToolInfo, CodexSessionVisibilityStatus, QuickConnectProtocol } from '@/types';
const { Title, Paragraph, Text } = Typography;
interface QuickConnectItem {
key: string;
name: string;
avatar: (size: number) => ReactNode;
description: string;
}
const CONNECT_ITEMS: QuickConnectItem[] = [
{
key: 'claude_code',
name: 'Claude Code',
avatar: (size) => <ClaudeCode.Avatar size={size} />,
description: 'gateway.templateDescClaude',
},
{
key: 'codex',
name: 'Codex',
avatar: (size) => <Codex.Avatar size={size} />,
description: 'gateway.templateDescCodex',
},
{
key: 'opencode',
name: 'OpenCode',
avatar: (size) => <OpenCode.Avatar size={size} />,
description: 'gateway.templateDescOpencode',
},
{
key: 'gemini',
name: 'Gemini CLI',
avatar: (size) => <Gemini.Avatar size={size} />,
description: 'gateway.templateDescGemini',
},
{
key: 'cursor',
name: 'Cursor',
avatar: (size) => <Cursor.Avatar size={size} />,
description: 'gateway.templateDescCursor',
},
];
/** Tab icon that cycles through integration target avatars */
export function QuickConnectCycleIcon({ size = 16 }: { size?: number }) {
const [index, setIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setIndex((prev) => (prev + 1) % CONNECT_ITEMS.length);
}, 2000);
return () => clearInterval(timer);
}, []);
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size,
height: size,
verticalAlign: '-0.125em',
overflow: 'hidden',
}}>
{CONNECT_ITEMS[index].avatar(size)}
</span>
);
}
function StatusTag({
status,
connectedProtocol,
}: {
status: string;
connectedProtocol?: QuickConnectProtocol | null;
}) {
const { t } = useTranslation();
const displayStatus = status === 'connected' && connectedProtocol == null ? 'not_connected' : status;
switch (displayStatus) {
case 'connected':
return (
<Tag color="success">
{connectedProtocol === 'https'
? t('gateway.cliConnectedHttps')
: connectedProtocol === 'http'
? t('gateway.cliConnectedHttp')
: t('gateway.cliNotConnected')}
</Tag>
);
case 'not_connected':
return <Tag color="default">{t('gateway.cliNotConnected')}</Tag>;
case 'not_installed':
return <Tag color="error">{t('gateway.cliNotInstalled')}</Tag>;
default:
return null;
}
}
function ToolCard({
item,
toolInfo,
selectedKeyId,
selectedProtocol,
quickConnectBlocked,
onConnect,
onDisconnect,
onOpenCodexSessionVisibilityRepair,
connecting,
repairing,
}: {
item: QuickConnectItem;
toolInfo?: CliToolInfo;
selectedKeyId?: string;
selectedProtocol?: QuickConnectProtocol;
quickConnectBlocked: boolean;
onConnect: (toolId: string) => void;
onDisconnect: (toolId: string, restoreBackup: boolean) => void;
onOpenCodexSessionVisibilityRepair: () => void;
connecting: string | null;
repairing: string | null;
}) {
const { t } = useTranslation();
const { modal } = App.useApp();
const status = toolInfo?.status ?? 'not_installed';
const connectedProtocol = toolInfo?.connectedProtocol ?? null;
const displayStatus = status === 'connected' && connectedProtocol == null ? 'not_connected' : status;
const isConnecting = connecting === item.key;
const isRepairing = repairing === item.key;
const isNotInstalled = displayStatus === 'not_installed';
const isConnected = displayStatus === 'connected';
const needsReconnect =
isConnected &&
connectedProtocol != null &&
selectedProtocol != null &&
connectedProtocol !== selectedProtocol;
const handleConnect = useCallback(() => {
if (isNotInstalled) {
modal.confirm({
title: t('gateway.cliNotInstalledConfirmTitle'),
content: t('gateway.cliNotInstalledConfirmContent'),
okText: t('gateway.cliNotInstalledConfirmOk'),
cancelText: t('gateway.cliNotInstalledConfirmCancel'),
onOk: () => onConnect(item.key),
});
} else {
onConnect(item.key);
}
}, [item.key, isNotInstalled, onConnect, modal, t]);
const handleDisconnect = useCallback(() => {
const hasBackup = toolInfo?.hasBackup ?? false;
if (hasBackup) {
modal.confirm({
title: t('gateway.cliDisconnectTitle'),
content: t('gateway.cliDisconnectContent'),
okText: t('gateway.cliRestoreBackup'),
cancelText: t('gateway.cliRemoveFieldsOnly'),
onOk: () => onDisconnect(item.key, true),
onCancel: () => onDisconnect(item.key, false),
});
} else {
onDisconnect(item.key, false);
}
}, [item.key, toolInfo, onDisconnect, modal, t]);
const toolMenu = useMemo(() => ({
items: [
{
key: 'repair_visibility',
icon: <Wrench size={14} />,
label: t('gateway.codexRepairVisibility'),
},
],
onClick: ({ key }: { key: string }) => {
if (key === 'repair_visibility') {
onOpenCodexSessionVisibilityRepair();
}
},
}), [onOpenCodexSessionVisibilityRepair, t]);
return (
<Card key={item.key} size="small" hoverable className="gateway-quick-connect-card">
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flexShrink: 0 }}>
{item.avatar(40)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Title level={5} style={{ margin: 0 }}>{item.name}</Title>
{toolInfo?.version && !isNotInstalled && (
<Text type="secondary" style={{ fontSize: 12 }}>v{toolInfo.version.replace(/^[^\d]*/, '')}</Text>
)}
<StatusTag status={status} connectedProtocol={connectedProtocol} />
</div>
<Paragraph
type="secondary"
style={{ fontSize: 13, margin: 0, marginTop: 4 }}
ellipsis={{ rows: 1 }}
>
{t(item.description)}
</Paragraph>
{toolInfo?.configPath && (
<Text type="secondary" style={{ fontSize: 11 }}>
{toolInfo.configPath}
</Text>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
{isConnected && !needsReconnect ? (
<Button
danger
icon={<ZapOff size={14} />}
onClick={handleDisconnect}
loading={isConnecting}
>
{t('gateway.cliDisconnect')}
</Button>
) : (
<Button
type="primary"
icon={<Zap size={14} />}
onClick={handleConnect}
disabled={quickConnectBlocked || !selectedKeyId || !selectedProtocol}
loading={isConnecting}
>
{needsReconnect ? t('gateway.cliSwitchProtocolReconnect') : t('gateway.quickConnect')}
</Button>
)}
{item.key === 'codex' && (
<Dropdown menu={toolMenu} trigger={['click']}>
<Button icon={<Wrench size={14} />} loading={isRepairing}>
{t('gateway.cliTools')}
</Button>
</Dropdown>
)}
</div>
</div>
</Card>
);
}
export function GatewayTemplates() {
const { t } = useTranslation();
const { message } = App.useApp();
const {
status,
cliTools,
cliToolsLoading,
keys,
fetchStatus,
fetchCliToolStatuses,
connectCliTool,
disconnectCliTool,
repairCodexSessionVisibility,
getCodexSessionVisibilityStatus,
fetchKeys,
} = useGatewayStore();
const [connecting, setConnecting] = useState<string | null>(null);
const [repairing, setRepairing] = useState<string | null>(null);
const [codexVisibilityModalOpen, setCodexVisibilityModalOpen] = useState(false);
const [codexVisibilityStatus, setCodexVisibilityStatus] =
useState<CodexSessionVisibilityStatus | null>(null);
const [codexVisibilityStatusLoading, setCodexVisibilityStatusLoading] = useState(false);
const [codexVisibilityCreateBackup, setCodexVisibilityCreateBackup] = useState(true);
const enabledKeys = keys.filter((k) => k.enabled && k.has_encrypted_key);
const quickConnectBlocked = !status.is_running;
const [selectedKeyId, setSelectedKeyId] = useState<string | undefined>(undefined);
const availableProtocols = useMemo<QuickConnectProtocol[]>(() => {
const protocols: QuickConnectProtocol[] = [];
if (!status.force_ssl) {
protocols.push('http');
}
if (status.https_port != null) {
protocols.push('https');
}
if (protocols.length > 0) {
return protocols;
}
return status.force_ssl ? ['https'] : ['http'];
}, [status.force_ssl, status.https_port]);
const [selectedProtocol, setSelectedProtocol] = useState<QuickConnectProtocol | undefined>(undefined);
useEffect(() => {
fetchStatus();
fetchCliToolStatuses();
fetchKeys();
}, [fetchStatus, fetchCliToolStatuses, fetchKeys]);
// Auto-select first key when keys load
useEffect(() => {
if (!selectedKeyId && enabledKeys.length > 0) {
setSelectedKeyId(enabledKeys[0].id);
}
}, [enabledKeys, selectedKeyId]);
useEffect(() => {
if (!selectedProtocol || !availableProtocols.includes(selectedProtocol)) {
setSelectedProtocol(availableProtocols[0]);
return;
}
if (availableProtocols.length === 1 && selectedProtocol !== availableProtocols[0]) {
setSelectedProtocol(availableProtocols[0]);
}
}, [availableProtocols, selectedProtocol]);
const handleRefresh = useCallback(() => {
void fetchStatus();
void fetchCliToolStatuses();
}, [fetchStatus, fetchCliToolStatuses]);
const handleConnect = useCallback(
async (toolId: string) => {
if (!selectedKeyId || !selectedProtocol) return;
setConnecting(toolId);
try {
await connectCliTool(toolId, selectedKeyId, selectedProtocol);
const name = CONNECT_ITEMS.find((i) => i.key === toolId)?.name;
message.success(t('gateway.cliConnectSuccess', { name }));
} catch (e) {
message.error(t('gateway.cliConnectError', { error: String(e) }));
} finally {
setConnecting(null);
}
},
[connectCliTool, message, selectedKeyId, selectedProtocol, t],
);
const handleDisconnect = useCallback(
async (toolId: string, restoreBackup: boolean) => {
setConnecting(toolId);
try {
await disconnectCliTool(toolId, restoreBackup);
const name = CONNECT_ITEMS.find((i) => i.key === toolId)?.name;
message.success(t('gateway.cliDisconnectSuccess', { name }));
} catch (e) {
message.error(t('gateway.cliDisconnectError', { error: String(e) }));
} finally {
setConnecting(null);
}
},
[disconnectCliTool, message, t],
);
const loadCodexSessionVisibilityStatus = useCallback(async () => {
setCodexVisibilityStatusLoading(true);
try {
const status = await getCodexSessionVisibilityStatus();
setCodexVisibilityStatus(status);
} catch (e) {
message.error(t('gateway.codexRepairVisibilityError', { error: String(e) }));
} finally {
setCodexVisibilityStatusLoading(false);
}
}, [getCodexSessionVisibilityStatus, message, t]);
const handleOpenCodexSessionVisibilityRepair = useCallback(() => {
setCodexVisibilityCreateBackup(true);
setCodexVisibilityModalOpen(true);
void loadCodexSessionVisibilityStatus();
}, [loadCodexSessionVisibilityStatus]);
const handleRepairCodexSessionVisibility = useCallback(async () => {
setRepairing('codex');
try {
const result = await repairCodexSessionVisibility(codexVisibilityCreateBackup);
message.success(t('gateway.codexRepairVisibilitySuccess', {
provider: result.targetProvider,
sessions: result.changedSessionFiles,
rows: result.sqliteRowsUpdated,
}));
if (result.encryptedContentWarning) {
message.warning(result.encryptedContentWarning);
}
await fetchCliToolStatuses();
await loadCodexSessionVisibilityStatus();
} catch (e) {
message.error(t('gateway.codexRepairVisibilityError', { error: String(e) }));
} finally {
setRepairing(null);
}
}, [
codexVisibilityCreateBackup,
fetchCliToolStatuses,
loadCodexSessionVisibilityStatus,
message,
repairCodexSessionVisibility,
t,
]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<Button
icon={<RefreshCw size={14} />}
onClick={handleRefresh}
loading={cliToolsLoading}
>
{t('gateway.cliRefresh')}
</Button>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
style={{ width: 200 }}
value={selectedKeyId}
onChange={setSelectedKeyId}
placeholder={t('gateway.cliSelectKey')}
options={enabledKeys.map((k) => ({
value: k.id,
label: `${k.name} (${k.key_prefix})`,
}))}
/>
<Select<QuickConnectProtocol>
data-testid="gateway-protocol-select"
style={{ width: 140 }}
value={selectedProtocol}
onChange={setSelectedProtocol}
placeholder={t('gateway.cliSelectProtocol')}
disabled={availableProtocols.length <= 1}
options={availableProtocols.map((protocol) => ({
value: protocol,
label: protocol === 'https' ? t('gateway.cliProtocolHttps') : t('gateway.cliProtocolHttp'),
}))}
/>
</div>
</div>
{quickConnectBlocked && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: 'var(--ant-color-warning-bg)', borderRadius: 6, marginBottom: 4 }}>
<AlertCircle size={16} style={{ color: 'var(--ant-color-warning)' }} />
<Text type="secondary" style={{ fontSize: 13 }}>
{t('gateway.cliStartGatewayFirst')}
</Text>
</div>
)}
{enabledKeys.length === 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: 'var(--ant-color-warning-bg)', borderRadius: 6, marginBottom: 4 }}>
<AlertCircle size={16} style={{ color: 'var(--ant-color-warning)' }} />
<Text type="secondary" style={{ fontSize: 13 }}>
{t('gateway.cliNoKeys')}
</Text>
</div>
)}
{CONNECT_ITEMS.map((item) => (
<ToolCard
key={item.key}
item={item}
toolInfo={cliTools.find((t) => t.id === item.key)}
selectedKeyId={selectedKeyId}
selectedProtocol={selectedProtocol}
quickConnectBlocked={quickConnectBlocked}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onOpenCodexSessionVisibilityRepair={handleOpenCodexSessionVisibilityRepair}
connecting={connecting}
repairing={repairing}
/>
))}
<CodexSessionVisibilityModal
open={codexVisibilityModalOpen}
loading={codexVisibilityStatusLoading}
repairing={repairing === 'codex'}
status={codexVisibilityStatus}
createBackup={codexVisibilityCreateBackup}
onCreateBackupChange={setCodexVisibilityCreateBackup}
onCancel={() => setCodexVisibilityModalOpen(false)}
onRepair={handleRepairCodexSessionVisibility}
/>
</div>
);
}
@@ -0,0 +1,102 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { GatewayDiagnostics } from '../GatewayDiagnostics';
const fetchRequestLogs = vi.fn();
const clearRequestLogs = vi.fn();
let requestLogs = [
{
id: 'log-1',
keyId: 'key-1',
keyName: 'Gateway Key',
method: 'POST',
path: '/v1/chat/completions',
model: 'deepseek-chat',
providerId: 'provider-1',
statusCode: 502,
durationMs: 234,
requestTokens: 1200,
responseTokens: 2300,
errorMessage: 'Upstream timeout waiting for provider response.',
createdAt: 1_700_000_000,
},
];
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@/stores/gatewayStore', () => ({
useGatewayStore: () => ({
requestLogs,
requestLogsLoading: false,
fetchRequestLogs,
clearRequestLogs,
}),
}));
describe('GatewayDiagnostics', () => {
beforeEach(() => {
vi.clearAllMocks();
requestLogs = [
{
id: 'log-1',
keyId: 'key-1',
keyName: 'Gateway Key',
method: 'POST',
path: '/v1/chat/completions',
model: 'deepseek-chat',
providerId: 'provider-1',
statusCode: 502,
durationMs: 234,
requestTokens: 1200,
responseTokens: 2300,
errorMessage: 'Upstream timeout waiting for provider response.',
createdAt: 1_700_000_000,
},
];
});
it('only refreshes logs on mount and manual click, without background auto refresh', async () => {
const setIntervalSpy = vi.spyOn(global, 'setInterval');
const user = userEvent.setup();
render(<GatewayDiagnostics />);
expect(fetchRequestLogs).toHaveBeenCalledTimes(1);
expect(setIntervalSpy).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'common.refresh' }));
expect(fetchRequestLogs).toHaveBeenCalledTimes(2);
setIntervalSpy.mockRestore();
});
it('shows error previews on one line and opens a modal with the full error text', async () => {
const user = userEvent.setup();
render(<GatewayDiagnostics />);
expect(screen.getAllByText('gateway.logRequestTokens').length).toBeGreaterThan(0);
expect(screen.getAllByText('gateway.logResponseTokens').length).toBeGreaterThan(0);
expect(screen.getAllByText('gateway.totalTokens').length).toBeGreaterThan(0);
expect(screen.getAllByText('1.2k').length).toBeGreaterThan(0);
expect(screen.getAllByText('2.3k').length).toBeGreaterThan(0);
expect(screen.getAllByText('3.5k').length).toBeGreaterThan(0);
const errorText = screen.getByText('Upstream timeout waiting for provider response.');
expect(errorText.closest('button')).not.toBeNull();
expect(errorText).toHaveStyle({
color: '#ff4d4f',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
});
await user.click(errorText);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(screen.getAllByText('Upstream timeout waiting for provider response.').length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,67 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { GatewayMetrics } from '../GatewayMetrics';
const fetchUsageByDay = vi.fn();
const fetchUsageByProvider = vi.fn();
const fetchUsageByKey = vi.fn();
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@/stores/gatewayStore', () => ({
useGatewayStore: () => ({
usageByDay: [
{
date: '2025-03-24',
request_count: 3,
token_count: 3500,
request_tokens: 1200,
response_tokens: 2300,
},
],
usageByProvider: [
{
provider_id: 'provider-1',
provider_name: 'DeepSeek',
request_count: 3,
token_count: 3500,
request_tokens: 1200,
response_tokens: 2300,
},
],
usageByKey: [
{
key_id: 'key-1',
key_name: 'Gateway Key',
request_count: 3,
token_count: 3500,
request_tokens: 1200,
response_tokens: 2300,
},
],
fetchUsageByDay,
fetchUsageByProvider,
fetchUsageByKey,
}),
}));
describe('GatewayMetrics', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders split request and response token columns for aggregate tables', () => {
render(<GatewayMetrics />);
expect(screen.getAllByText('gateway.logRequestTokens').length).toBeGreaterThan(0);
expect(screen.getAllByText('gateway.logResponseTokens').length).toBeGreaterThan(0);
expect(screen.getAllByText('gateway.totalTokens').length).toBeGreaterThan(0);
expect(screen.getAllByText('1.2k').length).toBeGreaterThan(0);
expect(screen.getAllByText('2.3k').length).toBeGreaterThan(0);
expect(screen.getAllByText('3.5k').length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,200 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { message } from 'antd';
import { GatewayOverview } from '../GatewayOverview';
let status = {
is_running: false,
listen_address: '127.0.0.1',
port: 8000,
ssl_enabled: true,
started_at: null,
https_port: 8443,
force_ssl: false,
};
const startGateway = vi.fn();
const stopGateway = vi.fn();
const fetchStatus = vi.fn();
const fetchMetrics = vi.fn();
const fetchRequestLogs = vi.fn();
const listRequestLogs = vi.fn();
let metrics: Record<string, unknown> | null = null;
let requestLogs: Array<Record<string, unknown>> = [];
let recentLogsResponse: Array<Record<string, unknown>> = [];
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@/stores/gatewayStore', () => ({
useGatewayStore: () => ({
status,
metrics,
requestLogs,
requestLogsLoading: false,
startGateway,
stopGateway,
fetchStatus,
fetchMetrics,
fetchRequestLogs,
listRequestLogs,
}),
}));
describe('GatewayOverview', () => {
beforeEach(() => {
vi.clearAllMocks();
status = {
is_running: false,
listen_address: '127.0.0.1',
port: 8000,
ssl_enabled: true,
started_at: null,
https_port: 8443,
force_ssl: false,
};
metrics = null;
requestLogs = [];
recentLogsResponse = [];
listRequestLogs.mockImplementation(() => Promise.resolve(recentLogsResponse));
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
it('shows an error message when starting the gateway fails', async () => {
startGateway.mockRejectedValueOnce(new Error('TLS cert missing'));
const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => {
const noop = () => {};
return {
then: undefined as never,
promise: Promise.resolve(),
close: noop,
} as never;
});
render(<GatewayOverview />);
await userEvent.click(screen.getByRole('button', { name: 'gateway.start' }));
await waitFor(() => {
expect(errorSpy).toHaveBeenCalled();
});
});
it('shows split request and response tokens in overview cards and recent logs', async () => {
metrics = {
total_requests: 12,
total_tokens: 3500,
total_request_tokens: 1200,
total_response_tokens: 2300,
active_connections: 0,
today_requests: 2,
today_tokens: 1500,
today_request_tokens: 900,
today_response_tokens: 600,
};
requestLogs = [
{
id: 'log-1',
keyId: 'key-1',
keyName: 'Gateway Key',
method: 'POST',
path: '/v1/chat/completions',
model: 'deepseek-chat',
providerId: 'provider-1',
statusCode: 200,
durationMs: 123,
requestTokens: 900,
responseTokens: 600,
errorMessage: null,
createdAt: 1_700_000_000,
},
];
recentLogsResponse = [...requestLogs];
render(<GatewayOverview />);
await screen.findByText('/v1/chat/completions');
expect(screen.getAllByText('1.5k').length).toBeGreaterThan(0);
expect(screen.getAllByText('900').length).toBeGreaterThan(0);
expect(screen.getAllByText('600').length).toBeGreaterThan(0);
expect(screen.getAllByText('3.5k').length).toBeGreaterThan(0);
});
it('shows only the latest 10 overview logs and exposes a view more action', async () => {
const onViewMoreLogs = vi.fn();
recentLogsResponse = Array.from({ length: 12 }, (_, index) => ({
id: `log-${index + 1}`,
keyId: `key-${index + 1}`,
keyName: `Gateway Key ${index + 1}`,
method: 'POST',
path: `/v1/test/${index + 1}`,
model: 'deepseek-chat',
providerId: 'provider-1',
statusCode: 200,
durationMs: 100 + index,
requestTokens: 10 + index,
responseTokens: 20 + index,
errorMessage: null,
createdAt: 1_700_000_000 + index,
}));
render(<GatewayOverview onViewMoreLogs={onViewMoreLogs} />);
expect(await screen.findByText('/v1/test/1')).toBeInTheDocument();
expect(screen.getByText('/v1/test/10')).toBeInTheDocument();
expect(screen.queryByText('/v1/test/11')).not.toBeInTheDocument();
expect(screen.queryByText('/v1/test/12')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'gateway.viewMoreLogs' }));
expect(onViewMoreLogs).toHaveBeenCalledTimes(1);
});
it('does not auto-refresh recent logs while the gateway is stopped but still allows manual refresh', async () => {
const setIntervalSpy = vi.spyOn(global, 'setInterval');
recentLogsResponse = [
{
id: 'log-1',
keyId: 'key-1',
keyName: 'Gateway Key',
method: 'POST',
path: '/v1/chat/completions',
model: 'deepseek-chat',
providerId: 'provider-1',
statusCode: 200,
durationMs: 123,
requestTokens: 900,
responseTokens: 600,
errorMessage: null,
createdAt: 1_700_000_000,
},
];
render(<GatewayOverview />);
await waitFor(() => {
expect(listRequestLogs).toHaveBeenCalledTimes(1);
});
expect(setIntervalSpy.mock.calls.filter(([, delay]) => delay === 5000)).toHaveLength(1);
await userEvent.click(screen.getByRole('button', { name: 'common.refresh' }));
expect(listRequestLogs).toHaveBeenCalledTimes(2);
setIntervalSpy.mockRestore();
});
});
@@ -0,0 +1,410 @@
import { App } from 'antd';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GatewayTemplates } from '../GatewayTemplates';
const fetchStatus = vi.fn();
const fetchCliToolStatuses = vi.fn();
const fetchKeys = vi.fn();
const connectCliTool = vi.fn();
const disconnectCliTool = vi.fn();
const getCodexSessionVisibilityStatus = vi.fn();
const repairCodexSessionVisibility = vi.fn();
let storeState: Record<string, unknown>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@lobehub/icons', () => {
const avatar = {
Avatar: ({ size }: { size: number }) => <span data-size={size}>avatar</span>,
};
return {
ClaudeCode: avatar,
Codex: avatar,
OpenCode: avatar,
Gemini: avatar,
Cursor: avatar,
};
});
vi.mock('@/stores/gatewayStore', () => ({
useGatewayStore: () => storeState,
}));
function buildStoreState(overrides: Record<string, unknown> = {}) {
return {
status: {
is_running: true,
listen_address: '127.0.0.1',
port: 8000,
ssl_enabled: true,
started_at: null,
https_port: 8443,
force_ssl: false,
},
cliTools: [
{
id: 'claude_code',
name: 'Claude Code',
status: 'not_connected',
configPath: '/configs/claude.json',
hasBackup: false,
connectedProtocol: null,
},
{
id: 'codex',
name: 'Codex',
status: 'connected',
configPath: '/configs/codex.json',
hasBackup: true,
connectedProtocol: 'http',
},
{
id: 'opencode',
name: 'OpenCode',
status: 'connected',
configPath: '/configs/opencode.json',
hasBackup: true,
connectedProtocol: 'https',
},
{
id: 'gemini',
name: 'Gemini CLI',
status: 'not_installed',
configPath: null,
hasBackup: false,
connectedProtocol: null,
},
],
cliToolsLoading: false,
keys: [
{
id: 'key-1',
name: 'Primary Gateway Key',
key_hash: 'hash',
key_prefix: 'aqb_123',
enabled: true,
created_at: 1,
last_used_at: null,
has_encrypted_key: true,
},
],
fetchStatus,
fetchCliToolStatuses,
fetchKeys,
connectCliTool,
disconnectCliTool,
getCodexSessionVisibilityStatus,
repairCodexSessionVisibility,
...overrides,
};
}
function renderWithApp() {
return render(
<App>
<GatewayTemplates />
</App>,
);
}
async function selectProtocol(protocolLabel: string) {
const protocolSelect = screen.getByTestId('gateway-protocol-select');
fireEvent.mouseDown(within(protocolSelect).getByRole('combobox'));
await userEvent.click(await screen.findByText(protocolLabel));
}
function getToolCard(name: string) {
const heading = screen.getByRole('heading', { name, level: 5 });
const card = heading.closest('.ant-card');
expect(card).not.toBeNull();
return card as HTMLElement;
}
describe('GatewayTemplates', () => {
beforeEach(() => {
vi.clearAllMocks();
connectCliTool.mockResolvedValue(undefined);
disconnectCliTool.mockResolvedValue(undefined);
getCodexSessionVisibilityStatus.mockResolvedValue({
targetProvider: 'any',
codexHome: '/Users/test/.codex',
totalSessionFiles: 5,
mismatchedSessionFiles: 2,
sqlitePresent: true,
sqliteRows: 4,
sqliteMismatchedRows: 1,
sqliteUserEventRowsNeedingRepair: 1,
sqliteCwdRowsNeedingRepair: 0,
workspaceRootsNeedingUpdate: 1,
statusRows: [
{
scope: 'sessions',
provider: 'any',
count: 3,
mismatchedCount: 0,
status: 'ok',
},
{
scope: 'archived_sessions',
provider: 'openai',
count: 2,
mismatchedCount: 2,
status: 'needs_repair',
},
{
scope: 'state_5.sqlite',
provider: 'openai',
count: 1,
mismatchedCount: 1,
status: 'needs_repair',
},
],
encryptedContentWarning: 'encrypted_content warning',
});
repairCodexSessionVisibility.mockResolvedValue({
targetProvider: 'any',
changedSessionFiles: 2,
skippedLockedSessionFiles: 0,
sqliteRowsUpdated: 3,
sqliteProviderRowsUpdated: 2,
sqliteUserEventRowsUpdated: 1,
sqliteCwdRowsUpdated: 0,
updatedWorkspaceRoots: 1,
backupDir: '/tmp/backup',
encryptedContentWarning: null,
});
storeState = buildStoreState();
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
it('fetches fresh status, lets users switch protocols, and passes the selected protocol to quick connect', async () => {
renderWithApp();
await waitFor(() => {
expect(fetchStatus).toHaveBeenCalledTimes(1);
expect(fetchCliToolStatuses).toHaveBeenCalledTimes(1);
expect(fetchKeys).toHaveBeenCalledTimes(1);
});
expect(screen.getByText('gateway.cliConnectedHttp')).toBeInTheDocument();
expect(screen.getByText('gateway.cliConnectedHttps')).toBeInTheDocument();
expect(screen.getByText('gateway.cliNotConnected')).toBeInTheDocument();
expect(screen.getAllByText('gateway.cliNotInstalled').length).toBeGreaterThan(0);
await selectProtocol('gateway.cliProtocolHttps');
const codexCard = getToolCard('Codex');
expect(within(codexCard).getByRole('button', { name: 'gateway.cliSwitchProtocolReconnect' })).toBeInTheDocument();
const openCodeCard = getToolCard('OpenCode');
expect(within(openCodeCard).getByRole('button', { name: 'gateway.cliDisconnect' })).toBeInTheDocument();
const claudeCard = getToolCard('Claude Code');
const quickConnectButton = within(claudeCard).getByRole('button', { name: 'gateway.quickConnect' });
await waitFor(() => expect(quickConnectButton).toBeEnabled());
await userEvent.click(quickConnectButton);
await waitFor(() => {
expect(connectCliTool).toHaveBeenCalledWith('claude_code', 'key-1', 'https');
});
});
it('auto-selects the only available http protocol and disables switching when https is unavailable', async () => {
storeState = buildStoreState({
status: {
is_running: true,
listen_address: '127.0.0.1',
port: 8000,
ssl_enabled: false,
started_at: null,
https_port: null,
force_ssl: false,
},
cliTools: [
{
id: 'claude_code',
name: 'Claude Code',
status: 'not_connected',
configPath: '/configs/claude.json',
hasBackup: false,
connectedProtocol: null,
},
],
});
renderWithApp();
const protocolSelect = await screen.findByTestId('gateway-protocol-select');
expect(protocolSelect).toHaveClass('ant-select-disabled');
expect(within(protocolSelect).getByText('gateway.cliProtocolHttp')).toBeInTheDocument();
const claudeCard = getToolCard('Claude Code');
const quickConnectButton = within(claudeCard).getByRole('button', { name: 'gateway.quickConnect' });
await waitFor(() => expect(quickConnectButton).toBeEnabled());
await userEvent.click(quickConnectButton);
await waitFor(() => {
expect(connectCliTool).toHaveBeenCalledWith('claude_code', 'key-1', 'http');
});
});
it('treats force ssl as https-only and offers reconnect when a tool is connected over http', async () => {
storeState = buildStoreState({
status: {
is_running: true,
listen_address: '127.0.0.1',
port: 8000,
ssl_enabled: true,
started_at: null,
https_port: 8443,
force_ssl: true,
},
});
renderWithApp();
const protocolSelect = await screen.findByTestId('gateway-protocol-select');
expect(protocolSelect).toHaveClass('ant-select-disabled');
expect(within(protocolSelect).getByText('gateway.cliProtocolHttps')).toBeInTheDocument();
const codexCard = getToolCard('Codex');
expect(within(codexCard).getByText('gateway.cliConnectedHttp')).toBeInTheDocument();
const reconnectButton = within(codexCard).getByRole('button', { name: 'gateway.cliSwitchProtocolReconnect' });
await waitFor(() => expect(reconnectButton).toBeEnabled());
await userEvent.click(reconnectButton);
await waitFor(() => {
expect(connectCliTool).toHaveBeenCalledWith('codex', 'key-1', 'https');
});
});
it('treats connected tools without a reported protocol as not connected in the UI', async () => {
storeState = buildStoreState({
cliTools: [
{
id: 'codex',
name: 'Codex',
status: 'connected',
configPath: '/configs/codex.json',
hasBackup: true,
connectedProtocol: null,
},
],
});
renderWithApp();
const codexCard = getToolCard('Codex');
expect(within(codexCard).getByText('gateway.cliNotConnected')).toBeInTheDocument();
expect(within(codexCard).queryByText('gateway.cliConnected')).not.toBeInTheDocument();
expect(within(codexCard).getByRole('button', { name: 'gateway.quickConnect' })).toBeInTheDocument();
expect(within(codexCard).queryByRole('button', { name: 'gateway.cliDisconnect' })).not.toBeInTheDocument();
});
it('shows Codex-only tools and repairs session visibility from status modal', async () => {
renderWithApp();
const codexCard = getToolCard('Codex');
const claudeCard = getToolCard('Claude Code');
const openCodeCard = getToolCard('OpenCode');
expect(within(codexCard).getByRole('button', { name: 'gateway.cliTools' })).toBeInTheDocument();
expect(within(claudeCard).queryByRole('button', { name: 'gateway.cliTools' })).not.toBeInTheDocument();
expect(within(openCodeCard).queryByRole('button', { name: 'gateway.cliTools' })).not.toBeInTheDocument();
await userEvent.click(within(codexCard).getByRole('button', { name: 'gateway.cliTools' }));
const repairMenuItem = await screen.findByRole('menuitem', { name: 'gateway.codexRepairVisibility' });
expect(repairMenuItem.querySelector('.lucide-wrench')).toBeInTheDocument();
await userEvent.click(repairMenuItem);
expect(await screen.findByText('gateway.codexRepairVisibilityModalTitle')).toBeInTheDocument();
expect(getCodexSessionVisibilityStatus).toHaveBeenCalledTimes(1);
expect(screen.getByText('gateway.codexRepairVisibilityCurrentStatus')).toBeInTheDocument();
expect(screen.getByText('gateway.codexRepairVisibilityMismatchSummary')).toBeInTheDocument();
expect(screen.getByText('archived_sessions')).toBeInTheDocument();
expect(screen.getAllByText('openai').length).toBeGreaterThan(0);
expect(screen.getByText('encrypted_content warning')).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'gateway.codexRepairVisibilityCreateBackup' })).toBeChecked();
await userEvent.click(screen.getByRole('button', { name: 'gateway.codexRepairVisibilityConfirmOk' }));
await waitFor(() => {
expect(repairCodexSessionVisibility).toHaveBeenCalledWith(true);
});
await waitFor(() => expect(getCodexSessionVisibilityStatus).toHaveBeenCalledTimes(2));
expect(fetchCliToolStatuses).toHaveBeenCalledTimes(2);
});
it('passes the unchecked backup option when repairing Codex session visibility', async () => {
renderWithApp();
const codexCard = getToolCard('Codex');
await userEvent.click(within(codexCard).getByRole('button', { name: 'gateway.cliTools' }));
await userEvent.click(await screen.findByText('gateway.codexRepairVisibility'));
const backupCheckbox = await screen.findByRole('checkbox', { name: 'gateway.codexRepairVisibilityCreateBackup' });
await userEvent.click(backupCheckbox);
expect(backupCheckbox).not.toBeChecked();
await userEvent.click(screen.getByRole('button', { name: 'gateway.codexRepairVisibilityConfirmOk' }));
await waitFor(() => {
expect(repairCodexSessionVisibility).toHaveBeenCalledWith(false);
});
});
it('shows a warning and disables quick connect actions while the gateway is stopped', async () => {
storeState = buildStoreState({
status: {
is_running: false,
listen_address: '127.0.0.1',
port: 8000,
ssl_enabled: true,
started_at: null,
https_port: 8443,
force_ssl: false,
},
});
renderWithApp();
expect(screen.getByText('gateway.cliStartGatewayFirst')).toBeInTheDocument();
const claudeCard = getToolCard('Claude Code');
await waitFor(() => {
expect(within(claudeCard).getByRole('button', { name: 'gateway.quickConnect' })).toBeDisabled();
});
await selectProtocol('gateway.cliProtocolHttps');
const codexCard = getToolCard('Codex');
await waitFor(() => {
expect(within(codexCard).getByRole('button', { name: 'gateway.cliSwitchProtocolReconnect' })).toBeDisabled();
});
const protocolSelect = screen.getByTestId('gateway-protocol-select');
expect(protocolSelect).not.toHaveClass('ant-select-disabled');
expect(screen.getByText('Primary Gateway Key (aqb_123)')).toBeInTheDocument();
});
});
+6
View File
@@ -0,0 +1,6 @@
export { GatewayOverview } from './GatewayOverview';
export { GatewayKeys } from './GatewayKeys';
export { GatewayMetrics } from './GatewayMetrics';
export { GatewaySettings } from './GatewaySettings';
export { GatewayDiagnostics } from './GatewayDiagnostics';
export { GatewayTemplates, QuickConnectCycleIcon } from './GatewayTemplates';
@@ -0,0 +1,29 @@
function formatCompact(value: number, divisor: number, suffix: string): string {
const compact = Math.round((value / divisor) * 10) / 10;
const formatted = Number.isInteger(compact) ? compact.toFixed(0) : compact.toFixed(1);
return `${formatted}${suffix}`;
}
export function formatTokenCount(value: number): string {
const abs = Math.abs(value);
if (abs >= 1_000_000) return formatCompact(value, 1_000_000, 'm');
if (abs >= 1_000) return formatCompact(value, 1_000, 'k');
return value.toLocaleString();
}
/** Format milliseconds into human-readable duration: 100ms, 1.1s, 1m2s */
export function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) {
const s = ms / 1000;
return `${Number.isInteger(s) ? s.toFixed(0) : s.toFixed(1)}s`;
}
const m = Math.floor(ms / 60_000);
const s = Math.round((ms % 60_000) / 1000);
return s > 0 ? `${m}m${s}s` : `${m}m`;
}
/** Format tokens per second: 28.5 tok/s */
export function formatSpeed(tps: number): string {
return `${tps < 10 ? tps.toFixed(1) : Math.round(tps)} tok/s`;
}