forked from boos/BoosAPI
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user