Merge remote-tracking branch 'origin/main'

# Conflicts:
#	AGENTS.md
#	backend/docs/docs.go
#	backend/docs/swagger.json
#	backend/docs/swagger.yaml
#	frontend/components/providers/title-updater.tsx
#	frontend/messages/fragments/admin.en.json
#	frontend/messages/fragments/admin.zh-CN.json
#	frontend/proxy.ts
This commit is contained in:
ryan
2026-09-03 09:16:22 +08:00
1065 changed files with 271858 additions and 9600 deletions
@@ -2,9 +2,9 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -77,7 +77,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
setLruEnabled(data.lru_enabled);
} catch (err) {
toast.error(t('fetchCacheStatusFailed'), {
description: err instanceof Error ? err.message : '未知错误',
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingCache(false);
@@ -116,7 +116,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
await fetchCacheStatus(true);
} catch (err) {
toast.error(t('saveConfigFailed'), {
description: err instanceof Error ? err.message : '未知错误',
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setSavingConfig(false);
@@ -133,7 +133,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
await fetchCacheStatus(true);
} catch (err) {
toast.error(t('clearCacheFailed'), {
description: err instanceof Error ? err.message : '未知错误',
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setClearingCache(false);
@@ -256,7 +256,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
value={maxSizeMB}
onChange={(e) => setMaxSizeMB(e.target.value)}
className='h-8 text-xs bg-background/50 border-border/40'
placeholder='例如 100'
placeholder={t('maxSizePlaceholder')}
required
/>
<p className='text-[9px] text-muted-foreground'>
@@ -275,7 +275,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
value={ttlMinutes}
onChange={(e) => setTtlMinutes(e.target.value)}
className='h-8 text-xs bg-background/50 border-border/40'
placeholder='例如 60,0 表示永不过期'
placeholder={t('ttlPlaceholder')}
required
/>
<p className='text-[9px] text-muted-foreground'>
@@ -6,8 +6,8 @@ import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
import { sql } from '@codemirror/lang-sql';
import { toast } from 'sonner';
import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -101,7 +101,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
setSqlResult(result);
toast.success(t('sqlExecSuccess'));
} catch (err) {
setSqlError(err instanceof Error ? err.message : '未知执行错误');
setSqlError(err instanceof Error ? err.message : t('unknownExecError'));
toast.error(t('sqlExecFailed'));
} finally {
setExecutingSQL(false);
@@ -198,7 +198,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
size='icon'
className='h-7 w-7 text-muted-foreground hover:text-foreground'
onClick={() => setSqlQuery('')}
title='清空编辑器'
title={t('clearEditor')}
>
<Trash2 className='size-3.5' />
</Button>
@@ -241,7 +241,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div
onMouseDown={handleMouseDown}
className='h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
title='拖动调整大小'
title={t('dragResize')}
>
<div className='w-8 h-1 rounded bg-muted-foreground/30' />
</div>
@@ -272,7 +272,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{sqlError && (
<div className='bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]'>
<p className='font-semibold mb-1'>SQL 执行报错 (Error):</p>
<p className='font-semibold mb-1'>{t('execErrorTitle')}</p>
<pre className='whitespace-pre-wrap'>{sqlError}</pre>
</div>
)}
@@ -2,9 +2,9 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -88,14 +88,14 @@ export function TableBrowser({
});
setTableData(data);
} catch (err) {
toast.error(`获取数据表 ${tableName} 数据失败`, {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('fetchTableFailed', { table: tableName }), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingData(false);
}
},
[],
[t],
);
// 默认选择第一张表
@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
Activity,
@@ -16,7 +17,6 @@ import {
Server,
Terminal,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -79,7 +79,7 @@ export function DatabasePageClient() {
setOverview(data);
} catch (err) {
toast.error(t('fetchOverviewFailed'), {
description: err instanceof Error ? err.message : '未知错误',
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingOverview(false);
@@ -96,7 +96,7 @@ export function DatabasePageClient() {
setTables(data);
} catch (err) {
toast.error(t('fetchTablesFailed'), {
description: err instanceof Error ? err.message : '未知错误',
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingTables(false);
@@ -128,7 +128,7 @@ export function DatabasePageClient() {
});
} catch (err) {
toast.error(t('exportFailed'), {
description: err instanceof Error ? err.message : '导出异常',
description: err instanceof Error ? err.message : t('exportException'),
});
} finally {
setExporting(false);
@@ -497,7 +497,9 @@ export function FileList() {
<div className='flex flex-col items-center gap-3'>
{getFileIcon(detailTarget.mime_type, 'size-14')}
<span className='text-xs font-medium text-muted-foreground uppercase'>
{detailTarget.extension} 文件
{t('list.extensionFile', {
ext: detailTarget.extension,
})}
</span>
</div>
)}
@@ -636,7 +638,9 @@ export function FileList() {
<AlertDialogHeader>
<AlertDialogTitle>{t('list.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('list.deleteConfirm', { name: deleteTarget?.file_name || '' })}
{t('list.deleteConfirm', {
name: deleteTarget?.file_name ?? '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -50,43 +50,47 @@ const categoryMap: Record<string, string> = {
其他: 'others',
};
const statsChartConfig = {
images: {
label: 'images',
color: 'hsl(var(--chart-1))',
},
videos: {
label: 'videos',
color: 'hsl(var(--chart-2))',
},
audio: {
label: 'audio',
color: 'hsl(var(--chart-3))',
},
documents: {
label: 'documents',
color: 'hsl(var(--chart-4))',
},
archives: {
label: 'archives',
color: 'hsl(var(--chart-5))',
},
others: {
label: 'others',
color: 'hsl(var(--chart-6))',
},
count: {
label: 'count',
color: 'hsl(var(--primary))',
},
size: {
label: 'size',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
function useStatsChartConfig() {
const t = useTranslations('admin.files');
return {
images: {
label: t('stats.catImages'),
color: 'hsl(var(--chart-1))',
},
videos: {
label: t('stats.catVideos'),
color: 'hsl(var(--chart-2))',
},
audio: {
label: t('stats.catAudio'),
color: 'hsl(var(--chart-3))',
},
documents: {
label: t('stats.catDocuments'),
color: 'hsl(var(--chart-4))',
},
archives: {
label: t('stats.catArchives'),
color: 'hsl(var(--chart-5))',
},
others: {
label: t('stats.catOthers'),
color: 'hsl(var(--chart-6))',
},
count: {
label: t('stats.chartLabelCount'),
color: 'hsl(var(--primary))',
},
size: {
label: t('stats.chartLabelSize'),
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
}
export function FileStats() {
const t = useTranslations('admin.files');
const statsChartConfig = useStatsChartConfig();
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
'count',
);
@@ -139,11 +143,16 @@ export function FileStats() {
const maxStats = React.useMemo(() => {
if (!stats?.categories || stats.categories.length === 0) {
return { maxCountName: '无', maxCount: 0, maxSizeName: '无', maxSize: 0 };
return {
maxCountName: t('stats.none'),
maxCount: 0,
maxSizeName: t('stats.none'),
maxSize: 0,
};
}
let maxCountName = '无';
let maxCountName = t('stats.none');
let maxCount = 0;
let maxSizeName = '无';
let maxSizeName = t('stats.none');
let maxSize = 0;
stats.categories.forEach((cat) => {
@@ -158,7 +167,7 @@ export function FileStats() {
});
return { maxCountName, maxCount, maxSizeName, maxSize };
}, [stats?.categories]);
}, [stats?.categories, t]);
const trendSummary = React.useMemo(() => {
if (!stats?.trend) return { count: 0, size: 0 };
@@ -369,7 +378,7 @@ export function FileStats() {
const formattedValue =
trendMetric === 'size'
? formatFileSize(Number(value))
: `${value} 个`;
: `${value} ${t('stats.countUnit')}`;
return (
<>
<div className='flex items-center gap-1.5'>
@@ -479,7 +488,7 @@ export function FileStats() {
</span>
</div>
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{value} 个
{value} {t('stats.countUnit')}
</span>
</>
);
@@ -488,8 +497,12 @@ export function FileStats() {
}
/>
<ChartLegend
content={<ChartLegendContent nameKey='name' />}
className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4'
content={
<ChartLegendContent
nameKey='name'
className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4'
/>
}
/>
</PieChart>
</ChartContainer>
@@ -576,8 +589,12 @@ export function FileStats() {
}
/>
<ChartLegend
content={<ChartLegendContent nameKey='name' />}
className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4'
content={
<ChartLegendContent
nameKey='name'
className='flex-wrap justify-center gap-x-4 gap-y-1 text-[11px] pt-4'
/>
}
/>
</PieChart>
</ChartContainer>
@@ -438,7 +438,7 @@ export function StorageConfigTab() {
<TextField
label={t('storage.basePath')}
value={config.webdav.base_path}
placeholder='wavelet'
placeholder='openflare'
onChange={(base_path) =>
setConfig({
...config,
@@ -66,12 +66,15 @@ interface TopUserData {
count: number;
}
const chartConfig = {
count: {
label: '请求量',
color: 'hsl(var(--primary))',
},
} satisfies ChartConfig;
function useAnalyticsChartConfig() {
const t = useTranslations('admin.logs.analytics');
return {
count: {
label: t('requestVolume'),
color: 'hsl(var(--primary))',
},
} satisfies ChartConfig;
}
// Format YYYY-MM-DD to MM/DD
function formatDateLabel(dateStr: string) {
@@ -85,6 +88,7 @@ function formatDateLabel(dateStr: string) {
export function AccessAnalytics() {
const t = useTranslations('admin.logs.analytics');
const chartConfig = useAnalyticsChartConfig();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
@@ -111,7 +115,7 @@ export function AccessAnalytics() {
setClickhouseDisabled(false);
} catch (err) {
const errorInstance =
err instanceof Error ? err : new Error('获取数据统计失败');
err instanceof Error ? err : new Error(t('fetchFailed'));
const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
setClickhouseDisabled(true);
@@ -121,7 +125,7 @@ export function AccessAnalytics() {
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchAnalytics();
@@ -290,7 +294,7 @@ export function AccessAnalytics() {
strokeWidth={2}
fillOpacity={1}
fill='url(#colorCount)'
name='请求次数'
name={t('requestCount')}
/>
</AreaChart>
</ChartContainer>
@@ -338,8 +342,10 @@ export function AccessAnalytics() {
<span className='font-semibold'>{item.browser}</span>
</span>
<span className='text-muted-foreground font-mono text-xs'>
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}
%)
{t('countTimes', {
count: item.count.toLocaleString(),
percent: percent.toFixed(1),
})}
</span>
</div>
<div className='h-2 w-full rounded-full bg-muted overflow-hidden'>
@@ -186,12 +186,12 @@ export function AccessLogs() {
const copyToClipboard = (text: string, subject: string) => {
navigator.clipboard.writeText(text);
toast.success(`${subject}已复制到剪贴板`);
toast.success(t('copiedToClipboard', { subject }));
};
// Prettify Headers JSON string
const getPrettyHeaders = (headersRaw?: string) => {
if (!headersRaw) return '暂无头部数据';
if (!headersRaw) return t('noHeaderData');
try {
const parsed = JSON.parse(headersRaw);
return JSON.stringify(parsed, null, 2);
@@ -417,7 +417,7 @@ export function AccessLogs() {
{t('accessLogDetail')}
</SheetTitle>
<SheetDescription className='text-xs'>
访问 ID: {selectedLog?.id}
{t('accessId', { id: selectedLog?.id ?? '' })}
</SheetDescription>
</SheetHeader>
@@ -505,7 +505,7 @@ export function AccessLogs() {
onClick={() =>
copyToClipboard(selectedLog.user_agent, 'User-Agent')
}
title='复制 User-Agent'
title={t('copyUserAgent')}
>
<Copy className='size-3' />
</Button>
@@ -531,7 +531,7 @@ export function AccessLogs() {
'Headers',
)
}
title='复制 Headers'
title={t('copyHeaders')}
>
<Copy className='size-3' />
</Button>
@@ -174,13 +174,16 @@ export function AppLogs() {
}, [logs, autoScroll]);
// ---- Data fetching ---------------------------------------------------
const fetchLogs = useCallback(async (cursor: number = 0) => {
try {
return await services.adminLog.getLogs(cursor);
} catch (err) {
throw err instanceof Error ? err : new Error('获取日志失败');
}
}, []);
const fetchLogs = useCallback(
async (cursor: number = 0) => {
try {
return await services.adminLog.getLogs(cursor);
} catch (err) {
throw err instanceof Error ? err : new Error(t('fetchFailed'));
}
},
[t],
);
const loadHistory = useCallback(
async (cursor: number = 0) => {
@@ -211,16 +214,16 @@ export function AppLogs() {
setNextCursor(data.next_cursor);
} catch (err) {
if (isInitial) {
setError(err instanceof Error ? err : new Error('获取日志失败'));
setError(err instanceof Error ? err : new Error(t('fetchFailed')));
} else {
toast.error('加载更早日志失败');
toast.error(t('loadOlderFailed'));
}
} finally {
if (isInitial) setLoading(false);
else setLoadingMore(false);
}
},
[fetchLogs],
[fetchLogs, t],
);
// ---- WebSocket -------------------------------------------------------
@@ -1,57 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface QQFormValue {
app_id: string;
app_secret: string;
portal_host: string;
}
interface QQFormProps {
value: QQFormValue;
onChange: (value: QQFormValue) => void;
keepSecretHint?: boolean;
}
export function QQForm({ value, onChange, keepSecretHint }: QQFormProps) {
const t = useTranslations('admin.messageGateway');
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='mg-app-id'>{t('appId')}</Label>
<Input
id='mg-app-id'
value={value.app_id}
onChange={(e) => onChange({ ...value, app_id: e.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='mg-app-secret'>{t('appSecret')}</Label>
<Input
id='mg-app-secret'
type='password'
autoComplete='off'
value={value.app_secret}
placeholder={keepSecretHint ? t('keepSecretHint') : undefined}
onChange={(e) => onChange({ ...value, app_secret: e.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='mg-portal-host'>{t('portalHost')}</Label>
<Input
id='mg-portal-host'
value={value.portal_host}
placeholder={t('portalHostPlaceholder')}
onChange={(e) => onChange({ ...value, portal_host: e.target.value })}
/>
</div>
</div>
);
}
@@ -1,54 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface TelegramFormValue {
bot_token: string;
base_url: string;
}
interface TelegramFormProps {
value: TelegramFormValue;
onChange: (value: TelegramFormValue) => void;
keepSecretHint?: boolean;
}
export function TelegramForm({
value,
onChange,
keepSecretHint,
}: TelegramFormProps) {
const t = useTranslations('admin.messageGateway');
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='mg-bot-token'>{t('botToken')}</Label>
<Input
id='mg-bot-token'
type='password'
autoComplete='off'
value={value.bot_token}
placeholder={
keepSecretHint ? t('keepSecretHint') : t('botTokenPlaceholder')
}
onChange={(e) => onChange({ ...value, bot_token: e.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='mg-base-url'>{t('baseUrl')}</Label>
<Input
id='mg-base-url'
value={value.base_url}
placeholder={t('baseUrlPlaceholder')}
onChange={(e) => onChange({ ...value, base_url: e.target.value })}
/>
</div>
</div>
);
}
@@ -1,142 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
TelegramForm,
type TelegramFormValue,
} from '../channels/telegram/form';
import { QQForm, type QQFormValue } from '../channels/qq/form';
import type { CreateMessageChannelRequest } from '@/lib/services/message-gateway';
interface AddChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateMessageChannelRequest) => void;
submitting?: boolean;
}
export function AddChannelDialog({
open,
onOpenChange,
onSubmit,
submitting,
}: AddChannelDialogProps) {
const t = useTranslations('admin.messageGateway');
const [name, setName] = React.useState('');
const [type, setType] = React.useState<'telegram' | 'qq' | ''>('');
const [telegram, setTelegram] = React.useState<TelegramFormValue>({
bot_token: '',
base_url: '',
});
const [qq, setQQ] = React.useState<QQFormValue>({
app_id: '',
app_secret: '',
portal_host: '',
});
React.useEffect(() => {
if (!open) {
setName('');
setType('');
setTelegram({ bot_token: '', base_url: '' });
setQQ({ app_id: '', app_secret: '', portal_host: '' });
}
}, [open]);
const canSubmit =
name.trim() !== '' &&
((type === 'telegram' && telegram.bot_token.trim() !== '') ||
(type === 'qq' &&
qq.app_id.trim() !== '' &&
qq.app_secret.trim() !== ''));
const handleSubmit = () => {
if (!canSubmit || !type) return;
const data: CreateMessageChannelRequest = {
name: name.trim(),
type,
enabled: true,
};
if (type === 'telegram') {
data.bot_token = telegram.bot_token.trim();
if (telegram.base_url.trim()) data.base_url = telegram.base_url.trim();
} else {
data.app_id = qq.app_id.trim();
data.app_secret = qq.app_secret.trim();
if (qq.portal_host.trim()) data.portal_host = qq.portal_host.trim();
}
onSubmit(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('addChannel')}</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='mg-name'>{t('name')}</Label>
<Input
id='mg-name'
value={name}
placeholder={t('namePlaceholder')}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label>{t('type')}</Label>
<Select
value={type}
onValueChange={(v) => setType(v as 'telegram' | 'qq')}
>
<SelectTrigger>
<SelectValue placeholder={t('selectType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='telegram'>{t('typeTelegram')}</SelectItem>
<SelectItem value='qq'>{t('typeQQ')}</SelectItem>
</SelectContent>
</Select>
</div>
{type === 'telegram' ? (
<TelegramForm value={telegram} onChange={setTelegram} />
) : null}
{type === 'qq' ? <QQForm value={qq} onChange={setQQ} /> : null}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('cancel')}
</Button>
<Button disabled={!canSubmit || submitting} onClick={handleSubmit}>
{submitting ? <Loader2 className='size-4 animate-spin' /> : null}
{t('create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,103 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Loader2, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { MessageChannel } from '@/lib/services/message-gateway';
interface ChannelCardProps {
channel: MessageChannel;
onToggle: (enabled: boolean) => void;
onDelete: () => void;
toggling?: boolean;
deleting?: boolean;
}
export function ChannelCard({
channel,
onToggle,
onDelete,
toggling,
deleting,
}: ChannelCardProps) {
const t = useTranslations('admin.messageGateway');
const [confirmOpen, setConfirmOpen] = React.useState(false);
const typeLabel = channel.type === 'qq' ? t('typeQQ') : t('typeTelegram');
return (
<div className='rounded-xl border bg-card p-4 space-y-4'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<h2 className='font-medium truncate'>{channel.name}</h2>
<Badge variant='secondary' className='mt-1'>
{typeLabel}
</Badge>
</div>
<div className='flex items-center gap-2 shrink-0'>
<span className='text-xs text-muted-foreground'>
{channel.enabled ? t('enabled') : t('disabled')}
</span>
<Switch
checked={channel.enabled}
disabled={toggling}
onCheckedChange={onToggle}
/>
</div>
</div>
<div className='flex justify-end'>
<Button
variant='ghost'
size='sm'
className='text-destructive'
disabled={deleting}
onClick={() => setConfirmOpen(true)}
>
{deleting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Trash2 className='size-4' />
)}
{t('delete')}
</Button>
</div>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteConfirmDescription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmOpen(false);
onDelete();
}}
>
{t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,142 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { motion } from 'motion/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { MessagesSquare, Plus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { AdminMessageGatewayService } from '@/lib/services/message-gateway';
import type { CreateMessageChannelRequest } from '@/lib/services/message-gateway';
import { AddChannelDialog } from './components/add-channel-dialog';
import { ChannelCard } from './components/channel-card';
export default function MessageGatewayAdminPage() {
const t = useTranslations('admin.messageGateway');
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = React.useState(false);
const channelsQuery = useQuery({
queryKey: ['admin', 'message-gateway-channels'],
queryFn: () => AdminMessageGatewayService.list(),
});
const createMutation = useMutation({
mutationFn: (data: CreateMessageChannelRequest) =>
AdminMessageGatewayService.create(data),
onSuccess: () => {
toast.success(t('createSuccess'));
queryClient.invalidateQueries({
queryKey: ['admin', 'message-gateway-channels'],
});
setAddOpen(false);
},
onError: (err: unknown) => {
toast.error(t('createFailed') + ': ' + (err as Error).message);
},
});
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
AdminMessageGatewayService.update(id, { enabled }),
onSuccess: () => {
toast.success(t('updateSuccess'));
queryClient.invalidateQueries({
queryKey: ['admin', 'message-gateway-channels'],
});
},
onError: (err: unknown) => {
toast.error(t('updateFailed') + ': ' + (err as Error).message);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => AdminMessageGatewayService.remove(id),
onSuccess: () => {
toast.success(t('deleteSuccess'));
queryClient.invalidateQueries({
queryKey: ['admin', 'message-gateway-channels'],
});
},
onError: (err: unknown) => {
toast.error(t('deleteFailed') + ': ' + (err as Error).message);
},
});
const channels = channelsQuery.data ?? [];
return (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className='w-full py-6 space-y-6'
>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start gap-2'>
<MessagesSquare className='size-5 text-primary mt-1' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
<p className='text-sm text-muted-foreground mt-1'>
{t('pageDescription')}
</p>
</div>
</div>
<Button onClick={() => setAddOpen(true)}>
<Plus className='size-4' />
{t('addChannel')}
</Button>
</div>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder />
) : channelsQuery.isError ? (
<ErrorInline message={t('loadFailed')} />
) : channels.length === 0 ? (
<div className='rounded-xl border border-dashed p-10 text-center space-y-2'>
<h2 className='font-medium'>{t('emptyTitle')}</h2>
<p className='text-sm text-muted-foreground'>
{t('emptyDescription')}
</p>
<Button variant='outline' onClick={() => setAddOpen(true)}>
<Plus className='size-4' />
{t('addChannel')}
</Button>
</div>
) : (
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-3'>
{channels.map((ch) => (
<ChannelCard
key={ch.id}
channel={ch}
toggling={toggleMutation.isPending}
deleting={
deleteMutation.isPending && deleteMutation.variables === ch.id
}
onToggle={(enabled) =>
toggleMutation.mutate({ id: ch.id, enabled })
}
onDelete={() => deleteMutation.mutate(ch.id)}
/>
))}
</div>
)}
<AddChannelDialog
open={addOpen}
onOpenChange={setAddOpen}
submitting={createMutation.isPending}
onSubmit={(data) => createMutation.mutate(data)}
/>
</motion.div>
);
}
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
@@ -67,8 +68,6 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -263,8 +262,8 @@ export function EventsTab() {
);
if (taskMeta) {
const defaultTemplate = {
title: `任务完成: ${taskMeta.name}`,
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
title: t('taskCompleteTitle', { name: taskMeta.name }),
content: t('taskCompleteContent'),
level: 'INFO',
};
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2));
@@ -650,11 +649,11 @@ export function EventsTab() {
{newEventChannels.length > 0
? newEventChannels
.map((ch) => {
if (ch === 'email') return '邮件';
if (ch === 'email') return t('emailChannel');
return ch;
})
.join(', ')
: '选择已配置推送渠道'}
: t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
@@ -680,7 +679,7 @@ export function EventsTab() {
}
}}
/>
<span>{ch === 'email' ? '邮件推送' : ch}</span>
<span>{ch === 'email' ? t('emailPush') : ch}</span>
</label>
))}
{availableChannels.length === 0 && (
@@ -699,7 +698,7 @@ export function EventsTab() {
</Label>
<Input
type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
placeholder={t('pushTargetsPlaceholder')}
value={newEventTargets}
onChange={(e) => setNewEventTargets(e.target.value)}
className='text-xs h-9'
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import {
ChevronLeft,
@@ -35,7 +36,6 @@ import {
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import {
Dialog,
@@ -4,11 +4,11 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Edit2, Loader2, Play, Plus, Settings, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -219,7 +219,7 @@ export function SettingsTab() {
? channelToken
: channelOther;
if (field.required && !value.trim()) {
toast.error(`${field.label}不能为空`);
toast.error(t('fieldRequired', { label: field.label }));
return;
}
}
@@ -516,7 +516,7 @@ export function SettingsTab() {
<>
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-1.5'>
<div className='text-xs font-semibold'>
{activeDef.name}配置说明
{t('configHelp', { name: activeDef.name })}
</div>
<p className='text-[11px] text-muted-foreground leading-relaxed'>
{activeDef.description}
@@ -0,0 +1,241 @@
import type { OptionItem } from '@/lib/services/openflare';
export type OpenFlareOpsFields = {
agent_heartbeat_interval: string;
agent_websocket_upgrade_enabled: boolean;
node_offline_threshold: string;
agent_update_repo: string;
geoip_provider: string;
server_address: string;
uptime_kuma_enabled: boolean;
uptime_kuma_url: string;
uptime_kuma_username: string;
uptime_kuma_password: string;
uptime_kuma_monitor_scope: string;
uptime_kuma_selected_sites: string;
uptime_kuma_sync_interval: string;
uptime_kuma_interval: string;
uptime_kuma_retry: string;
uptime_kuma_retry_interval: string;
uptime_kuma_timeout: string;
pages_max_package_size_mb: string;
pages_max_history_count: string;
};
export const defaultOpenFlareOpsFields: OpenFlareOpsFields = {
agent_heartbeat_interval: '3000',
agent_websocket_upgrade_enabled: true,
node_offline_threshold: '60000',
agent_update_repo: 'Rain-kl/OpenFlare',
geoip_provider: 'ipinfo',
server_address: '',
uptime_kuma_enabled: false,
uptime_kuma_url: '',
uptime_kuma_username: '',
uptime_kuma_password: '',
uptime_kuma_monitor_scope: 'all',
uptime_kuma_selected_sites: '',
uptime_kuma_sync_interval: '5',
uptime_kuma_interval: '60',
uptime_kuma_retry: '0',
uptime_kuma_retry_interval: '60',
uptime_kuma_timeout: '48',
pages_max_package_size_mb: '100',
pages_max_history_count: '20',
};
export const INSTALLER_SCRIPT_URL =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
export function optionsToMap(options: OptionItem[]) {
return options.reduce<Record<string, string>>((accumulator, option) => {
accumulator[option.key] = option.value;
return accumulator;
}, {});
}
function toBoolean(value: string | undefined, fallback: boolean) {
if (value === undefined) return fallback;
return value === 'true';
}
export function mapOptionsToOpsFields(
optionMap: Record<string, string>,
serverAddress = '',
): OpenFlareOpsFields {
return {
agent_heartbeat_interval: optionMap.agent_heartbeat_interval ?? '3000',
agent_websocket_upgrade_enabled: toBoolean(
optionMap.agent_websocket_upgrade_enabled,
true,
),
node_offline_threshold: optionMap.node_offline_threshold ?? '60000',
agent_update_repo: optionMap.agent_update_repo ?? 'Rain-kl/OpenFlare',
geoip_provider: optionMap.geoip_provider ?? 'ipinfo',
server_address: optionMap.server_address || serverAddress,
uptime_kuma_enabled: toBoolean(optionMap.uptime_kuma_enabled, false),
uptime_kuma_url: optionMap.uptime_kuma_url ?? '',
uptime_kuma_username: optionMap.uptime_kuma_username ?? '',
uptime_kuma_password: '',
uptime_kuma_monitor_scope: optionMap.uptime_kuma_monitor_scope ?? 'all',
uptime_kuma_selected_sites: optionMap.uptime_kuma_selected_sites ?? '',
uptime_kuma_sync_interval: optionMap.uptime_kuma_sync_interval ?? '5',
uptime_kuma_interval: optionMap.uptime_kuma_interval ?? '60',
uptime_kuma_retry: optionMap.uptime_kuma_retry ?? '0',
uptime_kuma_retry_interval: optionMap.uptime_kuma_retry_interval ?? '60',
uptime_kuma_timeout: optionMap.uptime_kuma_timeout ?? '48',
pages_max_package_size_mb: optionMap.pages_max_package_size_mb ?? '100',
pages_max_history_count: optionMap.pages_max_history_count ?? '20',
};
}
export type OpsMessage = (
key: string,
values?: Record<string, string | number>,
) => string;
export function formatDurationLabel(value: string, t: OpsMessage) {
const milliseconds = Number.parseInt(value, 10);
if (Number.isNaN(milliseconds)) return value;
if (milliseconds >= 60000) {
return t('duration.minutes', { count: milliseconds / 60000 });
}
return t('duration.seconds', { count: milliseconds / 1000 });
}
export function normalizeServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
export function getBrowserOrigin() {
if (typeof window === 'undefined') return '';
return normalizeServerUrl(window.location.origin);
}
export function buildDiscoveryCommand(
serverUrl: string,
discoveryToken: string,
) {
return [
`curl -fsSL ${INSTALLER_SCRIPT_URL} | bash -s -- \\`,
` --server-url ${normalizeServerUrl(serverUrl)} \\`,
` --discovery-token ${discoveryToken}`,
].join('\n');
}
export function validateAgentFields(fields: OpenFlareOpsFields, t: OpsMessage) {
const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10);
const offline = Number.parseInt(fields.node_offline_threshold, 10);
if (Number.isNaN(heartbeat) || heartbeat < 1000) {
throw new Error(t('errors.heartbeatMin'));
}
if (Number.isNaN(offline) || offline < 10000) {
throw new Error(t('errors.offlineMin'));
}
if (offline < heartbeat * 3) {
throw new Error(t('errors.offlineRatio'));
}
}
export function validateUptimeKumaFields(
fields: OpenFlareOpsFields,
t: OpsMessage,
) {
const syncInt = Number.parseInt(fields.uptime_kuma_sync_interval, 10);
const interval = Number.parseInt(fields.uptime_kuma_interval, 10);
const retry = Number.parseInt(fields.uptime_kuma_retry, 10);
const retryInt = Number.parseInt(fields.uptime_kuma_retry_interval, 10);
const timeout = Number.parseInt(fields.uptime_kuma_timeout, 10);
if (fields.uptime_kuma_enabled) {
if (!fields.uptime_kuma_url.trim()) throw new Error(t('errors.kumaUrl'));
if (!fields.uptime_kuma_username.trim())
throw new Error(t('errors.kumaUser'));
}
if (Number.isNaN(syncInt) || syncInt <= 0)
throw new Error(t('errors.syncInterval'));
if (Number.isNaN(interval) || interval <= 0)
throw new Error(t('errors.interval'));
if (Number.isNaN(retry) || retry < 0) throw new Error(t('errors.retry'));
if (Number.isNaN(retryInt) || retryInt <= 0)
throw new Error(t('errors.retryInterval'));
if (Number.isNaN(timeout) || timeout <= 0)
throw new Error(t('errors.timeout'));
}
export function agentOptionEntries(
fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validateAgentFields(fields, t);
return [
{ key: 'agent_heartbeat_interval', value: fields.agent_heartbeat_interval },
{
key: 'agent_websocket_upgrade_enabled',
value: String(fields.agent_websocket_upgrade_enabled),
},
{ key: 'node_offline_threshold', value: fields.node_offline_threshold },
{ key: 'agent_update_repo', value: fields.agent_update_repo.trim() },
{ key: 'geoip_provider', value: fields.geoip_provider },
];
}
export function uptimeKumaOptionEntries(
fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validateUptimeKumaFields(fields, t);
return [
{ key: 'uptime_kuma_enabled', value: String(fields.uptime_kuma_enabled) },
{ key: 'uptime_kuma_url', value: fields.uptime_kuma_url.trim() },
{ key: 'uptime_kuma_username', value: fields.uptime_kuma_username.trim() },
{ key: 'uptime_kuma_password', value: fields.uptime_kuma_password },
{
key: 'uptime_kuma_monitor_scope',
value: fields.uptime_kuma_monitor_scope,
},
{
key: 'uptime_kuma_selected_sites',
value: fields.uptime_kuma_selected_sites,
},
{
key: 'uptime_kuma_sync_interval',
value: fields.uptime_kuma_sync_interval,
},
{ key: 'uptime_kuma_interval', value: fields.uptime_kuma_interval },
{ key: 'uptime_kuma_retry', value: fields.uptime_kuma_retry },
{
key: 'uptime_kuma_retry_interval',
value: fields.uptime_kuma_retry_interval,
},
{ key: 'uptime_kuma_timeout', value: fields.uptime_kuma_timeout },
];
}
export function validatePagesFields(fields: OpenFlareOpsFields, t: OpsMessage) {
const packageSize = Number.parseInt(fields.pages_max_package_size_mb, 10);
const historyCount = Number.parseInt(fields.pages_max_history_count, 10);
if (Number.isNaN(packageSize) || packageSize < 1 || packageSize > 2048) {
throw new Error(t('errors.pagesSize'));
}
if (Number.isNaN(historyCount) || historyCount < 0) {
throw new Error(t('errors.pagesHistory'));
}
}
export function pagesOptionEntries(
fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validatePagesFields(fields, t);
return [
{
key: 'pages_max_package_size_mb',
value: String(Number.parseInt(fields.pages_max_package_size_mb, 10)),
},
{
key: 'pages_max_history_count',
value: String(Number.parseInt(fields.pages_max_history_count, 10)),
},
];
}
@@ -0,0 +1,696 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Copy,
ExternalLink,
Loader2,
RefreshCw,
RotateCw,
Save,
Server,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
NodeService,
OptionService,
StatusService,
UptimeKumaService,
} from '@/lib/services/openflare';
import {
agentOptionEntries,
buildDiscoveryCommand,
defaultOpenFlareOpsFields,
formatDurationLabel,
getBrowserOrigin,
mapOptionsToOpsFields,
type OpenFlareOpsFields,
optionsToMap,
pagesOptionEntries,
uptimeKumaOptionEntries,
} from './openflare-ops-utils';
import { UptimeKumaSiteSelectModal } from './uptimekuma-site-modal';
const optionsQueryKey = ['openflare', 'options'] as const;
const openflarePublicStatusQueryKey = ['openflare', 'public-status'] as const;
async function copyText(value: string) {
await navigator.clipboard.writeText(value);
}
export function OpenFlareOpsSettings() {
const t = useTranslations('openflareOps');
const queryClient = useQueryClient();
const [fields, setFields] = useState<OpenFlareOpsFields>(
defaultOpenFlareOpsFields,
);
const [savingSection, setSavingSection] = useState<string | null>(null);
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false);
const optionsQuery = useQuery({
queryKey: optionsQueryKey,
queryFn: () => OptionService.list(),
});
const statusQuery = useQuery({
queryKey: openflarePublicStatusQueryKey,
queryFn: () => StatusService.getPublicStatus(),
});
const bootstrapQuery = useQuery({
queryKey: ['openflare', 'bootstrap-token'],
queryFn: () => NodeService.getBootstrapToken(),
});
useEffect(() => {
if (!optionsQuery.data) return;
const optionMap = optionsToMap(optionsQuery.data);
const serverAddress =
optionMap.server_address ||
statusQuery.data?.server_address ||
getBrowserOrigin();
setFields(mapOptionsToOpsFields(optionMap, serverAddress));
}, [optionsQuery.data, statusQuery.data?.server_address]);
const geoIPMutation = useMutation({
mutationFn: () =>
OptionService.lookupGeoIP(fields.geoip_provider, geoIPTestIP.trim()),
});
const saveMutation = useMutation({
mutationFn: async ({
section,
entries,
}: {
section: string;
entries: Array<{ key: string; value: string }>;
}) => {
setSavingSection(section);
await OptionService.updateBatch(entries);
},
onSuccess: async () => {
toast.success(t('saved'));
await queryClient.invalidateQueries({ queryKey: optionsQueryKey });
setSavingSection(null);
},
onError: (error) => {
setSavingSection(null);
toast.error(error instanceof Error ? error.message : t('saveFailed'));
},
});
const rotateTokenMutation = useMutation({
mutationFn: () => NodeService.rotateBootstrapToken(),
onSuccess: async (data) => {
toast.success(t('tokenRotated'));
await queryClient.invalidateQueries({
queryKey: ['openflare', 'bootstrap-token'],
});
if (data.discovery_token) {
try {
await copyText(data.discovery_token);
} catch {
// ignore clipboard errors
}
}
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : t('tokenRotateFailed'),
);
},
});
const syncUptimeKumaMutation = useMutation({
mutationFn: () => UptimeKumaService.sync(),
onSuccess: () => toast.success(t('kumaSynced')),
onError: (error) =>
toast.error(error instanceof Error ? error.message : t('syncFailed')),
});
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
const discoveryCommand = useMemo(() => {
if (!fields.server_address || !discoveryToken) return '';
return buildDiscoveryCommand(fields.server_address, discoveryToken);
}, [discoveryToken, fields.server_address]);
const updateField = <K extends keyof OpenFlareOpsFields>(
key: K,
value: OpenFlareOpsFields[K],
) => {
setFields((previous) => ({ ...previous, [key]: value }));
};
const saveAgentSettings = () => {
try {
saveMutation.mutate({
section: 'agent',
entries: agentOptionEntries(fields, t),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : t('invalidParams'));
}
};
const saveUptimeKumaSettings = () => {
try {
saveMutation.mutate({
section: 'uptimekuma',
entries: uptimeKumaOptionEntries(fields, t),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : t('invalidParams'));
}
};
const savePagesSettings = () => {
try {
saveMutation.mutate({
section: 'pages',
entries: pagesOptionEntries(fields, t),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : t('invalidParams'));
}
};
if (optionsQuery.isLoading) {
return <LoadingStateWithBorder icon={Server} description={t('loading')} />;
}
if (optionsQuery.isError) {
return (
<ErrorInline
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: t('loadFailed')
}
onRetry={() => void optionsQuery.refetch()}
/>
);
}
return (
<div className='space-y-6'>
<div className='grid gap-6 xl:grid-cols-2'>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>{t('agent.title')}</CardTitle>
<CardDescription>{t('agent.description')}</CardDescription>
</div>
<Button
size='sm'
disabled={savingSection === 'agent'}
onClick={saveAgentSettings}
>
{savingSection === 'agent' ? (
<Loader2 className='size-4 animate-spin mr-1' />
) : (
<Save className='size-3.5 mr-1' />
)}
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label={t('agent.heartbeat', {
duration: formatDurationLabel(
fields.agent_heartbeat_interval,
t,
),
})}
value={fields.agent_heartbeat_interval}
type='number'
onChange={(value) =>
updateField('agent_heartbeat_interval', value)
}
/>
<FieldInput
label={t('agent.offline', {
duration: formatDurationLabel(
fields.node_offline_threshold,
t,
),
})}
value={fields.node_offline_threshold}
type='number'
onChange={(value) =>
updateField('node_offline_threshold', value)
}
/>
</div>
<ToggleRow
label={t('agent.wsUpgrade')}
description={t('agent.wsUpgradeDesc')}
checked={fields.agent_websocket_upgrade_enabled}
onChange={(value) =>
updateField('agent_websocket_upgrade_enabled', value)
}
/>
<FieldInput
label={t('agent.updateRepo')}
value={fields.agent_update_repo}
placeholder='Rain-kl/OpenFlare'
onChange={(value) => updateField('agent_update_repo', value)}
/>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>{t('geo.title')}</CardTitle>
<CardDescription>{t('geo.description')}</CardDescription>
</div>
<Button
size='sm'
disabled={savingSection === 'agent'}
onClick={saveAgentSettings}
>
{savingSection === 'agent' ? (
<Loader2 className='size-4 animate-spin mr-1' />
) : (
<Save className='size-3.5 mr-1' />
)}
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-1.5'>
<Label>{t('geo.mode')}</Label>
<Select
value={fields.geoip_provider}
onValueChange={(value) => updateField('geoip_provider', value)}
>
<SelectTrigger aria-label={t('geo.mode')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='disabled'>{t('geo.disabled')}</SelectItem>
<SelectItem value='mmdb'>MaxMind mmdb</SelectItem>
<SelectItem value='ip-api'>ip-api.com</SelectItem>
<SelectItem value='geojs'>geojs.io</SelectItem>
<SelectItem value='ipinfo'>ipinfo.io</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-end'>
<FieldInput
label={t('geo.testIp')}
value={geoIPTestIP}
onChange={setGeoIPTestIP}
placeholder='8.8.8.8'
/>
<Button
type='button'
variant='outline'
disabled={geoIPMutation.isPending}
onClick={() => geoIPMutation.mutate()}
>
{geoIPMutation.isPending ? t('geo.querying') : t('geo.query')}
</Button>
</div>
{geoIPMutation.data ? (
<div className='grid gap-2 text-sm sm:grid-cols-2'>
<InfoCell
label={t('geo.country')}
value={geoIPMutation.data.name || '—'}
/>
<InfoCell
label='ISO Code'
value={geoIPMutation.data.iso_code || '—'}
/>
</div>
) : null}
</CardContent>
</Card>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>{t('pages.title')}</CardTitle>
<CardDescription>{t('pages.description')}</CardDescription>
</div>
<Button
size='sm'
disabled={savingSection === 'pages'}
onClick={savePagesSettings}
>
{savingSection === 'pages' ? (
<Loader2 className='size-4 animate-spin mr-1' />
) : (
<Save className='size-3.5 mr-1' />
)}
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label={t('pages.maxSize')}
value={fields.pages_max_package_size_mb}
type='number'
onChange={(value) =>
updateField('pages_max_package_size_mb', value)
}
placeholder='100'
/>
<FieldInput
label={t('pages.history')}
value={fields.pages_max_history_count}
type='number'
onChange={(value) =>
updateField('pages_max_history_count', value)
}
placeholder='20'
/>
</div>
<p className='text-xs text-muted-foreground'>
{t('pages.historyHint')}
</p>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>{t('discovery.title')}</CardTitle>
<CardDescription>{t('discovery.description')}</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href='/nodes'>
<ExternalLink className='size-3.5 mr-1' />
{t('discovery.manageNodes')}
</Link>
</Button>
<Button
variant='outline'
size='sm'
disabled={rotateTokenMutation.isPending}
onClick={() => rotateTokenMutation.mutate()}
>
<RotateCw className='size-3.5 mr-1' />
{t('discovery.rotate')}
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<FieldInput
label='Server URL'
value={fields.server_address}
onChange={(value) => updateField('server_address', value)}
placeholder='https://yourdomain.com'
/>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('discovery.tokenReadonly')}
</p>
<p className='mt-2 break-all text-sm font-mono'>
{bootstrapQuery.isLoading
? t('discovery.loading')
: discoveryToken || t('discovery.notGenerated')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='discovery-command'>{t('discovery.command')}</Label>
<Textarea
id='discovery-command'
readOnly
value={discoveryCommand || t('discovery.commandHint')}
className='min-h-24 font-mono text-xs'
/>
{discoveryCommand ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
void copyText(discoveryCommand).then(() =>
toast.success(t('discovery.copied')),
)
}
>
<Copy className='size-3.5 mr-1' />
{t('discovery.copy')}
</Button>
) : null}
</div>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>{t('kuma.title')}</CardTitle>
<CardDescription>{t('kuma.description')}</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
disabled={
!fields.uptime_kuma_enabled || syncUptimeKumaMutation.isPending
}
onClick={() => syncUptimeKumaMutation.mutate()}
>
<RefreshCw className='size-3.5 mr-1' />
{t('kuma.syncNow')}
</Button>
<Button
size='sm'
disabled={savingSection === 'uptimekuma'}
onClick={saveUptimeKumaSettings}
>
{t('save')}
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<ToggleRow
label={t('kuma.enable')}
checked={fields.uptime_kuma_enabled}
onChange={(value) => updateField('uptime_kuma_enabled', value)}
/>
{fields.uptime_kuma_enabled ? (
<>
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label={t('kuma.url')}
value={fields.uptime_kuma_url}
onChange={(value) => updateField('uptime_kuma_url', value)}
placeholder='http://localhost:3001'
/>
<FieldInput
label={t('kuma.username')}
value={fields.uptime_kuma_username}
onChange={(value) =>
updateField('uptime_kuma_username', value)
}
/>
<FieldInput
label={t('kuma.password')}
value={fields.uptime_kuma_password}
type='password'
onChange={(value) =>
updateField('uptime_kuma_password', value)
}
placeholder={t('kuma.passwordKeep')}
/>
<FieldInput
label={t('kuma.syncInterval')}
value={fields.uptime_kuma_sync_interval}
type='number'
onChange={(value) =>
updateField('uptime_kuma_sync_interval', value)
}
/>
<div className='space-y-1.5'>
<Label>{t('kuma.scope')}</Label>
<Select
value={fields.uptime_kuma_monitor_scope}
onValueChange={(value) =>
updateField('uptime_kuma_monitor_scope', value)
}
>
<SelectTrigger aria-label={t('kuma.scope')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('kuma.allSites')}</SelectItem>
<SelectItem value='selected'>
{t('kuma.selectedSites')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{fields.uptime_kuma_monitor_scope === 'selected' ? (
<div className='rounded-lg border border-dashed p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>
{t('kuma.selectedLabel')}
</p>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setUptimeKumaModalOpen(true)}
>
{t('kuma.pickSites')}
</Button>
</div>
<p className='mt-2 break-all text-xs text-muted-foreground'>
{fields.uptime_kuma_selected_sites
? fields.uptime_kuma_selected_sites.split(',').join(', ')
: t('kuma.noneSelected')}
</p>
</div>
) : null}
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label={t('kuma.interval')}
value={fields.uptime_kuma_interval}
type='number'
onChange={(value) =>
updateField('uptime_kuma_interval', value)
}
/>
<FieldInput
label={t('kuma.retry')}
value={fields.uptime_kuma_retry}
type='number'
onChange={(value) => updateField('uptime_kuma_retry', value)}
/>
<FieldInput
label={t('kuma.retryInterval')}
value={fields.uptime_kuma_retry_interval}
type='number'
onChange={(value) =>
updateField('uptime_kuma_retry_interval', value)
}
/>
<FieldInput
label={t('kuma.timeout')}
value={fields.uptime_kuma_timeout}
type='number'
onChange={(value) =>
updateField('uptime_kuma_timeout', value)
}
/>
</div>
</>
) : null}
</CardContent>
</Card>
<UptimeKumaSiteSelectModal
open={uptimeKumaModalOpen}
selectedSites={
fields.uptime_kuma_selected_sites
? fields.uptime_kuma_selected_sites.split(',')
: []
}
onOpenChange={setUptimeKumaModalOpen}
onSave={(sites) =>
updateField('uptime_kuma_selected_sites', sites.join(','))
}
/>
</div>
);
}
function FieldInput({
label,
value,
onChange,
type = 'text',
placeholder,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
placeholder?: string;
}) {
return (
<div className='space-y-1.5'>
<Label htmlFor={`field-${label}`}>{label}</Label>
<Input
id={`field-${label}`}
type={type}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className='h-9 text-xs'
/>
</div>
);
}
function ToggleRow({
label,
description,
checked,
onChange,
}: {
label: string;
description?: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div className='flex items-center justify-between gap-3 rounded-lg border border-dashed px-3 py-2'>
<div>
<Label className='text-xs'>{label}</Label>
{description ? (
<p className='mt-0.5 text-[11px] text-muted-foreground'>
{description}
</p>
) : null}
</div>
<Switch checked={checked} onCheckedChange={onChange} aria-label={label} />
</div>
);
}
function InfoCell({ label, value }: { label: string; value: string }) {
return (
<div className='rounded-lg border border-dashed px-3 py-2'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<p className='mt-2 text-sm font-medium break-all'>{value}</p>
</div>
);
}
@@ -1,8 +1,8 @@
'use client';
import * as React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
Activity,
@@ -46,7 +46,7 @@ const formatNumber = (num: number | string) => {
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const t = useTranslations('admin.settings.status');
const t = useTranslations('openflareOps.status');
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false);
@@ -85,9 +85,8 @@ export function SystemStatusManager() {
setStatus(data);
prevStatusRef.current = data;
} catch (err) {
toast.error(t('fetchFailed'), {
description:
err instanceof Error ? err.message : t('unknownRequestError'),
toast.error(t('loadFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoading(false);
@@ -203,7 +202,7 @@ export function SystemStatusManager() {
<Activity className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
{t('title')}
</h1>
</div>
</div>
@@ -259,10 +258,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
{t('cards.serviceOverview')}
{t('overview')}
</CardTitle>
<CardDescription className='text-[10px]'>
{t('cards.serviceOverviewDesc')}
{t('overviewDesc')}
</CardDescription>
</div>
<Badge
@@ -274,17 +273,17 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label={t('metrics.uptime')}
label={t('uptime')}
value={status.uptime}
field='uptime'
/>
<RenderMetricRow
label={t('metrics.goroutines')}
label={t('goroutines')}
value={status.num_goroutine}
field='num_goroutine'
/>
<RenderMetricRow
label={t('metrics.heapObjects')}
label={t('heapObjects')}
value={status.heap_objects}
field='heap_objects'
/>
@@ -296,10 +295,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
{t('cards.memoryStats')}
{t('memory')}
</CardTitle>
<CardDescription className='text-[10px]'>
{t('cards.memoryStatsDesc')}
{t('memoryDesc')}
</CardDescription>
</div>
<Badge
@@ -312,22 +311,22 @@ export function SystemStatusManager() {
<CardContent className='space-y-3'>
<div className='space-y-1'>
<RenderMetricRow
label={t('metrics.alloc')}
label={t('alloc')}
value={status.alloc}
field='alloc'
/>
<RenderMetricRow
label={t('metrics.totalAlloc')}
label={t('totalAlloc')}
value={status.total_alloc}
field='total_alloc'
/>
<RenderMetricRow
label={t('metrics.sys')}
label={t('sys')}
value={status.sys}
field='sys'
/>
<RenderMetricRow
label={t('metrics.nextGc')}
label={t('nextGc')}
value={status.next_gc}
field='next_gc'
/>
@@ -335,7 +334,7 @@ export function SystemStatusManager() {
{/* 物理内存水位比例 */}
<div className='space-y-1 px-1 pt-1'>
<div className='flex justify-between text-[10px] text-muted-foreground'>
<span>{t('metrics.memoryUsagePercent')}</span>
<span>{t('usageRatio')}</span>
<span className='font-mono'>
{getMemoryUsagePercent()}%
</span>
@@ -350,10 +349,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
{t('cards.heapStack')}
{t('heapStack')}
</CardTitle>
<CardDescription className='text-[10px]'>
{t('cards.heapStackDesc')}
{t('heapStackDesc')}
</CardDescription>
</div>
<Badge
@@ -365,37 +364,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label={t('metrics.heapAlloc')}
label={t('heapAlloc')}
value={status.heap_alloc}
field='heap_alloc'
/>
<RenderMetricRow
label={t('metrics.heapSys')}
label={t('heapSys')}
value={status.heap_sys}
field='heap_sys'
/>
<RenderMetricRow
label={t('metrics.heapIdle')}
label={t('heapIdle')}
value={status.heap_idle}
field='heap_idle'
/>
<RenderMetricRow
label={t('metrics.heapInuse')}
label={t('heapInuse')}
value={status.heap_inuse}
field='heap_inuse'
/>
<RenderMetricRow
label={t('metrics.heapReleased')}
label={t('heapReleased')}
value={status.heap_released}
field='heap_released'
/>
<RenderMetricRow
label={t('metrics.stackInuse')}
label={t('stackInuse')}
value={status.stack_inuse}
field='stack_inuse'
/>
<RenderMetricRow
label={t('metrics.stackSys')}
label={t('stackSys')}
value={status.stack_sys}
field='stack_sys'
/>
@@ -407,10 +406,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
{t('cards.internalStructs')}
{t('structs')}
</CardTitle>
<CardDescription className='text-[10px]'>
{t('cards.internalStructsDesc')}
{t('structsDesc')}
</CardDescription>
</div>
<Badge
@@ -422,37 +421,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label={t('metrics.mspanInuse')}
label={t('mspanInuse')}
value={status.mspan_inuse}
field='mspan_inuse'
/>
<RenderMetricRow
label={t('metrics.mspanSys')}
label={t('mspanSys')}
value={status.mspan_sys}
field='mspan_sys'
/>
<RenderMetricRow
label={t('metrics.mcacheInuse')}
label={t('mcacheInuse')}
value={status.mcache_inuse}
field='mcache_inuse'
/>
<RenderMetricRow
label={t('metrics.mcacheSys')}
label={t('mcacheSys')}
value={status.mcache_sys}
field='mcache_sys'
/>
<RenderMetricRow
label={t('metrics.buckHashSys')}
label={t('buckHashSys')}
value={status.buck_hash_sys}
field='buck_hash_sys'
/>
<RenderMetricRow
label={t('metrics.gcSys')}
label={t('gcSys')}
value={status.gc_sys}
field='gc_sys'
/>
<RenderMetricRow
label={t('metrics.otherSys')}
label={t('otherSys')}
value={status.other_sys}
field='other_sys'
/>
@@ -464,10 +463,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
{t('cards.gcAndAlloc')}
{t('gc')}
</CardTitle>
<CardDescription className='text-[10px]'>
{t('cards.gcAndAllocDesc')}
{t('gcDesc')}
</CardDescription>
</div>
<Badge
@@ -481,39 +480,39 @@ export function SystemStatusManager() {
<div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'>
<div>
<RenderMetricRow
label={t('metrics.numGc')}
label={t('numGc')}
value={status.num_gc}
field='num_gc'
/>
<RenderMetricRow
label={t('metrics.lastGcTime')}
label={t('lastGc')}
value={status.last_gc_time}
field='last_gc_time'
/>
<RenderMetricRow
label={t('metrics.pauseTotalNs')}
label={t('pauseTotal')}
value={status.pause_total_ns}
field='pause_total_ns'
/>
<RenderMetricRow
label={t('metrics.lastPause')}
label={t('lastPause')}
value={status.last_pause}
field='last_pause'
/>
</div>
<div>
<RenderMetricRow
label={t('metrics.mallocs')}
label={t('mallocs')}
value={status.mallocs}
field='mallocs'
/>
<RenderMetricRow
label={t('metrics.frees')}
label={t('frees')}
value={status.frees}
field='frees'
/>
<RenderMetricRow
label={t('metrics.lookups')}
label={t('lookups')}
value={status.lookups}
field='lookups'
/>
@@ -0,0 +1,215 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ProxyRouteService } from '@/lib/services/openflare';
type UptimeKumaSiteSelectModalProps = {
open: boolean;
selectedSites: string[];
onOpenChange: (open: boolean) => void;
onSave: (sites: string[]) => void;
};
export function UptimeKumaSiteSelectModal({
open,
selectedSites,
onOpenChange,
onSave,
}: UptimeKumaSiteSelectModalProps) {
const t = useTranslations('openflareOps.siteModal');
const tCommon = useTranslations('common');
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
const routesQuery = useQuery({
queryKey: ['openflare', 'proxy-routes'],
queryFn: () => ProxyRouteService.list(),
enabled: open,
});
useEffect(() => {
if (!open) return;
setTempSelected(
new Set(selectedSites.map((site) => site.trim()).filter(Boolean)),
);
setSearchTerm('');
}, [open, selectedSites]);
const filteredRoutes = useMemo(() => {
const routes = routesQuery.data ?? [];
const keyword = searchTerm.trim().toLowerCase();
if (!keyword) return routes;
return routes.filter((route) => {
const domains = (route.zone_domains ?? [])
.map((item) => item.domain)
.join(' ');
return (
route.site_name.toLowerCase().includes(keyword) ||
domains.toLowerCase().includes(keyword)
);
});
}, [routesQuery.data, searchTerm]);
const toggleSite = (siteName: string) => {
setTempSelected((previous) => {
const next = new Set(previous);
if (next.has(siteName)) next.delete(siteName);
else next.add(siteName);
return next;
});
};
const handleSelectAll = () => {
setTempSelected((previous) => {
const next = new Set(previous);
filteredRoutes.forEach((route) => next.add(route.site_name));
return next;
});
};
const handleDeselectAll = () => {
setTempSelected((previous) => {
const next = new Set(previous);
filteredRoutes.forEach((route) => next.delete(route.site_name));
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-1.5'>
<Label>{t('search')}</Label>
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder={t('searchPlaceholder')}
/>
</div>
<div className='flex flex-wrap gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleSelectAll}
>
{t('selectFiltered')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={handleDeselectAll}
>
{t('clearFiltered')}
</Button>
</div>
<div className='max-h-72 overflow-y-auto rounded-lg border'>
{routesQuery.isLoading ? (
<p className='p-4 text-sm text-muted-foreground'>
{t('loading')}
</p>
) : routesQuery.isError ? (
<p className='p-4 text-sm text-destructive'>
{routesQuery.error instanceof Error
? routesQuery.error.message
: t('loadFailed')}
</p>
) : filteredRoutes.length === 0 ? (
<p className='p-4 text-sm text-muted-foreground'>
{t('noMatch')}
</p>
) : (
<table className='w-full text-left text-sm'>
<thead className='sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground'>
<tr>
<th className='w-12 px-3 py-2'>{t('select')}</th>
<th className='px-3 py-2'>{t('siteName')}</th>
<th className='px-3 py-2'>{t('primaryDomain')}</th>
</tr>
</thead>
<tbody className='divide-y'>
{filteredRoutes.map((route) => {
const checked = tempSelected.has(route.site_name);
return (
<tr
key={route.id}
className='cursor-pointer hover:bg-muted/30'
onClick={() => toggleSite(route.site_name)}
>
<td
className='px-3 py-2'
onClick={(event) => event.stopPropagation()}
>
<input
type='checkbox'
checked={checked}
onChange={() => toggleSite(route.site_name)}
className='size-4 rounded border-input'
/>
</td>
<td className='px-3 py-2 font-medium'>
{route.site_name}
</td>
<td className='px-3 py-2 text-muted-foreground'>
{(route.zone_domains ?? [])
.map((item) => item.domain)
.join(', ') || '—'}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<p className='text-right text-xs text-muted-foreground'>
{t('selectedCount', { count: tempSelected.size })}
</p>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{tCommon('cancel')}
</Button>
<Button
type='button'
onClick={() => {
onSave(Array.from(tempSelected));
onOpenChange(false);
}}
>
{t('save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -64,6 +64,14 @@ const SystemStatusManager = dynamic(
{ loading: () => tabFallback },
);
const OpenFlareOpsSettings = dynamic(
() =>
import('./components/openflare-ops').then(
(mod) => mod.OpenFlareOpsSettings,
),
{ loading: () => tabFallback },
);
function systemConfigMap(configs: SystemConfig[]) {
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
accumulator[config.key] = config;
@@ -80,6 +88,7 @@ export function AdminSettingsPageClient() {
const activeTab = useMemo(() => {
const rawTab = searchParams.get('tab');
const validTabs = [
'openflare-ops',
'security',
'operation',
'system',
@@ -87,7 +96,7 @@ export function AdminSettingsPageClient() {
'status',
'info',
];
return rawTab && validTabs.includes(rawTab) ? rawTab : 'security';
return rawTab && validTabs.includes(rawTab) ? rawTab : 'openflare-ops';
}, [searchParams]);
const handleTabChange = (value: string) => {
@@ -140,6 +149,12 @@ export function AdminSettingsPageClient() {
className='w-full'
>
<TabsList variant='line' className='w-fit inline-flex gap-8 mb-6'>
<TabsTrigger
value='openflare-ops'
className='px-0 pb-2 text-xs font-semibold'
>
{t('tabs.openflare')}
</TabsTrigger>
<TabsTrigger
value='security'
className='px-0 pb-2 text-xs font-semibold'
@@ -202,6 +217,12 @@ export function AdminSettingsPageClient() {
<TabsContent value='info' className='focus-visible:outline-none'>
<InfoTab />
</TabsContent>
<TabsContent
value='openflare-ops'
className='focus-visible:outline-none'
>
<OpenFlareOpsSettings />
</TabsContent>
</Tabs>
</motion.div>
);
@@ -199,7 +199,7 @@ export function TaskExecutionsManager() {
}
>
<SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' />
<SelectValue placeholder={t('colStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('allStatuses')}</SelectItem>
@@ -214,7 +214,7 @@ export function TaskExecutionsManager() {
onValueChange={handleTaskTypeChange}
>
<SelectTrigger size='sm' className='w-[180px]'>
<SelectValue placeholder='任务类型' />
<SelectValue placeholder={t('taskTypeFilter')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('allTasks')}</SelectItem>
@@ -361,7 +361,7 @@ export function TaskExecutionsManager() {
<SheetDescription>
{selectedExecution?.task_name ||
selectedExecution?.task_type ||
'任务记录'}
t('executionRecord')}
</SheetDescription>
</SheetHeader>
@@ -9,6 +9,13 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Spinner } from '@/components/ui/spinner';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
@@ -25,13 +32,6 @@ import {
Layers,
Play,
} from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
DispatchTaskRequest,
@@ -81,7 +81,7 @@ const TASK_CONFIGS: Record<
gradient:
'from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500',
},
logs_db_switch: {
of_log_db_switch: {
icon: Database,
color: 'text-teal-600 dark:text-teal-400',
gradient:
@@ -89,10 +89,10 @@ const TASK_CONFIGS: Record<
},
};
const LOG_DATABASE_LABELS: Record<string, string> = {
postgres: 'PostgreSQL(主库)',
sqlite: 'SQLite(主库)',
clickhouse: 'ClickHouse',
const LOG_DATABASE_LABEL_KEYS: Record<string, string> = {
postgres: 'dbPostgresPrimary',
sqlite: 'dbSqlitePrimary',
clickhouse: 'dbClickhouse',
};
const DEFAULT_TASK_CONFIG = {
@@ -109,6 +109,7 @@ function DatePickerWithTime({
date: Date | undefined;
setDate: (date: Date | undefined) => void;
}) {
const t = useTranslations('admin.tasks');
const timeString = date ? format(date, 'HH:mm:ss') : '';
const handleDateSelect = (newDate: Date | undefined) => {
@@ -164,7 +165,11 @@ function DatePickerWithTime({
)}
>
<CalendarIcon className='mr-1 size-3' />
{date ? format(date, 'yyyy-MM-dd') : <span>选择日期</span>}
{date ? (
format(date, 'yyyy-MM-dd')
) : (
<span>{t('selectDate')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
@@ -224,6 +229,7 @@ export function TaskManager() {
null,
);
// 日志库状态用于「切换日志数据库」卡片与 target 下拉;获取失败不阻塞任务列表。
const fetchLogDbStatus = useCallback(async () => {
try {
const data = await services.adminStatus.getLogDatabaseStatus();
@@ -405,20 +411,27 @@ export function TaskManager() {
</div>
</div>
{task.type === 'logs_db_switch' && logDbStatus && (
{task.type === 'of_log_db_switch' && logDbStatus && (
<div className='pt-3 mt-3 border-t border-border/50 space-y-1.5'>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
日志主库
{t('logPrimaryDb')}
</span>
<span className='text-[10px] font-mono text-foreground truncate'>
{LOG_DATABASE_LABELS[logDbStatus.active_database] ||
logDbStatus.active_database}
{LOG_DATABASE_LABEL_KEYS[
logDbStatus.active_database
]
? t(
LOG_DATABASE_LABEL_KEYS[
logDbStatus.active_database
],
)
: logDbStatus.active_database}
</span>
</div>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
保留天数
{t('retentionDays')}
</span>
<span className='text-[10px] font-mono text-muted-foreground truncate'>
{retentionSummary || '-'}
@@ -426,7 +439,7 @@ export function TaskManager() {
</div>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
迁移状态
{t('migrationStatus')}
</span>
<Badge
variant={
@@ -437,8 +450,8 @@ export function TaskManager() {
className='text-[10px] h-5 px-1.5'
>
{logDbStatus.migration === 'migrating'
? '迁移中'
: '空闲'}
? t('migrating')
: t('idle')}
</Badge>
</div>
</div>
@@ -534,98 +547,106 @@ export function TaskManager() {
}
return (
<div className='space-y-4'>
{targetTask.params.map((param) => (
<div key={param.name} className='grid gap-2'>
<Label
htmlFor={`param-${param.name}`}
className='flex items-center gap-1'
>
{param.label}
{param.required && (
<span className='text-destructive font-bold'>*</span>
)}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
placeholder={param.placeholder}
className='text-xs min-h-[80px]'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
) : param.type === 'boolean' ? (
<div className='flex items-center gap-2 pt-1 h-9'>
<Switch
{targetTask.params.map((param) => {
const isSwitchTarget =
param.name === 'target' &&
getSelectedTaskMeta()?.type === 'of_log_db_switch';
return (
<div key={param.name} className='grid gap-2'>
<Label
htmlFor={`param-${param.name}`}
className='flex items-center gap-1'
>
{param.label}
{param.required && (
<span className='text-destructive font-bold'>
*
</span>
)}
</Label>
{param.type === 'text' ? (
<Textarea
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) =>
placeholder={param.placeholder}
className='text-xs min-h-[80px]'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: checked ? 'true' : 'false',
[param.name]: e.target.value,
}))
}
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : param.name === 'target' &&
getSelectedTaskMeta()?.type === 'logs_db_switch' &&
availableLogDbTargets.length > 0 ? (
<Select
value={paramValues[param.name] || ''}
onValueChange={(value) =>
setParamValues((prev) => ({
...prev,
[param.name]: value,
}))
}
disabled={dispatching}
>
<SelectTrigger
id={`param-${param.name}`}
className='w-full text-xs'
size='sm'
) : param.type === 'boolean' ? (
<div className='flex items-center gap-2 pt-1 h-9'>
<Switch
id={`param-${param.name}`}
checked={paramValues[param.name] === 'true'}
onCheckedChange={(checked) =>
setParamValues((prev) => ({
...prev,
[param.name]: checked ? 'true' : 'false',
}))
}
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : isSwitchTarget &&
availableLogDbTargets.length > 0 ? (
<Select
value={paramValues[param.name] || ''}
onValueChange={(value) =>
setParamValues((prev) => ({
...prev,
[param.name]: value,
}))
}
disabled={dispatching}
>
<SelectValue placeholder='选择目标日志库...' />
</SelectTrigger>
<SelectContent>
{availableLogDbTargets.map((target) => (
<SelectItem key={target} value={target}>
{LOG_DATABASE_LABELS[target] || target}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className='text-xs'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
)}
{param.description && (
<p className='text-[10px] text-muted-foreground'>
{param.description}
</p>
)}
</div>
))}
<SelectTrigger
id={`param-${param.name}`}
className='w-full text-xs'
size='sm'
>
<SelectValue placeholder={t('selectLogDb')} />
</SelectTrigger>
<SelectContent>
{availableLogDbTargets.map((target) => (
<SelectItem key={target} value={target}>
{LOG_DATABASE_LABEL_KEYS[target]
? t(LOG_DATABASE_LABEL_KEYS[target])
: target}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`param-${param.name}`}
type={param.type === 'number' ? 'number' : 'text'}
placeholder={param.placeholder}
className='text-xs'
value={paramValues[param.name] || ''}
onChange={(e) =>
setParamValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
)}
{param.description && (
<p className='text-[10px] text-muted-foreground'>
{param.description}
</p>
)}
</div>
);
})}
</div>
);
})()}
@@ -284,7 +284,9 @@ export function EditUserModal({
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteConfirmDesc', { name: user.nickname || user.username })}
{t('deleteConfirmDesc', {
name: user.nickname || user.username,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>