mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user