Merge remote-tracking branch 'origin/main'

# Conflicts:
#	AGENTS.md
#	backend/docs/docs.go
#	backend/docs/swagger.json
#	backend/docs/swagger.yaml
#	frontend/components/providers/title-updater.tsx
#	frontend/messages/fragments/admin.en.json
#	frontend/messages/fragments/admin.zh-CN.json
#	frontend/proxy.ts
This commit is contained in:
ryan
2026-09-03 09:16:22 +08:00
1065 changed files with 271858 additions and 9600 deletions
+4 -9
View File
@@ -1,16 +1,11 @@
# 前端 URL
WAVELET_FRONTEND_URL=http://localhost:3000
# 后端 URL(服务端与 next.config rewrites 使用,不会暴露到浏览器)
WAVELET_BACKEND_URL=http://localhost:8000
# 浏览器侧 API base URL
# - 本地 `pnpm dev`:务必留空或不设置,请求走同源 `/api/*`,由 Next rewrites 反代到 WAVELET_BACKEND_URL
# - 静态导出 / 浏览器需直连后端(如部分 WebSocket 场景)时再设置,例如:
# NEXT_PUBLIC_WAVELET_BACKEND_URL=http://localhost:8000
# 后端 URL
WAVELET_BACKEND_URL=http://localhost:3000
# Session Cookie
WAVELET_SESSION_COOKIE_NAME=wavelet_session_id
WAVELET_SESSION_COOKIE_NAME=openflare_session_id
# 环境
NODE_ENV=development
NODE_ENV=production
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+7 -11
View File
@@ -1,21 +1,17 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
getApiSections,
DOCS_LAST_UPDATED,
} from '@/components/common/docs/api';
export default async function ApiDocPage() {
const t = await getTranslations('docs');
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function ApiDocPage() {
return (
<LegalPageLayout
title={t('api.title')}
title='API 接口文档'
lastUpdated={DOCS_LAST_UPDATED}
sections={getApiSections(t)}
sections={apiSections}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
{t('api.description')}
OpenFlare 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
</p>
}
/>
+6 -8
View File
@@ -1,19 +1,17 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { getHowToUseSections } from '@/components/common/docs/how-to-use';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default async function HowToUsePage() {
const t = await getTranslations('docs');
export default function HowToUsePage() {
return (
<LegalPageLayout
title={t('howToUse.title')}
title='平台使用指南'
lastUpdated={DOCS_LAST_UPDATED}
sections={getHowToUseSections(t)}
sections={howToUseSections}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
{t('howToUse.description')}
欢迎使用 OpenFlare
管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
</p>
}
/>
@@ -1,21 +1,18 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { getPrivacySections } from '@/components/common/docs/privacy';
import { privacySections } from '@/components/common/docs/privacy';
import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms';
export default async function PrivacyPolicyPage() {
const t = await getTranslations('docs');
export default function PrivacyPolicyPage() {
return (
<LegalPageLayout
title={t('privacy.title')}
title='隐私政策 (Privacy)'
lastUpdated={TERMS_LAST_UPDATED}
sections={getPrivacySections(t)}
sections={privacySections}
description={
<span>
{t('privacy.description')}
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
<br className='hidden md:block' />
{t('privacy.descriptionRights')}
以及您享有的相关权利。
</span>
}
/>
@@ -1,19 +1,16 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
getTermsSections,
termsSections,
} from '@/components/common/docs/terms';
export default async function TermsOfServicePage() {
const t = await getTranslations('docs');
export default function TermsOfServicePage() {
return (
<LegalPageLayout
title={t('terms.title')}
title='服务协议 (Terms)'
lastUpdated={TERMS_LAST_UPDATED}
sections={getTermsSections(t)}
description={<span>{t('terms.description')}</span>}
sections={termsSections}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
/>
);
}
@@ -0,0 +1,174 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { AccessLogItem } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { formatBytes } from '@/lib/utils/metrics';
import { cacheOutcomeLabel, resolveCacheOutcome } from './access-log-utils';
function DetailField({
label,
value,
mono,
full,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
full?: boolean;
}) {
return (
<div className={full ? 'sm:col-span-2 space-y-1' : 'space-y-1'}>
<p className='text-[11px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<div
className={`text-sm break-all ${mono ? 'font-mono text-xs' : 'text-foreground'}`}
>
{value}
</div>
</div>
);
}
function formatRequestTimeMs(value: number | undefined | null) {
if (value == null || !Number.isFinite(value) || value < 0) {
return '—';
}
if (value < 1000) {
return `${Math.round(value)} ms`;
}
return `${(value / 1000).toFixed(2)} s`;
}
export function AccessLogDetailDialog({
open,
item,
onOpenChange,
}: {
open: boolean;
item: AccessLogItem | null;
onOpenChange: (open: boolean) => void;
}) {
const t = useTranslations('accessLogs');
// Keep last selected item while the dialog closes to avoid empty-state flash.
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
if (item && item !== displayItem) {
setDisplayItem(item);
}
const activeItem = item ?? displayItem;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>{t('detail.title')}</DialogTitle>
<DialogDescription>{t('detail.description')}</DialogDescription>
</DialogHeader>
{activeItem ? (
<div className='grid gap-4 sm:grid-cols-2'>
<DetailField
label={t('detail.logId')}
value={activeItem.id || '—'}
mono
/>
<DetailField
label={t('detail.requestTime')}
value={formatDateTime(activeItem.logged_at)}
/>
<DetailField
label={t('detail.storedAt')}
value={
activeItem.created_at
? formatDateTime(activeItem.created_at)
: '—'
}
/>
<DetailField
label={t('detail.node')}
value={activeItem.node_name || activeItem.node_id || '—'}
/>
<DetailField
label={t('detail.nodeId')}
value={activeItem.node_id || '—'}
mono
/>
<DetailField
label={t('detail.clientIp')}
value={activeItem.remote_addr || '—'}
mono
/>
<DetailField
label={t('detail.region')}
value={activeItem.region || '—'}
/>
<DetailField
label={t('detail.host')}
value={activeItem.host || '—'}
/>
<DetailField
label={t('detail.status')}
value={
<Badge variant='outline' className='text-[10px]'>
{activeItem.status_code}
</Badge>
}
/>
<DetailField
label={t('detail.cache')}
value={
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline' className='text-[10px]'>
{cacheOutcomeLabel(
resolveCacheOutcome(activeItem.cache_status),
(key) => t(key),
)}
</Badge>
<span className='font-mono text-xs text-muted-foreground'>
{activeItem.cache_status || '—'}
</span>
</div>
}
/>
<DetailField
label={t('detail.bytesSent')}
value={formatBytes(activeItem.bytes_sent ?? 0)}
/>
<DetailField
label={t('detail.requestLength')}
value={formatBytes(activeItem.request_length ?? 0)}
/>
<DetailField
label={t('detail.duration')}
value={formatRequestTimeMs(activeItem.request_time_ms)}
/>
<DetailField
label={t('detail.path')}
value={activeItem.path || '—'}
full
mono
/>
<DetailField
label='User-Agent'
value={activeItem.user_agent || '—'}
full
mono
/>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,306 @@
'use client';
import { useState } from 'react';
import { format } from 'date-fns';
import { CalendarIcon, ChevronDown, Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { SearchDraft } from './access-log-utils';
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
interface AccessLogFiltersProps {
draft: SearchDraft;
pageSize: number;
onDraftChange: (draft: SearchDraft) => void;
onPageSizeChange: (pageSize: number) => void;
onSearch: () => void;
onReset: () => void;
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) =>
String(i).padStart(2, '0'),
);
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, i) =>
String(i).padStart(2, '0'),
);
function FilterField({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>{label}</p>
{children}
</div>
);
}
function TimeSelect({
value,
options,
onValueChange,
}: {
value: string;
options: string[];
onValueChange: (value: string) => void;
}) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className='h-8 w-18 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** shadcn 日期 + 时间选择器,value 为 ISO 字符串。 */
function DateTimePicker({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const t = useTranslations('accessLogs.filters');
const [open, setOpen] = useState(false);
const current = value ? new Date(value) : undefined;
const applyDate = (date: Date | undefined) => {
if (!date) return;
const next = value ? new Date(value) : new Date();
next.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
onChange(next.toISOString());
};
const applyTime = (hh: string, mm: string) => {
const next = value ? new Date(value) : new Date();
next.setHours(Number(hh), Number(mm), 0, 0);
onChange(next.toISOString());
};
const hour = current ? String(current.getHours()).padStart(2, '0') : '00';
const minute = current ? String(current.getMinutes()).padStart(2, '0') : '00';
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
className='h-9 w-full justify-start gap-2 px-3 text-xs font-normal'
>
<CalendarIcon className='size-3.5 text-muted-foreground' />
{current ? (
format(current, 'yyyy-MM-dd HH:mm')
) : (
<span className='text-muted-foreground'>{t('pickTime')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar mode='single' selected={current} onSelect={applyDate} />
<div className='flex items-center gap-1.5 border-t p-2'>
<TimeSelect
value={hour}
options={HOUR_OPTIONS}
onValueChange={(h) => applyTime(h, minute)}
/>
<span className='text-xs text-muted-foreground'>:</span>
<TimeSelect
value={minute}
options={MINUTE_OPTIONS}
onValueChange={(m) => applyTime(hour, m)}
/>
</div>
</PopoverContent>
</Popover>
);
}
export function AccessLogFilters({
draft,
pageSize,
onDraftChange,
onPageSizeChange,
onSearch,
onReset,
}: AccessLogFiltersProps) {
const t = useTranslations('accessLogs.filters');
const [moreOpen, setMoreOpen] = useState(false);
return (
<div className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
<FilterField label={t('remoteAddr')}>
<Input
value={draft.remoteAddr}
onChange={(e) =>
onDraftChange({ ...draft, remoteAddr: e.target.value })
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder={t('remoteAddrPlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
<FilterField label={t('host')}>
<Input
value={draft.host}
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder={t('hostPlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
<FilterField label={t('statusCode')}>
<Input
value={draft.statusCode}
onChange={(e) =>
onDraftChange({
...draft,
statusCode: e.target.value.replace(/\D/g, '').slice(0, 3),
})
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder={t('statusCodePlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
</div>
<Collapsible open={moreOpen} onOpenChange={setMoreOpen}>
<CollapsibleTrigger asChild>
<Button
variant='ghost'
size='sm'
className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground'
>
{t('more')}
<ChevronDown
className={`size-3.5 transition-transform ${
moreOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='grid gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3'>
<FilterField label={t('nodeId')}>
<div className='relative'>
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
<Input
value={draft.nodeId}
onChange={(e) =>
onDraftChange({ ...draft, nodeId: e.target.value })
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder={t('nodeIdPlaceholder')}
className='pl-8 h-9 text-xs'
/>
</div>
</FilterField>
<FilterField label={t('path')}>
<Input
value={draft.path}
onChange={(e) =>
onDraftChange({ ...draft, path: e.target.value })
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder={t('pathPlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
<FilterField label={t('timeRange')}>
<div className='grid grid-cols-2 gap-2'>
<DateTimePicker
value={draft.since}
onChange={(value) =>
onDraftChange({ ...draft, since: value })
}
/>
<DateTimePicker
value={draft.until}
onChange={(value) =>
onDraftChange({ ...draft, until: value })
}
/>
</div>
</FilterField>
</div>
</CollapsibleContent>
</Collapsible>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
<p className='text-xs font-medium text-muted-foreground'>
{t('pageSize')}
</p>
<Select
value={String(pageSize)}
onValueChange={(value) =>
onPageSizeChange(Number.parseInt(value, 10))
}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{t('pageSizeItem', { count: option })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Button size='sm' onClick={onSearch}>
{t('apply')}
</Button>
<Button variant='outline' size='sm' onClick={onReset}>
{t('clear')}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,126 @@
export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
nodeId: string;
remoteAddr: string;
host: string;
path: string;
statusCode: string;
since: string;
until: string;
};
export type OverviewRangeHours = 24 | 168 | 360 | 720;
/** 限流分析等短窗口场景:仅 24 小时 / 3 天 */
export type RateLimitRangeHours = 24 | 72;
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
export const OVERVIEW_RANGE_OPTIONS: {
value: OverviewRangeHours;
labelKey: `range.h${OverviewRangeHours}`;
}[] = [
{ value: 24, labelKey: 'range.h24' },
{ value: 168, labelKey: 'range.h168' },
{ value: 360, labelKey: 'range.h360' },
{ value: 720, labelKey: 'range.h720' },
];
export const RATE_LIMIT_RANGE_OPTIONS: {
value: RateLimitRangeHours;
labelKey: 'range.h24' | 'range.h72';
}[] = [
{ value: 24, labelKey: 'range.h24' },
{ value: 72, labelKey: 'range.h72' },
];
export const DETAIL_SORT_OPTIONS = [
{ value: 'logged_at:desc', labelKey: 'sort.logged_at:desc' },
{ value: 'logged_at:asc', labelKey: 'sort.logged_at:asc' },
{ value: 'status_code:desc', labelKey: 'sort.status_code:desc' },
{ value: 'status_code:asc', labelKey: 'sort.status_code:asc' },
{ value: 'remote_addr:asc', labelKey: 'sort.remote_addr:asc' },
{ value: 'remote_addr:desc', labelKey: 'sort.remote_addr:desc' },
] as const;
export const IP_SORT_OPTIONS = [
{ value: 'total_requests:desc', labelKey: 'sort.total_requests:desc' },
{ value: 'total_requests:asc', labelKey: 'sort.total_requests:asc' },
{ value: 'request_length:desc', labelKey: 'sort.request_length:desc' },
{ value: 'request_length:asc', labelKey: 'sort.request_length:asc' },
{ value: 'bytes_sent:desc', labelKey: 'sort.bytes_sent:desc' },
{ value: 'bytes_sent:asc', labelKey: 'sort.bytes_sent:asc' },
{ value: 'success_ratio:desc', labelKey: 'sort.success_ratio:desc' },
{ value: 'success_ratio:asc', labelKey: 'sort.success_ratio:asc' },
{ value: 'last_seen_at:desc', labelKey: 'sort.last_seen_at:desc' },
{ value: 'last_seen_at:asc', labelKey: 'sort.last_seen_at:asc' },
] as const;
export function parseSortValue(value: string) {
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
return {
sortBy,
sortOrder: sortOrder === 'asc' ? ('asc' as const) : ('desc' as const),
};
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
notation: value >= 10000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
}
export function formatOverviewRangeHint(
hours: number,
t: (
key: 'rangeHint.hours24' | 'rangeHint.days' | 'rangeHint.hours',
values?: { count: number },
) => string,
) {
if (hours <= 24) return t('rangeHint.hours24');
if (hours % 24 === 0) return t('rangeHint.days', { count: hours / 24 });
return t('rangeHint.hours', { count: hours });
}
export function formatOverviewTrendLabel(value: string, hours: number) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
if (hours <= 24) {
return `${hour}:00`;
}
return `${month}/${day} ${hour}:00`;
}
export type CacheOutcome = 'hit' | 'origin' | 'uncached';
export function resolveCacheOutcome(
cacheStatus: string | undefined | null,
): CacheOutcome {
const status = (cacheStatus ?? '').trim().toUpperCase();
if (
status === 'HIT' ||
status === 'STALE' ||
status === 'REVALIDATED' ||
status === 'UPDATING'
) {
return 'hit';
}
if (status === 'MISS' || status === 'EXPIRED') {
return 'origin';
}
return 'uncached';
}
export function cacheOutcomeLabel(
outcome: CacheOutcome,
t: (key: `cache.${CacheOutcome}`) => string,
) {
return t(`cache.${outcome}`);
}
@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const CLEANUP_PRESETS = [3, 7, 30];
interface CleanupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (retentionDays: number) => void;
loading: boolean;
}
export function CleanupDialog({
open,
onOpenChange,
onConfirm,
loading,
}: CleanupDialogProps) {
const t = useTranslations('accessLogs.cleanupDialog');
const tCommon = useTranslations('common');
const [mode, setMode] = useState<string>('7');
const [customDays, setCustomDays] = useState('14');
const [error, setError] = useState<string | null>(null);
const handleConfirm = () => {
const retentionDays =
mode === 'custom'
? Number.parseInt(customDays, 10)
: Number.parseInt(mode, 10);
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
setError(t('invalidDays'));
return;
}
setError(null);
onConfirm(retentionDays);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('title')}</AlertDialogTitle>
<AlertDialogDescription>{t('description')}</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-3'>
<div className='space-y-1.5'>
<Label>{t('policy')}</Label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLEANUP_PRESETS.map((days) => (
<SelectItem key={days} value={String(days)}>
{t('keepDays', { days })}
</SelectItem>
))}
<SelectItem value='custom'>{t('custom')}</SelectItem>
</SelectContent>
</Select>
</div>
{mode === 'custom' ? (
<div className='space-y-1.5'>
<Label htmlFor='customDays'>{t('customDays')}</Label>
<Input
id='customDays'
type='number'
min={1}
value={customDays}
onChange={(e) => setCustomDays(e.target.value)}
disabled={loading}
/>
</div>
) : null}
{error ? <p className='text-xs text-destructive'>{error}</p> : null}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{tCommon('cancel')}
</AlertDialogCancel>
<Button
variant='destructive'
onClick={handleConfirm}
disabled={loading}
>
{loading ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
{t('cleaning')}
</>
) : (
t('confirm')
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,248 @@
'use client';
import { useState } from 'react';
import { Eye } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { AccessLogItem, AccessLogList } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { AccessLogDetailDialog } from './access-log-detail-dialog';
import {
cacheOutcomeLabel,
DETAIL_SORT_OPTIONS,
resolveCacheOutcome,
type CacheOutcome,
} from './access-log-utils';
function cacheOutcomeVariant(
outcome: CacheOutcome,
): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (outcome) {
case 'hit':
return 'default';
case 'origin':
return 'secondary';
default:
return 'outline';
}
}
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('accessLogs.detail');
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>
{t('page', { page: page + 1 })}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
{t('prev')}
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
{t('next')}
</Button>
</div>
</div>
);
}
export function DetailTab({
data,
loading,
error,
page,
detailSort,
onDetailSortChange,
onRetry,
onPrevPage,
onNextPage,
isFetching,
}: {
data?: AccessLogList;
loading: boolean;
error: Error | null;
page: number;
detailSort: string;
onDetailSortChange: (value: string) => void;
onRetry: () => void;
onPrevPage: () => void;
onNextPage: () => void;
isFetching: boolean;
}) {
const t = useTranslations('accessLogs');
const [selected, setSelected] = useState<AccessLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
return (
<>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>{t('detail.listTitle')}</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || t('loadFailed')}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title={t('detail.empty')} />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>{t('detail.time')}</TableHead>
<TableHead className='text-xs'>{t('detail.node')}</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>{t('detail.host')}</TableHead>
<TableHead className='text-xs'>{t('detail.path')}</TableHead>
<TableHead className='text-xs'>{t('detail.cache')}</TableHead>
<TableHead className='text-xs'>{t('detail.status')}</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => {
const outcome = resolveCacheOutcome(item.cache_status);
return (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs'>
<div className='flex flex-col gap-0.5'>
<span className='font-mono'>{item.remote_addr}</span>
{item.region ? (
<span className='text-[10px] text-muted-foreground'>
{item.region}
</span>
) : null}
</div>
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge
variant={cacheOutcomeVariant(outcome)}
className='text-[10px]'
title={item.cache_status || undefined}
>
{cacheOutcomeLabel(outcome, (key) => t(key))}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{t('detail.view')}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
<AccessLogDetailDialog
open={detailOpen}
item={selected}
onOpenChange={setDetailOpen}
/>
</>
);
}
@@ -0,0 +1,621 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { RankChart } from '@/components/data/rank-chart';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
AccessLogService,
type DistributionItem,
type WAFIPGroup,
WafService,
} from '@/lib/services/openflare';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
OVERVIEW_RANGE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
function useTrendChartConfig() {
const t = useTranslations('accessLogs');
return {
requests: { label: t('ip.requests'), color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
}
function resolveBucketMinutes(hours: number) {
if (hours <= 24) return 30;
return 60;
}
function groupsContainingIp(groups: WAFIPGroup[], ip: string) {
const target = ip.trim();
if (!target) return [];
return groups.filter((group) =>
(group.ip_list ?? []).some((entry) => entry.trim() === target),
);
}
function toRankItems(items: DistributionItem[] | undefined) {
return (items ?? []).map((item) => ({
label: item.key,
value: item.value,
}));
}
/** Clamp analysis/trend window to API limits (1–720 hours). */
function clampAnalysisHours(hours: number): number {
if (!Number.isFinite(hours) || hours <= 0) return 24;
return Math.min(720, Math.max(1, Math.round(hours)));
}
function isOverviewPreset(hours: number): hours is OverviewRangeHours {
return hours === 24 || hours === 168 || hours === 360 || hours === 720;
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className='rounded-lg border border-dashed px-3 py-2.5'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<p className='mt-1 text-lg font-semibold tracking-tight'>{value}</p>
</div>
);
}
function MiniRankCard({
title,
items,
color,
}: {
title: string;
items: { label: string; value: number }[];
color: string;
}) {
const t = useTranslations('accessLogs.analysis');
return (
<div className='rounded-lg border border-dashed p-3'>
<p className='mb-2 text-sm font-medium'>{title}</p>
<RankChart
items={items}
color={color}
className='!h-[220px]'
emptyMessage={t('emptySeries', { title })}
/>
</div>
);
}
function AddToIPGroupPanel({
ip,
open,
onClose,
}: {
ip: string;
open: boolean;
onClose: () => void;
}) {
const t = useTranslations('accessLogs.analysis');
const queryClient = useQueryClient();
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const groupsQuery = useQuery({
queryKey: ['openflare', 'waf', 'ip-groups'],
queryFn: () => WafService.listIPGroups(),
enabled: open,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const matchedGroups = useMemo(
() => groupsContainingIp(groups, ip),
[groups, ip],
);
const manualGroups = useMemo(
() =>
groups.filter(
(group) => group.type === 'manual' && group.enabled !== false,
),
[groups],
);
const addableGroups = useMemo(
() =>
manualGroups.filter(
(group) =>
!(group.ip_list ?? []).some((entry) => entry.trim() === ip.trim()),
),
[manualGroups, ip],
);
const updateMutation = useMutation({
mutationFn: async ({
group,
nextList,
}: {
group: WAFIPGroup;
nextList: string[];
}) =>
WafService.updateIPGroup(
group.id,
buildIPGroupPayloadFromGroup(group, nextList),
),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'ip-groups'],
});
},
});
const handleAdd = async () => {
const groupId = Number.parseInt(selectedGroupId, 10);
const group = addableGroups.find((item) => item.id === groupId);
if (!group) {
toast.error(t('selectGroup'));
return;
}
try {
await updateMutation.mutateAsync({
group,
nextList: [...(group.ip_list ?? []), ip.trim()],
});
toast.success(t('added', { ip, name: group.name }));
setSelectedGroupId('');
} catch (error) {
toast.error(error instanceof Error ? error.message : t('addFailed'));
}
};
const handleRemove = async (group: WAFIPGroup) => {
try {
await updateMutation.mutateAsync({
group,
nextList: (group.ip_list ?? []).filter(
(entry) => entry.trim() !== ip.trim(),
),
});
toast.success(t('removed', { name: group.name, ip }));
} catch (error) {
toast.error(error instanceof Error ? error.message : t('removeFailed'));
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
setSelectedGroupId('');
onClose();
}
}}
>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle>{t('joinTitle')}</DialogTitle>
<DialogDescription>
{t('targetIp')}
<span className='font-mono text-foreground'>{ip}</span>
</DialogDescription>
</DialogHeader>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder title={t('loadingGroups')} />
) : groupsQuery.isError ? (
<ErrorInline
message={
groupsQuery.error instanceof Error
? groupsQuery.error.message
: t('loadGroupsFailed')
}
onRetry={() => void groupsQuery.refetch()}
/>
) : (
<div className='space-y-4'>
{matchedGroups.length > 0 ? (
<div className='space-y-2 rounded-lg border border-dashed p-3'>
<p className='text-sm font-medium text-foreground'>
{t('alreadyIn')}
</p>
<p className='text-xs text-muted-foreground'>
{t('alreadyInHint')}
</p>
<div className='space-y-2'>
{matchedGroups.map((group) => (
<div
key={group.id}
className='flex items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{group.name}
</p>
<p className='text-[11px] text-muted-foreground'>
{t('entryCount', {
type: group.type,
count: group.ip_list?.length ?? 0,
})}
</p>
</div>
{group.type === 'manual' ? (
<Button
variant='outline'
size='sm'
className='h-8 shrink-0 text-destructive'
disabled={updateMutation.isPending}
onClick={() => void handleRemove(group)}
>
{updateMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<>
<Trash2 className='mr-1 size-3.5' />
{t('delete')}
</>
)}
</Button>
) : (
<Badge variant='outline' className='text-[10px]'>
{t('cannotDelete')}
</Badge>
)}
</div>
))}
</div>
</div>
) : (
<p className='text-sm text-muted-foreground'>{t('notInAny')}</p>
)}
<div className='space-y-2'>
<p className='text-sm font-medium'>{t('addToOther')}</p>
{addableGroups.length === 0 ? (
<p className='text-xs text-muted-foreground'>
{t('noWritable')}
</p>
) : (
<Select
value={selectedGroupId}
onValueChange={setSelectedGroupId}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue placeholder={t('selectManual')} />
</SelectTrigger>
<SelectContent>
{addableGroups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={onClose}>
{t('close')}
</Button>
<Button
onClick={() => void handleAdd()}
disabled={
!selectedGroupId ||
updateMutation.isPending ||
addableGroups.length === 0
}
>
{updateMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
{t('processing')}
</>
) : (
t('joinSelected')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function IpAnalysisPanel({
ip,
enabled,
initialHours = 24,
}: {
ip: string;
enabled: boolean;
/**
* Exact analysis window in hours (1–720), aligned with list filter duration.
* Remount with key={ip} when switching IPs so this re-initializes.
*/
initialHours?: number;
}) {
const t = useTranslations('accessLogs');
const trendChartConfig = useTrendChartConfig();
const [ipGroupOpen, setIpGroupOpen] = useState(false);
const [rangeHours, setRangeHours] = useState(() =>
clampAnalysisHours(initialHours),
);
const bucketMinutes = resolveBucketMinutes(rangeHours);
const rangeHint = isOverviewPreset(rangeHours)
? formatOverviewRangeHint(rangeHours, (key, values) => t(key, values))
: t('analysis.recentHours', { hours: rangeHours });
const trendQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-trend',
ip,
rangeHours,
bucketMinutes,
],
queryFn: () =>
AccessLogService.getIPTrend({
remote_addr: ip,
hours: rangeHours,
bucket_minutes: bucketMinutes,
}),
enabled: enabled && ip !== '',
});
const analysisQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours],
queryFn: () =>
AccessLogService.getIPAnalysis({
remote_addr: ip,
hours: rangeHours,
}),
enabled: enabled && ip !== '',
});
const trendChartData = useMemo(() => {
return (trendQuery.data?.points ?? []).map((point) => ({
label: formatOverviewTrendLabel(point.bucket_started_at, rangeHours),
requests: point.request_count,
}));
}, [rangeHours, trendQuery.data?.points]);
const analysis = analysisQuery.data;
const isLoadingIP = trendQuery.isLoading || analysisQuery.isLoading;
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
if (!ip) {
return <EmptyStateWithBorder description={t('analysis.invalidIp')} />;
}
return (
<>
<div className='space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => setIpGroupOpen(true)}
>
<ShieldPlus className='mr-1 size-3.5' />
{t('analysis.addToGroup')}
</Button>
<ToggleGroup
type='single'
value={isOverviewPreset(rangeHours) ? String(rangeHours) : ''}
onValueChange={(value) => {
if (!value) return;
setRangeHours(clampAnalysisHours(Number.parseInt(value, 10)));
}}
variant='outline'
size='sm'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{t(option.labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{isLoadingIP ? (
<LoadingStateWithBorder title={t('analysis.loading')} />
) : (
<div className='space-y-4'>
{analysisQuery.isError ? (
<ErrorInline
message={
analysisQuery.error instanceof Error
? analysisQuery.error.message
: t('analysis.loadFailed')
}
onRetry={() => void analysisQuery.refetch()}
/>
) : analysis ? (
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<MetricCard
label={t('analysis.totalRequests')}
value={formatCompactNumber(analysis.summary.total_requests)}
/>
<MetricCard
label={t('analysis.errors')}
value={formatCompactNumber(analysis.summary.error_count)}
/>
<MetricCard
label={t('analysis.bandwidth')}
value={formatBytes(analysis.summary.bandwidth_served)}
/>
<MetricCard
label={t('analysis.received')}
value={formatBytes(analysis.summary.bytes_received)}
/>
<MetricCard
label={t('analysis.uniqueHosts')}
value={formatCompactNumber(analysis.summary.unique_hosts)}
/>
<MetricCard
label={t('analysis.uniquePaths')}
value={formatCompactNumber(analysis.summary.unique_paths)}
/>
</div>
) : null}
<div className='space-y-3 rounded-lg border border-dashed p-4'>
<div className='flex items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>{t('analysis.trend')}</p>
<p className='text-xs text-muted-foreground'>
{t('analysis.trendMeta', {
ip,
range: rangeHint,
minutes: bucketMinutes,
})}
</p>
</div>
<Button
size='sm'
variant='ghost'
disabled={isFetchingIP}
onClick={() => {
void trendQuery.refetch();
void analysisQuery.refetch();
}}
>
{t('analysis.refresh')}
</Button>
</div>
{trendQuery.isError ? (
<ErrorInline
message={
trendQuery.error instanceof Error
? trendQuery.error.message
: t('analysis.trendFailed')
}
onRetry={() => void trendQuery.refetch()}
/>
) : trendChartData.every((point) => point.requests === 0) ? (
<EmptyStateWithBorder
description={t('analysis.trendEmpty', { range: rangeHint })}
/>
) : (
<ChartContainer
config={trendChartConfig}
className='h-56 w-full'
>
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='label'
tickLine={false}
axisLine={false}
fontSize={10}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
width={40}
tickFormatter={(value) =>
formatCompactNumber(Number(value))
}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type='monotone'
dataKey='requests'
stroke='var(--color-requests)'
fill='var(--color-requests)'
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
</div>
{analysis ? (
<div className='grid gap-3 md:grid-cols-2'>
<MiniRankCard
title='Top Paths'
color='#a78bfa'
items={toRankItems(analysis.top_paths)}
/>
<MiniRankCard
title='Top Hosts'
color='#34d399'
items={toRankItems(analysis.top_hosts)}
/>
<MiniRankCard
title='Status Codes'
color='#f59e0b'
items={toRankItems(analysis.status_codes)}
/>
<MiniRankCard
title='Top User-Agents'
color='#818cf8'
items={toRankItems(analysis.top_user_agents)}
/>
<MiniRankCard
title='Device Types'
color='#38bdf8'
items={toRankItems(analysis.device_types)}
/>
<MiniRankCard
title='Top Browsers'
color='#22c55e'
items={toRankItems(analysis.top_browsers)}
/>
</div>
) : null}
</div>
)}
</div>
<AddToIPGroupPanel
ip={ip}
open={ipGroupOpen}
onClose={() => setIpGroupOpen(false)}
/>
</>
);
}
@@ -0,0 +1,60 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IpAnalysisPanel } from './ip-analysis-panel';
export function IpDetailDialog({
open,
remoteAddr,
region,
initialHours,
onOpenChange,
}: {
open: boolean;
remoteAddr: string | null;
region?: string;
initialHours?: number;
onOpenChange: (open: boolean) => void;
}) {
const [displayAddr, setDisplayAddr] = useState<string | null>(remoteAddr);
const [displayRegion, setDisplayRegion] = useState(region);
if (remoteAddr && remoteAddr !== displayAddr) {
setDisplayAddr(remoteAddr);
setDisplayRegion(region);
}
const t = useTranslations('accessLogs.ip');
const ip = remoteAddr ?? displayAddr ?? '';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>{t('dialogTitle')}</DialogTitle>
<DialogDescription>
<span className='font-mono text-foreground'>{ip || '—'}</span>
{displayRegion ? (
<span className='text-muted-foreground'> · {displayRegion}</span>
) : null}
{t('dialogDesc')}
</DialogDescription>
</DialogHeader>
<IpAnalysisPanel
key={ip}
ip={ip}
enabled={open && ip !== ''}
initialHours={initialHours}
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,526 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Eye } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type {
AccessLogIPSummaryItem,
AccessLogIPSummaryList,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewRangeHint,
IP_SORT_OPTIONS,
OVERVIEW_RANGE_OPTIONS,
PAGE_SIZE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
import { IpDetailDialog } from './ip-detail-dialog';
export function toLocalInputValue(date: Date) {
const pad = (n: number) => `${n}`.padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** Default local range ending now, spanning `hours`. */
export function defaultLocalRangeForHours(hours: number) {
const until = new Date();
const since = new Date(until.getTime() - hours * 3_600_000);
return {
since: toLocalInputValue(since),
until: toLocalInputValue(until),
};
}
function localInputToRFC3339(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString();
}
const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
/** Validate custom local datetime-local range; returns error message or null. */
export function validateCustomTimeRange(
sinceLocal: string,
untilLocal: string,
t?: (
key: 'ip.needRange' | 'ip.invalidTime' | 'ip.endAfterStart' | 'ip.maxRange',
) => string,
): string | null {
if (!sinceLocal.trim() || !untilLocal.trim()) {
return t ? t('ip.needRange') : 'needRange';
}
const sinceMs = new Date(sinceLocal).getTime();
const untilMs = new Date(untilLocal).getTime();
if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
return t ? t('ip.invalidTime') : 'invalidTime';
}
if (untilMs <= sinceMs) {
return t ? t('ip.endAfterStart') : 'endAfterStart';
}
if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
return t ? t('ip.maxRange') : 'maxRange';
}
return null;
}
function formatRatio(ratio: number) {
if (!Number.isFinite(ratio) || ratio < 0) return '0%';
return `${(ratio * 100).toFixed(1)}%`;
}
/** Exact hours for analysis API (1–720), matching list window duration. */
export function resolveAnalysisHours(input: {
timeMode: IpTabTimeMode;
hours: OverviewRangeHours;
customSince: string;
customUntil: string;
}): number {
if (input.timeMode === 'custom' && input.customSince && input.customUntil) {
const ms =
new Date(input.customUntil).getTime() -
new Date(input.customSince).getTime();
if (Number.isFinite(ms) && ms > 0) {
return Math.min(720, Math.max(1, Math.ceil(ms / 3_600_000)));
}
}
return input.hours;
}
function PaginationBar({
page,
hasMore,
loading,
totalIp,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
totalIp?: number;
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('accessLogs.ip');
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>
{t('page', { page: page + 1 })}
{typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
{t('prev')}
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
{t('next')}
</Button>
</div>
</div>
);
}
export type IpTabTimeMode = 'preset' | 'custom';
export function IpTab({
data,
loading,
error,
page,
pageSize,
sort,
hours,
timeMode,
customSince,
customUntil,
onHoursChange,
onTimeModeChange,
onApplyCustomRange,
onPageSizeChange,
onSortChange,
onRetry,
onPrevPage,
onNextPage,
isFetching,
}: {
data?: AccessLogIPSummaryList;
loading: boolean;
error: Error | null;
page: number;
pageSize: number;
sort: string;
hours: OverviewRangeHours;
timeMode: IpTabTimeMode;
/** Applied custom range (local datetime-local strings). */
customSince: string;
customUntil: string;
onHoursChange: (hours: OverviewRangeHours) => void;
onTimeModeChange: (mode: IpTabTimeMode) => void;
/** Commit validated custom range for list query. */
onApplyCustomRange: (since: string, until: string) => void;
onPageSizeChange: (size: number) => void;
onSortChange: (value: string) => void;
onRetry: () => void;
onPrevPage: () => void;
onNextPage: () => void;
isFetching: boolean;
}) {
const t = useTranslations('accessLogs');
const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [draftSince, setDraftSince] = useState(customSince);
const [draftUntil, setDraftUntil] = useState(customUntil);
const [customError, setCustomError] = useState<string | null>(null);
const defaultCustomRange = useMemo(
() => defaultLocalRangeForHours(hours),
[hours],
);
useEffect(() => {
if (timeMode !== 'custom') {
setCustomError(null);
return;
}
// Sync draft from applied range when entering custom or after apply.
setDraftSince(customSince || defaultCustomRange.since);
setDraftUntil(customUntil || defaultCustomRange.until);
setCustomError(null);
}, [timeMode, customSince, customUntil, defaultCustomRange]);
const analysisHours = resolveAnalysisHours({
timeMode,
hours,
customSince,
customUntil,
});
const rangeHint =
timeMode === 'custom' && customSince && customUntil
? t('ip.customRange')
: timeMode === 'custom'
? t('ip.customPending')
: formatOverviewRangeHint(hours, (key, values) => t(key, values));
const handleApplyCustom = () => {
const message = validateCustomTimeRange(draftSince, draftUntil, (key) =>
t(key),
);
if (message) {
setCustomError(message);
return;
}
setCustomError(null);
onApplyCustomRange(draftSince, draftUntil);
};
return (
<>
<div className='space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='space-y-1'>
<p className='text-sm font-medium'>{t('ip.timeRange')}</p>
<p className='text-xs text-muted-foreground'>
{t('ip.current', { range: rangeHint })}
{data?.total_ip != null
? t('ip.total', { count: data.total_ip })
: ''}
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<ToggleGroup
type='single'
value={timeMode === 'preset' ? String(hours) : ''}
onValueChange={(value) => {
if (!value) return;
onTimeModeChange('preset');
onHoursChange(
Number.parseInt(value, 10) as OverviewRangeHours,
);
}}
variant='outline'
size='sm'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{t(option.labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
<Button
size='sm'
variant={timeMode === 'custom' ? 'default' : 'outline'}
className='h-8 text-xs'
onClick={() => onTimeModeChange('custom')}
>
{t('ip.custom')}
</Button>
</div>
</div>
{timeMode === 'custom' ? (
<div className='space-y-2'>
<div className='flex flex-wrap items-end gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>
{t('ip.start')}
</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
value={draftSince || defaultCustomRange.since}
onChange={(e) => {
setDraftSince(e.target.value);
setCustomError(null);
}}
/>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>{t('ip.end')}</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
value={draftUntil || defaultCustomRange.until}
onChange={(e) => {
setDraftUntil(e.target.value);
setCustomError(null);
}}
/>
</div>
<Button
size='sm'
className='h-9 text-xs'
onClick={handleApplyCustom}
>
{t('ip.apply')}
</Button>
</div>
{customError ? (
<p className='text-xs text-destructive'>{customError}</p>
) : (
<p className='text-xs text-muted-foreground'>
{t('ip.applyHint')}
</p>
)}
</div>
) : null}
<div className='flex flex-wrap items-center gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>{t('ip.sort')}</p>
<Select value={sort} onValueChange={onSortChange}>
<SelectTrigger className='h-8 w-48 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>
{t('ip.pageSize')}
</p>
<Select
value={String(pageSize)}
onValueChange={(value) =>
onPageSizeChange(Number.parseInt(value, 10))
}
>
<SelectTrigger className='h-8 w-24 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>{t('ip.title')}</p>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || t('loadFailed')}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title={t('ip.empty')} />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>{t('ip.region')}</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.requests')}
</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.successRatio')}
</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.inbound')}
</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.outbound')}
</TableHead>
<TableHead className='text-xs'>{t('ip.lastSeen')}</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className='border-dashed'>
<TableCell className='font-mono text-xs'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{item.region || '—'}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatCompactNumber(item.total_requests)}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
<span
title={`${item.success_2xx_count}/${item.total_requests}`}
>
{formatRatio(item.success_ratio)}
</span>
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatBytes(item.bytes_received)}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatBytes(item.bytes_sent)}
</TableCell>
<TableCell className='text-xs'>
{formatDateTime(item.last_seen_at)}
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{t('ip.view')}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
totalIp={data?.total_ip}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
</div>
<IpDetailDialog
open={detailOpen}
remoteAddr={selected?.remote_addr ?? null}
region={selected?.region}
initialHours={analysisHours}
onOpenChange={setDetailOpen}
/>
</>
);
}
/** Build list API time params from IP tab state (applied values only). */
export function buildIpSummaryTimeParams(input: {
timeMode: IpTabTimeMode;
hours: OverviewRangeHours;
customSince: string;
customUntil: string;
}): { hours?: number; since?: string; until?: string } | null {
if (input.timeMode === 'custom') {
if (validateCustomTimeRange(input.customSince, input.customUntil)) {
return null;
}
const since = localInputToRFC3339(input.customSince);
const until = localInputToRFC3339(input.customUntil);
if (!since || !until) {
return null;
}
return { since, until };
}
return { hours: input.hours };
}
@@ -0,0 +1,442 @@
'use client';
import { useTranslations } from 'next-intl';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { Cell, Pie, PieChart } from 'recharts';
import { RankCard } from '@/components/data/rank-card';
import { TrendChart } from '@/components/data/trend-chart';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import type {
AccessLogOverview,
DistributionItem,
} from '@/lib/services/openflare';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
type OverviewRangeHours,
} from './access-log-utils';
import { OverviewToolbar } from './overview-toolbar';
const DEVICE_COLORS = [
'#38bdf8',
'#34d399',
'#f59e0b',
'#a78bfa',
'#f472b6',
'#94a3b8',
];
function SparklineMetricCard({
title,
value,
hint,
color,
fillColor,
labels,
values,
valueFormatter,
}: {
title: string;
value: string;
hint: string;
color: string;
fillColor: string;
labels: string[];
values: number[];
valueFormatter?: (value: number) => string;
}) {
const option = useMemo<EChartsOption>(
() => ({
animationDuration: 400,
grid: {
left: 0,
right: 0,
top: 8,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
boundaryGap: false,
data: labels,
},
yAxis: {
type: 'value',
show: false,
min: 0,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const items = Array.isArray(params) ? params : [];
const item = items[0] as
| { axisValueLabel?: string; value?: number }
| undefined;
if (!item) return '';
const raw = typeof item.value === 'number' ? item.value : 0;
const formatted = valueFormatter
? valueFormatter(raw)
: formatCompactNumber(raw);
return `${item.axisValueLabel ?? ''}<br/>${formatted}`;
},
},
series: [
{
type: 'line',
data: values,
smooth: true,
showSymbol: false,
lineStyle: {
color,
width: 2,
},
areaStyle: {
color: fillColor,
},
},
],
}),
[color, fillColor, labels, valueFormatter, values],
);
return (
<Card className='border-dashed shadow-none'>
<CardContent className='p-4'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 space-y-1'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{title}
</p>
<p className='text-2xl font-semibold tracking-tight'>{value}</p>
<p className='text-[11px] text-muted-foreground'>{hint}</p>
</div>
<div className='h-16 w-28 shrink-0 sm:w-36'>
{labels.length > 0 ? (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: '100%', width: '100%' }}
/>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}
function toRankItems(items: DistributionItem[] | undefined) {
return (items ?? []).map((item) => ({
label: item.key,
value: item.value,
}));
}
function PieDistributionCard({
title,
description,
items,
emptyMessage,
}: {
title: string;
description: string;
items: DistributionItem[];
emptyMessage: string;
}) {
const chartData = useMemo(
() =>
items.map((item, index) => ({
name: item.key,
value: item.value,
fill: DEVICE_COLORS[index % DEVICE_COLORS.length],
})),
[items],
);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
chartData.forEach((item) => {
config[item.name] = {
label: item.name,
color: item.fill,
};
});
return config;
}, [chartData]);
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold text-foreground'>
{title}
</CardTitle>
<CardDescription className='text-xs text-muted-foreground'>
{description}
</CardDescription>
</CardHeader>
<CardContent className='pt-0'>
{chartData.length === 0 ? (
<div className='flex h-[300px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
{emptyMessage}
</div>
) : (
<ChartContainer
config={chartConfig}
className='mx-auto h-[300px] w-full'
>
<PieChart>
<Pie
data={chartData}
dataKey='value'
nameKey='name'
cx='50%'
cy='46%'
innerRadius={50}
outerRadius={80}
paddingAngle={2}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => (
<>
<span className='text-muted-foreground'>{name}</span>
<span className='ml-auto font-mono font-medium tabular-nums text-foreground'>
{formatCompactNumber(Number(value ?? 0))}
</span>
</>
)}
/>
}
/>
<ChartLegend
content={
<ChartLegendContent
nameKey='name'
className='flex-wrap justify-center gap-x-4 gap-y-1 pt-2 text-[11px]'
/>
}
/>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}
export function OverviewTab({
data,
loading,
error,
hours,
hosts,
onHoursChange,
onHostsChange,
onRetry,
}: {
data?: AccessLogOverview;
loading: boolean;
error: Error | null;
hours: OverviewRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
onRetry: () => void;
}) {
const t = useTranslations('accessLogs.overview');
return (
<div className='space-y-6'>
<OverviewToolbar
hours={hours}
hosts={hosts}
onHoursChange={onHoursChange}
onHostsChange={onHostsChange}
/>
{loading ? (
<LoadingStateWithBorder
title={t('loadingTitle')}
description={t('loadingDesc')}
/>
) : error ? (
<ErrorInline
message={error.message || t('loadFailed')}
onRetry={onRetry}
/>
) : !data ? (
<EmptyStateWithBorder
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<OverviewContent data={data} hours={hours} />
)}
</div>
);
}
function OverviewContent({
data,
hours,
}: {
data: AccessLogOverview;
hours: number;
}) {
const t = useTranslations('accessLogs');
const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours),
);
const requestValues = data.trends.requests.map((point) => point.value);
const visitValues = data.trends.visits.map((point) => point.value);
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
const hint = formatOverviewRangeHint(hours, (key, values) => t(key, values));
return (
<>
<div className='grid gap-4 lg:grid-cols-3'>
<SparklineMetricCard
title='Total Requests'
value={formatCompactNumber(data.summary.total_requests)}
hint={hint}
color='#f59e0b'
fillColor='rgba(245, 158, 11, 0.18)'
labels={requestLabels}
values={requestValues}
/>
<SparklineMetricCard
title='Total Visits'
value={formatCompactNumber(data.summary.total_visits)}
hint={t('overview.uniqueVisitors', { hint })}
color='#38bdf8'
fillColor='rgba(56, 189, 248, 0.16)'
labels={requestLabels}
values={visitValues}
/>
<SparklineMetricCard
title='Bandwidth Served'
value={formatBytes(data.summary.bandwidth_served)}
hint={t('overview.served', { hint })}
color='#34d399'
fillColor='rgba(52, 211, 153, 0.16)'
labels={requestLabels}
values={bandwidthValues}
valueFormatter={formatBytes}
/>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
Requests over time
</CardTitle>
<CardDescription className='text-xs'>
{t('overview.requestsHint')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={requestLabels}
height={280}
showSummary={false}
series={[
{
label: t('overview.requests'),
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: requestValues,
},
]}
/>
</CardContent>
</Card>
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title='Requests by device type'
description={t('overview.deviceDesc')}
items={data.device_types ?? []}
emptyMessage={t('overview.deviceEmpty')}
/>
<PieDistributionCard
title='Status code'
description={t('overview.statusDesc')}
items={data.status_codes ?? []}
emptyMessage={t('overview.statusEmpty')}
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
description={t('overview.topPathsDesc')}
items={toRankItems(data.top_paths)}
/>
<RankCard
title='Top Hosts'
description={t('overview.topHostsDesc')}
items={toRankItems(data.top_hosts)}
/>
<RankCard
title='Top IPs'
description={t('overview.topIpsDesc')}
items={toRankItems(data.top_ips)}
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top browsers'
description={t('overview.browsersDesc')}
items={toRankItems(data.top_browsers)}
/>
<RankCard
title='Top Operating System'
description={t('overview.osDesc')}
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title='Top User-Agent'
description={t('overview.uaDesc')}
items={toRankItems(data.top_user_agents)}
/>
</div>
</>
);
}
@@ -0,0 +1,370 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { ZoneService, zoneQueryKey } from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
import {
OVERVIEW_RANGE_OPTIONS,
RATE_LIMIT_RANGE_OPTIONS,
type OverviewRangeHours,
type RateLimitRangeHours,
} from './access-log-utils';
type ManagedZoneDomains = {
zoneId: number;
zoneName: string;
domains: string[];
};
function useManagedZoneDomains(enabled: boolean) {
return useQuery({
queryKey: [...zoneQueryKey, 'zone-domain-tree'],
enabled,
staleTime: 60_000,
queryFn: async (): Promise<ManagedZoneDomains[]> => {
const zones = await ZoneService.list();
const overviews = await Promise.all(
zones.map((zone) => ZoneService.getOverview(zone.id)),
);
return overviews
.map((overview) => {
const domainSet = new Set<string>();
for (const item of overview.domains ?? []) {
const domain = item.domain?.trim();
if (domain) domainSet.add(domain);
}
return {
zoneId: overview.zone.id,
zoneName: overview.zone.domain || `Zone #${overview.zone.id}`,
domains: Array.from(domainSet).sort((a, b) => a.localeCompare(b)),
};
})
.filter((zone) => zone.domains.length > 0)
.sort((a, b) => a.zoneName.localeCompare(b.zoneName));
},
});
}
export function OverviewHostFilter({
hosts,
onHostsChange,
}: {
hosts: string[];
onHostsChange: (hosts: string[]) => void;
}) {
const t = useTranslations('accessLogs.toolbar');
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
{},
);
const zonesQuery = useManagedZoneDomains(open);
const zones = useMemo(() => zonesQuery.data ?? [], [zonesQuery.data]);
const selectedSet = useMemo(() => new Set(hosts), [hosts]);
const filteredZones = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return zones;
return zones
.map((zone) => {
const zoneMatched = zone.zoneName.toLowerCase().includes(q);
const domains = zoneMatched
? zone.domains
: zone.domains.filter((domain) => domain.toLowerCase().includes(q));
return { ...zone, domains };
})
.filter((zone) => zone.domains.length > 0);
}, [query, zones]);
useEffect(() => {
if (!open || zones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
let changed = false;
for (const zone of zones) {
if (next[zone.zoneId] === undefined) {
next[zone.zoneId] = true;
changed = true;
}
}
return changed ? next : prev;
});
}, [open, zones]);
useEffect(() => {
const q = query.trim();
if (!q || filteredZones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
for (const zone of filteredZones) {
next[zone.zoneId] = true;
}
return next;
});
}, [filteredZones, query]);
const toggleHost = (domain: string, checked: boolean | 'indeterminate') => {
if (checked === true) {
if (selectedSet.has(domain)) return;
onHostsChange([...hosts, domain]);
return;
}
onHostsChange(hosts.filter((item) => item !== domain));
};
const toggleZone = (
zoneDomains: string[],
checked: boolean | 'indeterminate',
) => {
if (checked === true) {
const next = new Set(hosts);
for (const domain of zoneDomains) next.add(domain);
onHostsChange(Array.from(next));
return;
}
onHostsChange(hosts.filter((item) => !zoneDomains.includes(item)));
};
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery('');
}}
>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
size='icon'
className={cn(
'size-8 shrink-0',
hosts.length > 0 ? 'border-primary text-primary' : undefined,
)}
title={
hosts.length > 0
? t('hostsFiltered', { count: hosts.length })
: t('filterByHost')
}
aria-label={
hosts.length > 0
? t('hostsFiltered', { count: hosts.length })
: t('filterByHost')
}
>
<Filter className='size-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>{t('filterHosts')}</p>
{hosts.length > 0 ? (
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 px-2 text-xs'
onClick={() => onHostsChange([])}
>
<X className='mr-1 size-3' />
{t('clear')}
</Button>
) : null}
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('searchPlaceholder')}
className='h-8 text-xs'
/>
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
{zonesQuery.isLoading ? (
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
{t('loadingHosts')}
</div>
) : zonesQuery.isError ? (
<div className='space-y-2 py-2'>
<p className='text-xs text-destructive'>{t('loadHostsFailed')}</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => void zonesQuery.refetch()}
>
{t('retry')}
</Button>
</div>
) : filteredZones.length === 0 ? (
<p className='py-6 text-center text-xs text-muted-foreground'>
{zones.length === 0 ? t('noRegistered') : t('noMatch')}
</p>
) : (
filteredZones.map((zone) => {
const selectedCount = zone.domains.filter((domain) =>
selectedSet.has(domain),
).length;
const allSelected = selectedCount === zone.domains.length;
const partialSelected =
selectedCount > 0 && selectedCount < zone.domains.length;
const expanded = expandedZones[zone.zoneId] ?? true;
return (
<Collapsible
key={zone.zoneId}
open={expanded}
onOpenChange={(next) =>
setExpandedZones((prev) => ({
...prev,
[zone.zoneId]: next,
}))
}
className='rounded-md border border-dashed'
>
<div className='flex items-center gap-1 px-2 py-1.5'>
<Checkbox
checked={
allSelected
? true
: partialSelected
? 'indeterminate'
: false
}
onCheckedChange={(checked) =>
toggleZone(zone.domains, checked)
}
aria-label={t('selectZone', { name: zone.zoneName })}
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 py-0.5 text-left text-xs font-medium hover:bg-accent'
>
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-transform',
expanded ? 'rotate-0' : '-rotate-90',
)}
/>
<span className='min-w-0 flex-1 truncate'>
{zone.zoneName}
</span>
<span className='text-[10px] text-muted-foreground'>
{selectedCount}/{zone.domains.length}
</span>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='space-y-0.5 border-t border-dashed px-2 py-1.5'>
{zone.domains.map((domain) => {
const selected = selectedSet.has(domain);
return (
<label
key={domain}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent',
selected ? 'bg-accent/50' : undefined,
)}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) =>
toggleHost(domain, checked)
}
aria-label={t('selectHost', { name: domain })}
/>
<span className='min-w-0 flex-1 truncate font-mono'>
{domain}
</span>
</label>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
export function OverviewToolbar({
hours,
hosts,
onHoursChange,
onHostsChange,
rangeOptions,
}: {
hours: OverviewRangeHours | RateLimitRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
rangeOptions?: ReadonlyArray<{ value: number; label: string }>;
}) {
const t = useTranslations('accessLogs');
const options =
rangeOptions ??
OVERVIEW_RANGE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}));
return (
<div className='flex flex-wrap items-center justify-end gap-2'>
{hosts.length > 0 ? (
<Badge variant='secondary' className='max-w-[260px] truncate'>
{hosts.length === 1
? hosts[0]
: t('toolbar.selectedCount', { count: hosts.length })}
</Badge>
) : null}
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
<ToggleGroup
type='single'
value={String(hours)}
onValueChange={(value) => {
if (!value) return;
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
}}
variant='outline'
size='sm'
className='justify-end'
>
{options.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export { RATE_LIMIT_RANGE_OPTIONS };
+361
View File
@@ -0,0 +1,361 @@
'use client';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccessLogService } from '@/lib/services/openflare';
import { AccessLogFilters } from './components/access-log-filters';
import {
type AccessLogTab,
type OverviewRangeHours,
parseSortValue,
type SearchDraft,
} from './components/access-log-utils';
import { CleanupDialog } from './components/cleanup-dialog';
import { DetailTab } from './components/detail-tab';
import {
buildIpSummaryTimeParams,
defaultLocalRangeForHours,
IpTab,
type IpTabTimeMode,
} from './components/ip-tab';
import { OverviewTab } from './components/overview-tab';
const emptyDraft: SearchDraft = {
nodeId: '',
remoteAddr: '',
host: '',
path: '',
statusCode: '',
since: '',
until: '',
};
function resolveTab(value: string | null): AccessLogTab {
if (value === 'list') return 'list';
if (value === 'ips') return 'ips';
return 'overview';
}
function AccessLogsPageContent() {
const t = useTranslations('accessLogs');
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const tab = resolveTab(searchParams.get('tab'));
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(0);
const [detailSort, setDetailSort] = useState('logged_at:desc');
const [ipSort, setIpSort] = useState('total_requests:desc');
const [ipPageSize, setIpPageSize] = useState(20);
const [ipPage, setIpPage] = useState(0);
const [ipHours, setIpHours] = useState<OverviewRangeHours>(168);
const [ipTimeMode, setIpTimeMode] = useState<IpTabTimeMode>('preset');
const [ipCustomSince, setIpCustomSince] = useState('');
const [ipCustomUntil, setIpCustomUntil] = useState('');
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
const [overviewHosts, setOverviewHosts] = useState<string[]>([]);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
const ipSortState = parseSortValue(ipSort);
const ipTimeParams = buildIpSummaryTimeParams({
timeMode: ipTimeMode,
hours: ipHours,
customSince: ipCustomSince,
customUntil: ipCustomUntil,
});
const ipQueryEnabled = tab === 'ips' && ipTimeParams != null;
const handleTabChange = (value: string) => {
const next = resolveTab(value);
router.replace(
next === 'overview' ? '/access-logs' : `/access-logs?tab=${next}`,
);
};
const overviewQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'overview',
overviewHours,
overviewHosts,
],
queryFn: () =>
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
}),
enabled: tab === 'overview',
});
const listQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'list',
filters,
page,
pageSize,
detailSort,
],
queryFn: () =>
AccessLogService.list({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
status_code: filters.statusCode
? Number.parseInt(filters.statusCode, 10)
: undefined,
since: filters.since || undefined,
until: filters.until || undefined,
p: page,
page_size: pageSize,
sort_by: detailSortState.sortBy,
sort_order: detailSortState.sortOrder,
}),
enabled: tab === 'list',
});
const ipQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-summary',
ipTimeParams,
ipPage,
ipPageSize,
ipSort,
],
queryFn: () =>
AccessLogService.listIPSummaries({
...(ipTimeParams as NonNullable<typeof ipTimeParams>),
p: ipPage,
page_size: ipPageSize,
sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder,
}),
enabled: ipQueryEnabled,
});
const cleanupMutation = useMutation({
mutationFn: (retentionDays: number) =>
AccessLogService.cleanup({ retention_days: retentionDays }),
onSuccess: async (result) => {
toast.success(t('cleaned', { count: result.deleted_count }));
setCleanupOpen(false);
await queryClient.invalidateQueries({
queryKey: ['openflare', 'access-logs'],
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('cleanupFailed'));
},
});
const handleSearch = useCallback(() => {
setFilters({
nodeId: draft.nodeId.trim(),
remoteAddr: draft.remoteAddr.trim(),
host: draft.host.trim(),
path: draft.path.trim(),
statusCode: draft.statusCode.trim(),
since: draft.since.trim(),
until: draft.until.trim(),
});
setPage(0);
}, [draft]);
const handleReset = () => {
setDraft(emptyDraft);
setFilters(emptyDraft);
setPage(0);
};
useEffect(() => {
setPage(0);
}, [tab, pageSize]);
useEffect(() => {
setIpPage(0);
}, [
tab,
ipPageSize,
ipHours,
ipTimeMode,
ipCustomSince,
ipCustomUntil,
ipSort,
]);
const refreshActive = () => {
if (tab === 'overview') void overviewQuery.refetch();
if (tab === 'list') void listQuery.refetch();
if (tab === 'ips') void ipQuery.refetch();
};
const isFetching =
overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching;
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<ScrollText className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
</div>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={refreshActive}
disabled={isFetching}
>
<RefreshCw
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
/>
{t('refresh')}
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setCleanupOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
{t('cleanup')}
</Button>
</div>
</div>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-lg grid-cols-3'>
<TabsTrigger value='overview'>{t('tabOverview')}</TabsTrigger>
<TabsTrigger value='ips'>{t('tabIps')}</TabsTrigger>
<TabsTrigger value='list'>{t('tabList')}</TabsTrigger>
</TabsList>
<TabsContent value='overview' className='mt-4'>
<OverviewTab
data={overviewQuery.data}
loading={overviewQuery.isLoading}
error={
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={overviewHours}
hosts={overviewHosts}
onHoursChange={setOverviewHours}
onHostsChange={setOverviewHosts}
onRetry={() => void overviewQuery.refetch()}
/>
</TabsContent>
<TabsContent value='ips' className='mt-4'>
<IpTab
data={ipQuery.data}
loading={ipQueryEnabled && ipQuery.isLoading}
error={ipQuery.error instanceof Error ? ipQuery.error : null}
page={ipPage}
pageSize={ipPageSize}
sort={ipSort}
hours={ipHours}
timeMode={ipTimeMode}
customSince={ipCustomSince}
customUntil={ipCustomUntil}
onHoursChange={(next) => {
setIpTimeMode('preset');
setIpHours(next);
}}
onTimeModeChange={(mode) => {
setIpTimeMode(mode);
if (
mode === 'custom' &&
(!ipCustomSince.trim() || !ipCustomUntil.trim())
) {
const range = defaultLocalRangeForHours(ipHours);
setIpCustomSince(range.since);
setIpCustomUntil(range.until);
}
}}
onApplyCustomRange={(since, until) => {
setIpCustomSince(since);
setIpCustomUntil(until);
setIpTimeMode('custom');
setIpPage(0);
}}
onPageSizeChange={setIpPageSize}
onSortChange={setIpSort}
onRetry={() => void ipQuery.refetch()}
onPrevPage={() => setIpPage((p) => Math.max(0, p - 1))}
onNextPage={() => setIpPage((p) => p + 1)}
isFetching={ipQuery.isFetching}
/>
</TabsContent>
<TabsContent value='list' className='mt-4 space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4'>
<AccessLogFilters
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
/>
</div>
<DetailTab
data={listQuery.data}
loading={listQuery.isLoading}
error={listQuery.error instanceof Error ? listQuery.error : null}
page={page}
detailSort={detailSort}
onDetailSortChange={setDetailSort}
onRetry={() => void listQuery.refetch()}
onPrevPage={() => setPage((p) => Math.max(0, p - 1))}
onNextPage={() => setPage((p) => p + 1)}
isFetching={listQuery.isFetching}
/>
</TabsContent>
</Tabs>
<CleanupDialog
open={cleanupOpen}
onOpenChange={setCleanupOpen}
onConfirm={(days) => cleanupMutation.mutate(days)}
loading={cleanupMutation.isPending}
/>
</div>
);
}
export default function AccessLogsPage() {
const t = useTranslations('accessLogs');
return (
<Suspense
fallback={
<LoadingStateWithBorder
title={t('loadingTitle')}
description={t('loadingDesc')}
/>
}
>
<AccessLogsPageContent />
</Suspense>
);
}
@@ -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>
@@ -0,0 +1,131 @@
'use client';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { ApplyLogItem } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
interface LogDetailSheetProps {
log: ApplyLogItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function ResultBadge({ result }: { result: string }) {
const t = useTranslations('applyLogs');
if (result === 'success') {
return (
<Badge
variant='outline'
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
>
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
{t('success')}
</Badge>
);
}
if (result === 'warning') {
return (
<Badge
variant='outline'
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
>
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
{t('warning')}
</Badge>
);
}
return (
<Badge
variant='outline'
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
>
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
{t('failed')}
</Badge>
);
}
export function LogDetailSheet({
log,
open,
onOpenChange,
}: LogDetailSheetProps) {
const t = useTranslations('applyLogs');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-md w-full p-0 flex flex-col gap-0'>
<SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>{t('sheetTitle')}</SheetTitle>
<SheetDescription>{t('sheetDesc')}</SheetDescription>
</SheetHeader>
{log ? (
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-4'>
<div className='flex flex-wrap gap-2'>
<ResultBadge result={log.result} />
<Badge variant='outline' className='text-[10px] rounded-full'>
Node: {log.node_id}
</Badge>
<Badge variant='outline' className='text-[10px] rounded-full'>
{t('versionBadge', { version: log.version })}
</Badge>
</div>
<div className='grid gap-3 text-xs'>
<div>
<p className='text-muted-foreground'>{t('createdAt')}</p>
<p className='font-medium'>{formatDateTime(log.created_at)}</p>
</div>
<div>
<p className='text-muted-foreground'>{t('targetChecksum')}</p>
<p className='font-mono break-all'>{log.checksum || '—'}</p>
</div>
<div>
<p className='text-muted-foreground'>{t('mainChecksum')}</p>
<p className='font-mono break-all'>
{log.main_config_checksum || '—'}
</p>
</div>
<div>
<p className='text-muted-foreground'>{t('routeChecksum')}</p>
<p className='font-mono break-all'>
{log.route_config_checksum || '—'}
</p>
</div>
<div>
<p className='text-muted-foreground'>{t('supportFiles')}</p>
<p className='font-medium'>{log.support_file_count}</p>
</div>
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground mb-2'>
{t('message')}
</p>
<pre className='text-xs whitespace-pre-wrap break-words'>
{log.message || '—'}
</pre>
</div>
</div>
) : null}
<SheetFooter className='px-5 py-4 border-t'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('close')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,447 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { type ApplyLogItem, ApplyLogService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { LogDetailSheet } from './components/log-detail-sheet';
const PAGE_SIZE_OPTIONS = [20, 50, 100];
function truncateHash(value: string) {
if (!value) return '—';
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
}
function ResultBadge({ result }: { result: string }) {
const t = useTranslations('applyLogs');
if (result === 'success') {
return (
<Badge
variant='outline'
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
>
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
{t('success')}
</Badge>
);
}
if (result === 'warning') {
return (
<Badge
variant='outline'
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
>
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
{t('warning')}
</Badge>
);
}
return (
<Badge
variant='outline'
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
>
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
{t('failed')}
</Badge>
);
}
export function ApplyLogsPageClient() {
const t = useTranslations('applyLogs');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const initialNodeId = searchParams.get('node_id')?.trim() ?? '';
const [nodeFilterInput, setNodeFilterInput] = useState(initialNodeId);
const [nodeFilter, setNodeFilter] = useState(initialNodeId);
const [pageNo, setPageNo] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [rows, setRows] = useState<ApplyLogItem[]>([]);
const [current, setCurrent] = useState(1);
const [total, setTotal] = useState(0);
const [totalPage, setTotalPage] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [cleanupOpen, setCleanupOpen] = useState(false);
const [cleaning, setCleaning] = useState(false);
const summary = useMemo(() => {
const nodeIds = new Set(rows.map((item) => item.node_id));
return [
{ label: t('totalRecords'), value: total },
{ label: t('currentPage'), value: current },
{ label: t('totalPages'), value: totalPage },
{ label: t('pageNodes'), value: nodeIds.size },
];
}, [rows, total, current, totalPage, t]);
const fetchLogs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await ApplyLogService.list({
node_id: nodeFilter || undefined,
pageNo,
pageSize,
});
setRows(data.rows);
setCurrent(data.current);
setTotal(data.total);
setTotalPage(data.totalPage);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [nodeFilter, pageNo, pageSize, t]);
useEffect(() => {
void fetchLogs();
}, [fetchLogs]);
useEffect(() => {
const nodeId = searchParams.get('node_id')?.trim();
if (!nodeId) return;
setNodeFilterInput(nodeId);
setNodeFilter(nodeId);
setPageNo(1);
}, [searchParams]);
const handleSearch = () => {
setPageNo(1);
setNodeFilter(nodeFilterInput.trim());
};
const handleResetFilters = () => {
setNodeFilterInput('');
setNodeFilter('');
setPageNo(1);
};
const handleCleanupLogs = async () => {
setCleaning(true);
try {
const result = await ApplyLogService.cleanup({ delete_all: true });
toast.success(t('cleared'), {
description: t('clearedDesc', { count: result.deleted_count }),
});
setCleanupOpen(false);
setPageNo(1);
await fetchLogs();
} catch (err) {
toast.error(t('clearFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setCleaning(false);
}
};
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<ClipboardList className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('description')}</p>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => void fetchLogs()}
disabled={loading}
>
<RefreshCw
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
/>
{t('refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setCleanupOpen(true)}
disabled={loading || cleaning || total === 0}
>
<Trash2 className='size-3.5 mr-1' />
{t('clear')}
</Button>
<Button variant='outline' size='sm' asChild>
<Link href='/nodes'>{t('backToNodes')}</Link>
</Button>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
{summary.map((item) => (
<div
key={item.label}
className='rounded-lg border border-dashed px-4 py-3 bg-background'
>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{item.label}
</p>
<p className='mt-1 text-lg font-semibold'>{item.value}</p>
</div>
))}
</div>
<div className='flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between'>
<div className='grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]'>
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>
{t('nodeId')}
</p>
<div className='relative'>
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
<Input
value={nodeFilterInput}
onChange={(e) => setNodeFilterInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
placeholder={t('nodePlaceholder')}
className='pl-8 h-9 text-xs'
/>
</div>
</div>
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>
{t('pageSize')}
</p>
<Select
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number.parseInt(value, 10));
setPageNo(1);
}}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{t('pageSizeOption', { count: option })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button size='sm' onClick={handleSearch}>
{t('filter')}
</Button>
<Button variant='outline' size='sm' onClick={handleResetFilters}>
{t('reset')}
</Button>
</div>
</div>
{error ? (
<ErrorInline message={error} onRetry={() => void fetchLogs()} />
) : null}
<div className='border border-dashed shadow-none rounded-lg overflow-hidden bg-background'>
{loading ? (
<LoadingStateWithBorder />
) : rows.length === 0 ? (
<EmptyStateWithBorder
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'>
{t('colNodeId')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colVersion')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colResult')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChecksum')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTime')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colMessage')}
</TableHead>
<TableHead className='text-xs font-semibold text-right'>
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((log) => (
<TableRow
key={log.id}
className='border-dashed hover:bg-muted/10 transition-colors align-top'
>
<TableCell className='text-xs font-medium'>
{log.node_id}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{log.version}
</TableCell>
<TableCell>
<ResultBadge result={log.result} />
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground'
title={log.checksum}
>
{truncateHash(log.checksum)}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{formatDateTime(log.created_at)}
</TableCell>
<TableCell className='text-xs text-muted-foreground max-w-56'>
<div className='line-clamp-2 break-words'>
{log.message || '—'}
</div>
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={() => {
setSelectedLog(log);
setDetailOpen(true);
}}
>
<Eye className='size-3 mr-1' />
{t('detail')}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
{!loading && rows.length > 0 ? (
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<p className='text-xs text-muted-foreground'>
{t('pageInfo', {
current,
total: Math.max(totalPage, 1),
count: total,
})}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={current <= 1}
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
>
{t('prev')}
</Button>
<Button
variant='outline'
size='sm'
disabled={totalPage === 0 || current >= totalPage}
onClick={() =>
setPageNo((prev) =>
totalPage > 0 ? Math.min(totalPage, prev + 1) : prev,
)
}
>
{t('next')}
</Button>
</div>
</div>
) : null}
<LogDetailSheet
log={selectedLog}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('clearTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('clearDesc', { count: total })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={cleaning}
onClick={(event) => {
event.preventDefault();
void handleCleanupLogs();
}}
>
{cleaning ? t('clearing') : t('confirmClear')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { ApplyLogsPageClient } from './page-client';
function ApplyLogsPageFallback() {
return (
<div className='py-6 px-1 space-y-6'>
<Skeleton className='h-8 w-40' />
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<Skeleton className='h-16 w-full' />
<Skeleton className='h-16 w-full' />
<Skeleton className='h-16 w-full' />
<Skeleton className='h-16 w-full' />
</div>
<Skeleton className='h-96 w-full' />
</div>
);
}
export default function ApplyLogsPage() {
return (
<Suspense fallback={<ApplyLogsPageFallback />}>
<ApplyLogsPageClient />
</Suspense>
);
}
+389
View File
@@ -0,0 +1,389 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { FileKey, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { TlsCertificateItem } from '@/lib/services/openflare';
import { TlsCertificateService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { CertificateApplyDialog } from '../websites/components/certificate-apply-dialog';
import { CertificateDetailDialog } from '../websites/components/certificate-detail-dialog';
import { CertificateEditorDialog } from '../websites/components/certificate-editor-dialog';
import { CertificateImportDialog } from '../websites/components/certificate-import-dialog';
import { WebsiteStatusBadge } from '../websites/components/status-badge';
import { useTranslations } from 'next-intl';
import {
getCertificateStatus,
getErrorMessage,
} from '../websites/components/website-utils';
const certificatesQueryKey = ['openflare', 'tls-certificates'];
type CertificateApplyMode = 'edit-acme' | 'convert-upload';
export default function CertificatesPage() {
const t = useTranslations('certificates');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const [importOpen, setImportOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<TlsCertificateItem | null>(
null,
);
const [selectedCertificateId, setSelectedCertificateId] = useState<
number | null
>(null);
const [applyCertificate, setApplyCertificate] =
useState<TlsCertificateItem | null>(null);
const [applyMode, setApplyMode] = useState<CertificateApplyMode>('edit-acme');
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: () => TlsCertificateService.list(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
onSuccess: async () => {
toast.success(t('deleted'));
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const renewMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.renew(id),
onSuccess: async (cert) => {
toast.success(t('renewSubmitted', { name: cert.name }));
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const handleOpenEditor = (certificate: TlsCertificateItem) => {
if (certificate.provider === 'acme') {
setApplyMode('edit-acme');
setApplyCertificate(certificate);
setApplyOpen(true);
} else {
setSelectedCertificateId(certificate.id);
setEditorOpen(true);
}
};
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<FileKey className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
void queryClient.invalidateQueries({
queryKey: certificatesQueryKey,
})
}
>
<RefreshCw className='size-3.5 mr-1' />
{t('refresh')}
</Button>
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={() => setImportOpen(true)}
>
{t('importCert')}
</Button>
<Button
size='sm'
className='h-7 text-xs'
onClick={() => setApplyOpen(true)}
>
<Plus className='size-3.5 mr-1' />
{t('applyTitle')}
</Button>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base font-semibold'>
{t('listTitle')}
</CardTitle>
<CardDescription>{t('listDesc')}</CardDescription>
</CardHeader>
<CardContent>
{certificatesQuery.isLoading ? (
<LoadingStateWithBorder
icon={FileKey}
description={t('loadingList')}
/>
) : certificatesQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(
certificatesQuery.error,
t('requestFailed'),
)}
onRetry={() => void certificatesQuery.refetch()}
className='justify-center'
/>
</div>
) : certificates.length === 0 ? (
<EmptyStateWithBorder icon={FileKey} description={t('emptyList')} />
) : (
<div className='space-y-3'>
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate, t);
return (
<div
key={certificate.id}
className='rounded-lg border bg-card px-4 py-3'
>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<p className='text-sm font-semibold'>
{certificate.name}
</p>
<WebsiteStatusBadge
label={status.label}
tone={status.tone}
/>
</div>
<div className='text-xs leading-5 text-muted-foreground space-y-0.5'>
<p>
{t('notBeforeLabel', {
value: formatDateTime(certificate.not_before),
})}
</p>
<p>
{t('notAfterLabel', {
value: formatDateTime(certificate.not_after),
})}
</p>
<p>
{t('sourceLabel', {
value:
certificate.provider === 'acme'
? t('sourceAcme')
: t('sourceUpload'),
})}
</p>
{certificate.apply_status === 'applying' ? (
<p className='text-blue-600'>
{t('statusLabel', {
value:
certificate.provider === 'upload'
? t('converting')
: t('applying'),
})}
</p>
) : null}
{certificate.apply_status === 'error' ? (
<p className='text-destructive'>
{t('statusError', {
value:
certificate.provider === 'upload'
? t('convertFailed')
: t('applyFailed'),
message: certificate.apply_message,
})}
</p>
) : null}
<p>
{t('remarkLabel', {
value: certificate.remark || t('noRemark'),
})}
</p>
</div>
</div>
<div className='flex flex-wrap gap-1'>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => {
setSelectedCertificateId(certificate.id);
setDetailOpen(true);
}}
>
{t('view')}
</Button>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => handleOpenEditor(certificate)}
>
{t('edit')}
</Button>
{certificate.provider === 'acme' ? (
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
disabled={renewMutation.isPending}
onClick={() => renewMutation.mutate(certificate.id)}
>
{t('renew')}
</Button>
) : null}
<Button
variant='outline'
size='sm'
className='h-7 text-xs text-destructive'
onClick={() => setDeleteTarget(certificate)}
>
<Trash2 className='size-3' />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<CertificateImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={(certificate) =>
toast.success(t('imported', { name: certificate.name }))
}
/>
<CertificateApplyDialog
open={applyOpen && !applyCertificate}
onOpenChange={setApplyOpen}
onApplied={(certificate) =>
toast.success(t('applySubmitted', { name: certificate.name }))
}
/>
{applyCertificate ? (
<CertificateApplyDialog
open={applyOpen}
onOpenChange={(open) => {
setApplyOpen(open);
if (!open) setApplyCertificate(null);
}}
mode={applyMode}
certificate={applyCertificate}
onApplied={(certificate) => {
setApplyCertificate(null);
toast.success(
applyMode === 'convert-upload'
? t('convertSubmitted', { name: certificate.name })
: t('reapplySubmitted', { name: certificate.name }),
);
}}
/>
) : null}
<CertificateDetailDialog
certificateId={selectedCertificateId}
open={detailOpen}
onOpenChange={setDetailOpen}
onEdit={() => {
setDetailOpen(false);
const item = certificates.find((c) => c.id === selectedCertificateId);
if (item) handleOpenEditor(item);
}}
onDelete={() => {
const item = certificates.find((c) => c.id === selectedCertificateId);
if (item) {
setDetailOpen(false);
setDeleteTarget(item);
}
}}
deleting={deleteMutation.isPending}
/>
<CertificateEditorDialog
certificateId={selectedCertificateId}
open={editorOpen}
onOpenChange={setEditorOpen}
onSaved={(certificate) =>
toast.success(t('updated', { name: certificate.name }))
}
onConvert={(certificate) => {
setEditorOpen(false);
setApplyMode('convert-upload');
setApplyCertificate(certificate);
setApplyOpen(true);
}}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{tc('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,167 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import type {
CloudflareGroup,
CloudflareGroupPayload,
NodeItem,
} from '@/lib/services/openflare';
export function GroupDialog({
open,
onOpenChange,
group,
nodes,
pending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
group?: CloudflareGroup | null;
nodes: NodeItem[];
pending: boolean;
onSubmit: (payload: CloudflareGroupPayload) => void;
}) {
const t = useTranslations('cloudflare.groupDialog');
const tCommon = useTranslations('common');
const edgeNodes = useMemo(
() => nodes.filter((node) => node.node_type === 'edge_node'),
[nodes],
);
const [name, setName] = useState('');
const [primaryNodeID, setPrimaryNodeID] = useState('');
const [backupNodeID, setBackupNodeID] = useState('none');
const [defaultProxied, setDefaultProxied] = useState(true);
const [enabled, setEnabled] = useState(true);
useEffect(() => {
if (!open) return;
setName(group?.name ?? '');
setPrimaryNodeID(group ? String(group.primary_node.id) : '');
setBackupNodeID(group?.backup_node ? String(group.backup_node.id) : 'none');
setDefaultProxied(group?.default_proxied ?? true);
setEnabled(group?.enabled ?? true);
}, [group, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{group ? t('editTitle') : t('createTitle')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-group-name'>{t('name')}</FieldLabel>
<Input
id='cf-group-name'
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{edgeNodes.map((node) => (
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
{t('defaultProxied')}
</FieldLabel>
<Switch
id='cf-default-proxied'
checked={defaultProxied}
onCheckedChange={setDefaultProxied}
/>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-enabled'>{t('enableSync')}</FieldLabel>
<Switch
id='cf-enabled'
checked={enabled}
onCheckedChange={setEnabled}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{tCommon('cancel')}
</Button>
<Button
disabled={pending || !name.trim() || !primaryNodeID}
onClick={() =>
onSubmit({
name: name.trim(),
primary_node_id: Number(primaryNodeID),
backup_node_id:
backupNodeID === 'none' ? null : Number(backupNodeID),
default_proxied: defaultProxied,
enabled,
})
}
>
{t('save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,391 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import type { CloudflareAvailableDomain } from '@/lib/services/openflare';
type DomainZoneGroup = {
zoneId: number;
zoneDomain: string;
domains: CloudflareAvailableDomain[];
};
function groupDomainsByZone(
domains: CloudflareAvailableDomain[],
): DomainZoneGroup[] {
const map = new Map<number, DomainZoneGroup>();
for (const domain of domains) {
const existing = map.get(domain.zone_id);
if (existing) {
existing.domains.push(domain);
continue;
}
map.set(domain.zone_id, {
zoneId: domain.zone_id,
zoneDomain: domain.zone_domain || `Zone #${domain.zone_id}`,
domains: [domain],
});
}
return [...map.values()]
.map((group) => ({
...group,
domains: [...group.domains].sort((a, b) =>
a.domain.localeCompare(b.domain),
),
}))
.sort((a, b) => a.zoneDomain.localeCompare(b.zoneDomain));
}
export function MemberAddDialog({
open,
onOpenChange,
domains,
defaultProxied,
pending,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
domains: CloudflareAvailableDomain[];
defaultProxied: boolean;
pending: boolean;
onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void;
}) {
const t = useTranslations('cloudflare.memberDialog');
const tCommon = useTranslations('common');
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const [proxied, setProxied] = useState(defaultProxied);
const [collapsedZones, setCollapsedZones] = useState<Set<number>>(
() => new Set(),
);
useEffect(() => {
if (!open) return;
setKeyword('');
setSelectedIDs([]);
setProxied(defaultProxied);
setCollapsedZones(new Set());
}, [defaultProxied, open]);
const selected = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const filteredGroups = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
const list = normalized
? domains.filter((domain) => {
const zoneRoot = domain.zone_domain ?? '';
return (
domain.domain.toLowerCase().includes(normalized) ||
zoneRoot.toLowerCase().includes(normalized) ||
String(domain.id).includes(normalized)
);
})
: domains;
return groupDomainsByZone(list);
}, [domains, keyword]);
const visibleIDs = useMemo(
() => filteredGroups.flatMap((group) => group.domains.map((d) => d.id)),
[filteredGroups],
);
const allVisibleSelected =
visibleIDs.length > 0 && visibleIDs.every((id) => selected.has(id));
const someVisibleSelected =
visibleIDs.some((id) => selected.has(id)) && !allVisibleSelected;
const toggleOne = (id: number) => {
setSelectedIDs((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
);
};
const toggleGroup = (group: DomainZoneGroup) => {
const ids = group.domains.map((d) => d.id);
const allSelected = ids.every((id) => selected.has(id));
setSelectedIDs((prev) => {
if (allSelected) {
return prev.filter((id) => !ids.includes(id));
}
const next = new Set(prev);
for (const id of ids) next.add(id);
return [...next];
});
};
const toggleAllVisible = () => {
setSelectedIDs((prev) => {
if (allVisibleSelected) {
return prev.filter((id) => !visibleIDs.includes(id));
}
const next = new Set(prev);
for (const id of visibleIDs) next.add(id);
return [...next];
});
};
const toggleCollapsed = (zoneId: number) => {
setCollapsedZones((prev) => {
const next = new Set(prev);
if (next.has(zoneId)) next.delete(zoneId);
else next.add(zoneId);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-domain-search'>
{t('zoneDomain')}
</FieldLabel>
<div className='space-y-2'>
<div className='relative'>
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
id='cf-domain-search'
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={t('searchPlaceholder')}
className='pl-8'
disabled={pending}
/>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Badge variant='secondary' className='font-normal'>
{t('selected', { count: selectedIDs.length })}
</Badge>
<span>{t('visible', { count: visibleIDs.length })}</span>
</div>
<div className='flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || visibleIDs.length === 0}
onClick={toggleAllVisible}
>
{allVisibleSelected ? t('unselectAll') : t('selectVisible')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
disabled={pending || selectedIDs.length === 0}
onClick={() => setSelectedIDs([])}
>
{t('clear')}
</Button>
</div>
</div>
{domains.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
{t('noAvailable')}
</div>
) : filteredGroups.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
{t('noMatch')}
</div>
) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
{/* Header row: select all visible */}
<label className='flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted/50'>
<Checkbox
checked={
allVisibleSelected
? true
: someVisibleSelected
? 'indeterminate'
: false
}
disabled={pending}
onCheckedChange={toggleAllVisible}
/>
<span>{t('groupByZone')}</span>
</label>
{filteredGroups.map((group) => {
const groupIDs = group.domains.map((d) => d.id);
const groupAllSelected = groupIDs.every((id) =>
selected.has(id),
);
const groupSomeSelected =
groupIDs.some((id) => selected.has(id)) &&
!groupAllSelected;
const open = !collapsedZones.has(group.zoneId);
return (
<Collapsible
key={group.zoneId}
open={open}
onOpenChange={() => toggleCollapsed(group.zoneId)}
>
<div
className={cn(
'rounded-md',
(groupAllSelected || groupSomeSelected) &&
'bg-muted/30',
)}
>
<div className='flex items-center gap-1 px-1 py-0.5'>
<Checkbox
checked={
groupAllSelected
? true
: groupSomeSelected
? 'indeterminate'
: false
}
disabled={pending}
onCheckedChange={() => toggleGroup(group)}
aria-label={t('selectZone', {
name: group.zoneDomain,
})}
className='ml-1'
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-sm font-medium hover:bg-muted/60'
>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform',
!open && '-rotate-90',
)}
/>
<span className='truncate'>
{group.zoneDomain}
</span>
<Badge
variant='outline'
className='ml-auto shrink-0 font-normal text-[10px]'
>
{
groupIDs.filter((id) => selected.has(id))
.length
}
/{group.domains.length}
</Badge>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='ml-4 space-y-0.5 border-l border-border/70 py-0.5 pl-2'>
{group.domains.map((domain) => {
const checked = selected.has(domain.id);
const isApex =
domain.domain === group.zoneDomain;
return (
<label
key={domain.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/60',
checked && 'bg-muted/40',
)}
>
<Checkbox
checked={checked}
disabled={pending}
onCheckedChange={() =>
toggleOne(domain.id)
}
/>
<span className='min-w-0 flex-1 truncate text-sm'>
{domain.domain}
</span>
{isApex ? (
<Badge
variant='secondary'
className='shrink-0 text-[10px] font-normal'
>
{t('zone')}
</Badge>
) : null}
{checked ? (
<Check className='size-3.5 shrink-0 text-primary' />
) : null}
</label>
);
})}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
})}
</div>
)}
</div>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-member-proxied'>{t('proxied')}</FieldLabel>
<Switch
id='cf-member-proxied'
checked={proxied}
disabled={pending}
onCheckedChange={setProxied}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button
variant='outline'
disabled={pending}
onClick={() => onOpenChange(false)}
>
{tCommon('cancel')}
</Button>
<Button
disabled={pending || selectedIDs.length === 0}
onClick={() =>
onSubmit(
[...selectedIDs].sort((a, b) => a - b),
proxied,
)
}
>
{selectedIDs.length > 1
? t('submitCount', { count: selectedIDs.length })
: t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,514 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import {
Activity,
ChevronLeft,
ChevronRight,
RefreshCw,
RotateCcw,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { AdminTaskService } from '@/lib/services/admin';
import type {
ListTaskExecutionsResponse,
TaskExecution,
TaskExecutionStatus,
} from '@/lib/services/admin';
import { cloudflareQueryKey } from '@/lib/services/openflare';
/**
* Only domain-member and group sync jobs.
* Uses exact asynq task_type filters (compatible with existing admin API).
* Excludes cloudflare:sync_by_node and any unrelated scheduled system tasks.
*/
export const CLOUDFLARE_SYNC_TASK_TYPES = [
'cloudflare:sync_member',
'cloudflare:sync_group',
] as const;
const ALLOWED_TASK_TYPES = new Set<string>(CLOUDFLARE_SYNC_TASK_TYPES);
/** Per-type fetch window; merged and paginated on the client. */
const FETCH_PAGE_SIZE = 50;
const PAGE_SIZE = 10;
async function listCloudflareSyncExecutions(options: {
page: number;
status?: TaskExecutionStatus;
}): Promise<ListTaskExecutionsResponse> {
const status = options.status;
const responses = await Promise.all(
CLOUDFLARE_SYNC_TASK_TYPES.map((taskType) =>
AdminTaskService.listTaskExecutions({
task_type: taskType,
page: 1,
page_size: FETCH_PAGE_SIZE,
status,
}),
),
);
const merged = responses
.flatMap((response) => response.items)
.filter((item) => ALLOWED_TASK_TYPES.has(item.task_type))
.sort((a, b) => {
const timeA = Date.parse(a.created_at) || 0;
const timeB = Date.parse(b.created_at) || 0;
if (timeA !== timeB) return timeB - timeA;
return String(b.id).localeCompare(String(a.id), undefined, {
numeric: true,
});
});
const start = (options.page - 1) * PAGE_SIZE;
return {
items: merged.slice(start, start + PAGE_SIZE),
total: merged.length,
page: options.page,
page_size: PAGE_SIZE,
};
}
function formatDateTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return format(date, 'yyyy-MM-dd HH:mm:ss');
}
function formatDuration(duration: number) {
if (!duration) return '-';
if (duration < 1000) return `${duration}ms`;
return `${(duration / 1000).toFixed(2)}s`;
}
function statusVariant(status: TaskExecutionStatus) {
if (status === 'failed') return 'destructive' as const;
if (status === 'succeeded') return 'secondary' as const;
return 'outline' as const;
}
export function SyncTasksPanel() {
const t = useTranslations('cloudflare.tasks');
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preview, setPreview] = useState<TaskExecution | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const executionsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'sync-executions', page, status],
queryFn: () =>
listCloudflareSyncExecutions({
page,
status: status === 'all' ? undefined : status,
}),
refetchInterval: (query) => {
const items = query.state.data?.items ?? [];
const active = items.some(
(item) => item.status === 'pending' || item.status === 'running',
);
return active ? 3000 : 15000;
},
});
const detailQuery = useQuery({
queryKey: ['admin', 'task-execution', selectedId],
queryFn: () => AdminTaskService.getTaskExecution(selectedId!),
enabled: detailOpen && !!selectedId,
});
const retryMutation = useMutation({
mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success(t('retried'), {
description: t('newTaskId', { id: taskID }),
});
void queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
},
onError: (err: Error) => {
toast.error(t('retryFailed'), {
description: err.message || t('unknownError'),
});
},
});
const executions = executionsQuery.data?.items ?? [];
const total = executionsQuery.data?.total ?? 0;
const loading = executionsQuery.isPending || executionsQuery.isFetching;
const selected = detailQuery.data ?? preview;
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
[total],
);
const statusLabels: Record<TaskExecutionStatus, string> = {
pending: t('pending'),
running: t('running'),
succeeded: t('succeeded'),
failed: t('failed'),
};
const triggerLabels: Record<string, string> = {
system: t('system'),
manual: t('manual'),
retry: t('retry'),
schedule: t('schedule'),
};
const openDetail = (execution: TaskExecution) => {
setPreview(execution);
setSelectedId(execution.id);
setDetailOpen(true);
};
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-base'>
<Activity className='size-4 text-primary' />
{t('title')}
</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as TaskExecutionStatus | 'all');
setPage(1);
}}
>
<SelectTrigger
size='sm'
className='w-[120px]'
aria-label={t('statusPlaceholder')}
>
<SelectValue placeholder={t('statusPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('allStatus')}</SelectItem>
<SelectItem value='pending'>{t('pending')}</SelectItem>
<SelectItem value='running'>{t('running')}</SelectItem>
<SelectItem value='succeeded'>{t('succeeded')}</SelectItem>
<SelectItem value='failed'>{t('failed')}</SelectItem>
</SelectContent>
</Select>
<Button
variant='outline'
size='sm'
onClick={() => void executionsQuery.refetch()}
disabled={loading}
>
{loading ? (
<Spinner className='size-4' />
) : (
<RefreshCw data-icon='inline-start' />
)}
{t('refresh')}
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
{executionsQuery.isError ? (
<ErrorInline
message={
executionsQuery.error instanceof Error
? executionsQuery.error.message
: t('loadFailed')
}
onRetry={() => void executionsQuery.refetch()}
/>
) : loading && executions.length === 0 ? (
<LoadingStateWithBorder icon={Activity} description={t('loading')} />
) : executions.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className='rounded-lg border'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[200px]'>
{t('columns.task')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.status')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.trigger')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.duration')}
</TableHead>
<TableHead className='min-w-[180px]'>
{t('columns.result')}
</TableHead>
<TableHead className='w-[160px]'>
{t('columns.createdAt')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map((execution) => (
<TableRow
key={execution.id}
className='cursor-pointer'
onClick={() => openDetail(execution)}
>
<TableCell>
<div className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{execution.task_name || execution.task_type}
</span>
<span className='font-mono text-[11px] text-muted-foreground'>
{execution.task_type}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{statusLabels[execution.status] || execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{triggerLabels[execution.triggered_by] ||
execution.triggered_by}
</Badge>
</TableCell>
<TableCell className='font-mono text-xs text-muted-foreground'>
{formatDuration(execution.duration)}
</TableCell>
<TableCell className='max-w-[280px] truncate text-xs text-muted-foreground'>
{execution.error_message || execution.result || '-'}
</TableCell>
<TableCell className='font-mono text-[11px] text-muted-foreground'>
{formatDateTime(execution.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className='flex items-center justify-between gap-2'>
<div className='text-xs text-muted-foreground'>
{t('pageSummary', { total, page, pages: totalPages })}
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className='size-4' />
{t('prev')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
{t('next')}
<ChevronRight className='size-4' />
</Button>
</div>
</div>
</CardContent>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[560px]'>
<SheetHeader className='border-b'>
<SheetTitle>{t('detailTitle')}</SheetTitle>
<SheetDescription>
{selected?.task_name || selected?.task_type || t('defaultName')}
</SheetDescription>
</SheetHeader>
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{detailQuery.isFetching && !selected ? (
<LoadingStateWithBorder
icon={Activity}
description={t('loadingDetail')}
/>
) : selected ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
{t('status')}
</div>
<div className='mt-2'>
<Badge variant={statusVariant(selected.status)}>
{statusLabels[selected.status] || selected.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
{t('trigger')}
</div>
<div className='mt-2 text-sm font-medium'>
{triggerLabels[selected.triggered_by] ||
selected.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
{t('retries')}
</div>
<div className='mt-2 font-mono text-sm'>
{selected.retry_count}/{selected.max_retry}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
{t('duration')}
</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selected.duration)}
</div>
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
{t('taskId')}
</div>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selected.task_id}
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
{t('createdAt')}
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.created_at)}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
{t('finishedAt')}
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.finished_at)}
</div>
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
{t('result')}
</div>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selected.result || '-'}
</div>
</div>
{selected.error_message ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
{t('error')}
</div>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selected.error_message}
</div>
</div>
) : null}
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>Payload</div>
<pre className='max-h-36 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.payload || '{}'}
</pre>
</div>
{selected.log ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
{t('logs')}
</div>
<pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.log}
</pre>
</div>
) : null}
</>
) : null}
</div>
<SheetFooter className='border-t'>
{selected?.retryable && selected.status === 'failed' ? (
<Button
variant='outline'
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(selected.id)}
>
{retryMutation.isPending ? (
<Spinner className='size-4' />
) : (
<RotateCcw data-icon='inline-start' />
)}
{t('retry')}
</Button>
) : null}
<Button variant='outline' onClick={() => setDetailOpen(false)}>
{t('close')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</Card>
);
}
@@ -0,0 +1,347 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Cloud,
Loader2,
Plus,
RefreshCw,
Settings,
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
CloudflareService,
cloudflareQueryKey,
NodeService,
type CloudflareGroupPayload,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../../../websites/components/website-utils';
import { GroupDialog } from '../../components/group-dialog';
import { MemberAddDialog } from '../../components/member-add-dialog';
export function CloudflareGroupDetailPageClient() {
const t = useTranslations('cloudflare');
const params = useParams<{ id: string }>();
const groupID = Number(params.id);
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const detailQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups', groupID],
queryFn: () => CloudflareService.getGroup(groupID),
enabled: Number.isInteger(groupID) && groupID > 0,
refetchInterval: 5000,
});
const domainsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'domains', 'available'],
queryFn: () => CloudflareService.listAvailableDomains(),
});
const nodesQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const invalidate = async () =>
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
const updateGroupMutation = useMutation({
mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.updateGroup(groupID, payload),
onSuccess: async () => {
toast.success(t('groupUpdated'));
setEditOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const addMutation = useMutation({
mutationFn: async ({
domainIDs,
proxied,
}: {
domainIDs: number[];
proxied: boolean;
}) => {
const results = await Promise.allSettled(
domainIDs.map((domainID) =>
CloudflareService.createMember(groupID, {
zone_domain_id: domainID,
proxied,
}),
),
);
const failed = results.filter((r) => r.status === 'rejected');
const succeeded = results.length - failed.length;
return { succeeded, failed: failed.length, total: results.length };
},
onSuccess: async ({ succeeded, failed, total }) => {
if (failed === 0) {
toast.success(
total === 1
? t('memberAddedOne')
: t('memberAddedMany', { count: succeeded }),
);
} else if (succeeded === 0) {
toast.error(t('memberAddFailed', { count: failed }));
} else {
toast.warning(t('memberPartial', { succeeded, failed }));
}
setAddOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const proxiedMutation = useMutation({
mutationFn: ({
memberID,
proxied,
}: {
memberID: number;
proxied: boolean;
}) => CloudflareService.updateMember(groupID, memberID, proxied),
onSuccess: async () => {
toast.success(t('proxyUpdated'));
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const syncMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.syncMember(groupID, memberID),
onSuccess: () => toast.success(t('memberSyncQueued')),
onError: (error) => toast.error(getErrorMessage(error)),
});
const removeMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.removeMember(groupID, memberID),
onSuccess: async () => {
toast.success(t('memberDeleted'));
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
if (detailQuery.isLoading)
return (
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder icon={Cloud} description={t('loadingDetail')} />
</div>
);
if (detailQuery.isError || !detailQuery.data)
return (
<div className='w-full py-6 px-1'>
<ErrorInline
message={getErrorMessage(detailQuery.error)}
onRetry={() => void detailQuery.refetch()}
/>
</div>
);
const { group, members } = detailQuery.data;
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex flex-col gap-4'>
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
{t('back')}
</Link>
</Button>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{group.name}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => void detailQuery.refetch()}
disabled={detailQuery.isFetching}
>
{detailQuery.isFetching ? (
<Loader2 data-icon='inline-start' className='animate-spin' />
) : (
<RefreshCw data-icon='inline-start' />
)}
{t('refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setEditOpen(true)}
>
<Settings data-icon='inline-start' />
{t('edit')}
</Button>
<Button size='sm' onClick={() => setAddOpen(true)}>
<Plus data-icon='inline-start' />
{t('addDomain')}
</Button>
</div>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
<CardDescription>
{t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
</Badge>
<Badge variant='outline'>
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
</Badge>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>{t('members')}</CardTitle>
<CardDescription>{t('membersDesc')}</CardDescription>
</CardHeader>
<CardContent>
{members.length === 0 ? (
<p className='py-8 text-center text-sm text-muted-foreground'>
{t('noMembers')}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('columns.domain')}</TableHead>
<TableHead>{t('columns.desiredIp')}</TableHead>
<TableHead>{t('columns.status')}</TableHead>
<TableHead>{t('columns.proxied')}</TableHead>
<TableHead className='text-right'>
{t('columns.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div className='font-medium'>{member.domain}</div>
{member.last_error ? (
<p className='max-w-md text-xs text-destructive'>
{member.last_error}
</p>
) : null}
</TableCell>
<TableCell>
{member.desired_ip || t('pendingSync')}
</TableCell>
<TableCell>
<Badge
variant={
member.sync_status === 'ok'
? 'default'
: member.sync_status === 'error'
? 'destructive'
: 'secondary'
}
>
{member.sync_status}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={member.proxied}
onCheckedChange={(proxied) =>
proxiedMutation.mutate({
memberID: member.id,
proxied,
})
}
/>
</TableCell>
<TableCell>
<div className='flex justify-end gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => syncMutation.mutate(member.id)}
>
<RefreshCw data-icon='inline-start' />
{t('sync')}
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => removeMutation.mutate(member.id)}
>
<Trash2 data-icon='inline-start' />
{t('remove')}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<GroupDialog
open={editOpen}
onOpenChange={setEditOpen}
group={group}
nodes={nodesQuery.data ?? []}
pending={updateGroupMutation.isPending}
onSubmit={(payload) => updateGroupMutation.mutate(payload)}
/>
<MemberAddDialog
open={addOpen}
onOpenChange={setAddOpen}
domains={domainsQuery.data ?? []}
defaultProxied={group.default_proxied}
pending={addMutation.isPending}
onSubmit={(domainIDs, proxied) =>
addMutation.mutate({ domainIDs, proxied })
}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import { Cloud } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { CloudflareGroupDetailPageClient } from './page-client';
/** Placeholder for static export; real ids resolve client-side via useParams. */
export async function generateStaticParams() {
return [{ id: '1' }];
}
export default async function CloudflareGroupDetailPage() {
const t = await getTranslations('cloudflare');
return (
<Suspense
fallback={
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder
icon={Cloud}
description={t('loadingDetail')}
/>
</div>
}
>
<CloudflareGroupDetailPageClient />
</Suspense>
);
}
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function CloudflareGroupsPage() {
redirect('/cloudflare');
}
+268
View File
@@ -0,0 +1,268 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
Cloud,
Plus,
RefreshCw,
Settings,
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { GroupDialog } from '@/app/(main)/cloudflare/components/group-dialog';
import { SyncTasksPanel } from '@/app/(main)/cloudflare/components/sync-tasks-panel';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
CloudflareService,
cloudflareQueryKey,
NodeService,
type CloudflareGroup,
type CloudflareGroupPayload,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../websites/components/website-utils';
export default function CloudflarePage() {
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>(
null,
);
const overviewQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'overview'],
queryFn: () => CloudflareService.getOverview(),
});
const groupsQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'groups'],
queryFn: () => CloudflareService.listGroups(),
});
const nodesQuery = useQuery({
queryKey: ['openflare', 'nodes'],
queryFn: () => NodeService.listNodes(),
});
const invalidate = async () =>
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
const createMutation = useMutation({
mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.createGroup(payload),
onSuccess: async () => {
toast.success(t('created'));
setCreateOpen(false);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const syncMutation = useMutation({
mutationFn: (id: number) => CloudflareService.syncGroup(id),
onSuccess: async () => {
toast.success(t('syncQueued'));
await queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => CloudflareService.deleteGroup(id),
onSuccess: async () => {
toast.success(t('deleted'));
setDeleteTarget(null);
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const overview = overviewQuery.data;
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href='/cloudflare/settings'>
<Settings data-icon='inline-start' />
{t('connectionSettings')}
</Link>
</Button>
<Button size='sm' onClick={() => setCreateOpen(true)}>
<Plus data-icon='inline-start' />
{t('addGroup')}
</Button>
</div>
</div>
{overviewQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description={t('loadingOverview')}
/>
) : overviewQuery.isError ? (
<ErrorInline
message={getErrorMessage(overviewQuery.error)}
onRetry={() => void overviewQuery.refetch()}
/>
) : !overview?.connection.ready ? (
<Alert>
<AlertTriangle />
<AlertTitle>{t('notReadyTitle')}</AlertTitle>
<AlertDescription className='flex flex-col items-start gap-3'>
<span>{t('notReadyDesc')}</span>
<Button asChild size='sm'>
<Link href='/cloudflare/settings'>{t('configure')}</Link>
</Button>
</AlertDescription>
</Alert>
) : null}
<div className='flex flex-col gap-4'>
<div>
<h2 className='text-lg font-semibold'>{t('groupsTitle')}</h2>
<p className='text-sm text-muted-foreground'>{t('groupsDesc')}</p>
</div>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description={t('loadingGroups')}
/>
) : groupsQuery.isError ? (
<ErrorInline
message={getErrorMessage(groupsQuery.error)}
onRetry={() => void groupsQuery.refetch()}
/>
) : (groupsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Cloud}
title={t('emptyTitle')}
description={t('emptyDesc')}
actionText={t('addGroup')}
onAction={() => setCreateOpen(true)}
/>
) : (
<div className='grid gap-4 lg:grid-cols-2'>
{(groupsQuery.data ?? []).map((group) => (
<Card key={group.id} className='border-dashed shadow-none'>
<CardHeader>
<div className='flex items-start justify-between gap-3'>
<div>
<CardTitle className='text-base'>{group.name}</CardTitle>
<CardDescription>
{group.active_node.name} · {group.active_node.ip}
</CardDescription>
</div>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? t('enabled') : t('disabled')}
</Badge>
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<p className='text-sm text-muted-foreground'>
{t('memberSummary', {
count: group.member_count,
proxy: group.default_proxied
? t('proxyOn')
: t('proxyOff'),
})}
</p>
<div className='flex flex-wrap gap-2'>
<Button asChild size='sm'>
<Link href={`/cloudflare/groups/${group.id}`}>
<Settings data-icon='inline-start' />
{t('manage')}
</Link>
</Button>
<Button
variant='outline'
size='sm'
onClick={() => syncMutation.mutate(group.id)}
>
<RefreshCw data-icon='inline-start' />
{t('sync')}
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setDeleteTarget(group)}
>
<Trash2 data-icon='inline-start' />
{t('delete')}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<SyncTasksPanel />
<GroupDialog
open={createOpen}
onOpenChange={setCreateOpen}
nodes={nodesQuery.data ?? []}
pending={createMutation.isPending}
onSubmit={(payload) => createMutation.mutate(payload)}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,260 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ErrorInline } from '@/components/layout/error';
import {
CloudflareService,
cloudflareQueryKey,
DnsAccountService,
type CloudflareConnectionSource,
} from '@/lib/services/openflare';
import { getErrorMessage } from '../../websites/components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'] as const;
export default function CloudflareSettingsPage() {
const t = useTranslations('cloudflare.settings');
const queryClient = useQueryClient();
const [source, setSource] =
useState<CloudflareConnectionSource>('dns_account');
const [dnsAccountID, setDNSAccountID] = useState('');
const [apiToken, setAPIToken] = useState('');
const connectionQuery = useQuery({
queryKey: [...cloudflareQueryKey, 'connection'],
queryFn: () => CloudflareService.getConnection(),
});
const accountsQuery = useQuery({
queryKey: dnsAccountsQueryKey,
queryFn: () => DnsAccountService.list(),
});
const cloudflareAccounts = useMemo(
() =>
(accountsQuery.data ?? []).filter(
(account) => account.type === 'cloudflare',
),
[accountsQuery.data],
);
useEffect(() => {
const connection = connectionQuery.data;
if (!connection?.configured) return;
if (connection.source) setSource(connection.source);
if (connection.dns_account_id)
setDNSAccountID(String(connection.dns_account_id));
}, [connectionQuery.data]);
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
};
const saveMutation = useMutation({
mutationFn: () =>
CloudflareService.saveConnection({
source,
dns_account_id: source === 'dns_account' ? Number(dnsAccountID) : 0,
api_token: source === 'standalone' ? apiToken : '',
}),
onSuccess: async () => {
toast.success(t('saved'));
setAPIToken('');
await refresh();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const verifyMutation = useMutation({
mutationFn: () => CloudflareService.verifyConnection(),
onSuccess: async () => {
toast.success(t('verified'));
await refresh();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const clearMutation = useMutation({
mutationFn: () => CloudflareService.clearConnection(),
onSuccess: async () => {
toast.success(t('cleared'));
setDNSAccountID('');
setAPIToken('');
await refresh();
},
onError: (error) => toast.error(getErrorMessage(error)),
});
return (
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex flex-col gap-4'>
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
{t('back')}
</Link>
</Button>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
</div>
{connectionQuery.isError ? (
<ErrorInline
message={getErrorMessage(connectionQuery.error)}
onRetry={() => void connectionQuery.refetch()}
/>
) : null}
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>{t('sourceTitle')}</CardTitle>
<CardDescription>{t('sourceDesc')}</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-source'>{t('source')}</FieldLabel>
<Select
value={source}
onValueChange={(value) =>
setSource(value as CloudflareConnectionSource)
}
>
<SelectTrigger id='cf-source' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='dns_account'>
{t('importDns')}
</SelectItem>
<SelectItem value='standalone'>
{t('standalone')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{source === 'dns_account' ? (
<Field>
<FieldLabel htmlFor='cf-dns-account'>
{t('dnsAccount')}
</FieldLabel>
<Select value={dnsAccountID} onValueChange={setDNSAccountID}>
<SelectTrigger id='cf-dns-account' className='w-full'>
<SelectValue placeholder={t('selectAccount')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{cloudflareAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
{t('dnsHint')}{' '}
<Link href='/dns-accounts'>{t('dnsLink')}</Link>
{t('dnsHintAfter')}
</FieldDescription>
</Field>
) : (
<Field>
<FieldLabel htmlFor='cf-api-token'>API Token</FieldLabel>
<Input
id='cf-api-token'
type='password'
autoComplete='new-password'
value={apiToken}
onChange={(event) => setAPIToken(event.target.value)}
placeholder={
connectionQuery.data?.configured
? t('tokenKeep')
: t('tokenEnter')
}
/>
</Field>
)}
<div className='flex flex-wrap items-center gap-2'>
<Button
onClick={() => saveMutation.mutate()}
disabled={
saveMutation.isPending ||
(source === 'dns_account' ? !dnsAccountID : !apiToken.trim())
}
>
<Save data-icon='inline-start' />
{t('save')}
</Button>
<Button
variant='outline'
onClick={() => verifyMutation.mutate()}
disabled={
!connectionQuery.data?.configured || verifyMutation.isPending
}
>
<ShieldCheck data-icon='inline-start' />
{t('test')}
</Button>
<Button
variant='destructive'
onClick={() => clearMutation.mutate()}
disabled={
!connectionQuery.data?.configured || clearMutation.isPending
}
>
<Trash2 data-icon='inline-start' />
{t('clear')}
</Button>
</div>
</FieldGroup>
</CardContent>
</Card>
<Alert>
<ShieldCheck />
<AlertTitle>{t('statusTitle')}</AlertTitle>
<AlertDescription>
{connectionQuery.data?.ready ? t('ready') : t('notReady')}
</AlertDescription>
</Alert>
</div>
);
}
@@ -0,0 +1,63 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { CapacityTrendPoint } from '@/lib/services/openflare';
import { formatPercent, formatTrendHour } from './dashboard-utils';
export function CapacityTrendChart({
points,
title,
description,
}: {
points: CapacityTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('dashboard.capacityTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
yAxisValueFormatter={formatPercent}
series={[
{
label: t('avgCpu'),
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
values: points.map((point) => point.average_cpu_usage_percent),
valueFormatter: formatPercent,
},
{
label: t('avgMemory'),
color: '#2563eb',
values: points.map((point) => point.average_memory_usage_percent),
valueFormatter: formatPercent,
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,15 @@
export {
calculateNiceAxisMax,
formatBytes,
formatBytesPerSecond,
formatCompactNumber,
formatPercent,
} from '@/lib/utils/metrics';
export function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
@@ -0,0 +1,177 @@
{
"Afghanistan": [65.99254367483304, 33.83349410470984],
"Albania": [20.035803636476235, 41.179283471608585],
"Algeria": [2.6175172555729325, 28.158202786113698],
"Angola": [17.563198329790602, -12.334557680834592],
"Argentina": [-65.15670576062132, -35.178771590782524],
"Armenia": [44.929379894609674, 40.29884592895426],
"Australia": [134.361180723562, -25.574955774991995],
"Austria": [14.10165807418182, 47.57042831528493],
"Azerbaijan": [47.636777782137415, 40.3311508093757],
"Bangladesh": [90.21009840315206, 23.88793565572049],
"Belarus": [28.04895281233843, 53.53432933090482],
"Belgium": [4.628129604331132, 50.646494813466056],
"Belize": [-88.72096138346826, 17.17073148225863],
"Benin": [2.323507643298733, 9.63048313950481],
"Bhutan": [90.40562361681495, 27.40133623508629],
"Bolivia": [-64.69104972918655, -16.70141507507877],
"Bosnia and Herzegovina": [17.762461846176542, 44.16398853722456],
"Botswana": [23.791788508036337, -22.185333985163894],
"Brazil": [-53.113858151998855, -10.827995867833263],
"Brunei": [114.54229268997476, 4.451218476864121],
"Bulgaria": [25.225364036830744, 42.77785706066581],
"Burkina Faso": [-1.7757430610210276, 12.260370131848955],
"Burundi": [29.873065896828848, -3.3802825292327188],
"Cambodia": [104.90702345739547, 12.717932969523966],
"Cameroon": [12.739868035578002, 5.692692453534944],
"Canada": [-102.31777616484295, 57.94457487676815],
"Central African Republic": [20.490781591297914, 6.563625165826992],
"Chad": [18.645815630757973, 15.32729896930035],
"Chile": [-71.34891661948937, -35.98028631773855],
"China": [103.79804331240364, 36.61094167680319],
"Colombia": [-73.07794094917405, 3.9235618261252663],
"Costa Rica": [-84.21742467558424, 9.986463158070535],
"Croatia": [16.458685366615978, 45.18567759026696],
"Cuba": [-78.8954025482625, 21.62136808001903],
"Cyprus": [33.02409208254226, 34.91304424540028],
"Czech Republic": [15.330767057694615, 49.70160772219921],
"Democratic Republic of the Congo": [23.64811688254194, -2.8775533309965056],
"Denmark": [-41.29088868109839, 74.72535220460091],
"Djibouti": [42.56788631653962, 11.745988995518626],
"Dominican Republic": [-70.48595892120278, 18.896879062003393],
"East Timor": [125.78222644041995, -8.858373836074785],
"Ecuador": [-78.38384779532852, -1.4454571869341688],
"Egypt": [29.86739912685152, 26.49286512797635],
"El Salvador": [-88.86593554283397, 13.734573052971413],
"Equatorial Guinea": [10.481670786028316, 1.5660796013647418],
"Eritrea": [38.835037148217566, 15.359809600209143],
"Estonia": [25.811350172874032, 58.69303019372469],
"Ethiopia": [39.60372599651433, 8.621595995236198],
"Fiji": [177.98415179869738, -17.827004078184217],
"Finland": [26.28958351526606, 64.52279699210293],
"France": [165.43817082439585, -21.285375782391018],
"Gabon": [11.774048593462979, -0.5859798536474902],
"Gambia": [-16.21304964555284, 13.37062695465985],
"Georgia": [43.48650319000057, 42.175027555818694],
"Germany": [10.380496796525602, 51.083853487429664],
"Ghana": [-1.2150159067556041, 7.94078880601944],
"Greece": [22.53435219348475, 39.517734832602365],
"Guatemala": [-90.35344030476544, 15.679044168855697],
"Guinea": [-10.943568403941383, 10.44078010915303],
"Guinea Bissau": [-14.929646023453557, 12.05088889829685],
"Guyana": [-58.978019347627985, 4.800329726037504],
"Haiti": [-72.67866559866681, 18.916195586860297],
"Honduras": [-86.65651796671759, 14.821029518594349],
"Hong Kong": [114.1747, 22.2783],
"Hungary": [19.410867145831855, 47.168542742918525],
"Iceland": [-18.509670948264166, 65.03034972428229],
"India": [79.58636900833257, 22.901586798523514],
"Indonesia": [101.52237404492608, -0.44681277438152994],
"Iran": [54.266246557433305, 32.57616445788996],
"Iraq": [43.74006501790808, 33.0341456153028],
"Ireland": [-8.117695195030898, 53.2341315203433],
"Israel": [35.241573878810335, 31.94758544099519],
"Italy": [12.16233842748749, 43.51281131035235],
"Ivory Coast": [-5.569771898429842, 7.626172775954684],
"Jamaica": [-77.32850498837172, 18.172957893241634],
"Japan": [137.97352453982398, 36.589925009528365],
"Jordan": [36.76131486689134, 31.237145277490367],
"Kashmir": [77.08733723957188, 35.415738932286466],
"Kazakhstan": [67.27815427190085, 48.155989088140196],
"Kenya": [37.795078751163835, 0.5967194222965451],
"Kosovo": [20.851519165302378, 42.51783231278167],
"Kuwait": [47.566177395781736, 29.316145203902064],
"Kyrgyzstan": [74.58224715420873, 41.48363475100716],
"Laos": [103.75263811609112, 18.494354885305256],
"Latvia": [24.89496154477673, 56.853143698564296],
"Lebanon": [35.88502277707876, 33.91528991779158],
"Lesotho": [28.24303757633595, -29.57351232727891],
"Liberia": [-9.325867879201954, 6.453996218817175],
"Libya": [18.008785486000946, 27.031457363755713],
"Lithuania": [23.872793668340726, 55.3142460584835],
"Luxembourg": [6.070340274408408, 49.76746140228473],
"Macao": [113.5439, 22.1987],
"Macau": [113.5439, 22.1987],
"Macedonia": [21.704642152886493, 41.60373573782414],
"Madagascar": [46.70172536870331, -19.374776628372288],
"Malawi": [34.288911351438394, -13.200363393826267],
"Malaysia": [114.69201508943004, 3.57724156754415],
"Mali": [-3.5321833143642736, 17.347810147640345],
"Mauritania": [-10.34307896458181, 20.259793585678146],
"Mexico": [-102.51849775988345, 23.943713432682014],
"Moldova": [28.446472517363826, 47.2017924696845],
"Mongolia": [103.06423533091326, 46.82560864032323],
"Montenegro": [19.233365413523355, 42.78395456212118],
"Morocco": [-8.459192831832421, 29.83932361976915],
"Mozambique": [35.53173663088568, -17.27297477254694],
"Myanmar": [96.49588816533908, 21.22201469728619],
"Namibia": [17.208945346875193, -22.132302890947404],
"Nepal": [83.9234099472594, 28.243335814044734],
"Netherlands": [5.646194278986089, 52.27734132368374],
"New Zealand": [170.54047059084724, -43.955381757717255],
"Nicaragua": [-85.03229930746807, 12.835548864379284],
"Niger": [9.384787308614277, 17.41758190683491],
"Nigeria": [8.074235448092892, 9.58958974174772],
"North Korea": [127.21551783512186, 40.144856985239926],
"Northern Cyprus": [33.39079188058263, 35.225753269439686],
"Norway": [13.994247649210363, 64.32249798565987],
"Oman": [56.08271927344639, 20.573738446088598],
"Pakistan": [69.32988114530586, 29.951585862562073],
"Panama": [-80.05970573973497, 8.521692369504816],
"Papua New Guinea": [144.22896759838426, -6.607045399379529],
"Paraguay": [-58.39900641582761, -23.223895588708068],
"Peru": [-74.38850244798846, -9.156316252308201],
"Philippines": [121.45754828266669, 15.948824272939182],
"Poland": [19.379892383012464, 52.11963086628078],
"Portugal": [-7.977838817691485, 39.6854900148663],
"Qatar": [51.170816022848, 25.32876608728452],
"Republic of Serbia": [20.766249889622852, 44.19998417744864],
"Republic of the Congo": [15.21207418503586, -0.8564004333096924],
"Romania": [24.971632366248883, 45.84560772065747],
"Russia": [99.05228241954488, 61.677043847105494],
"Rwanda": [29.91765145538139, -1.9953628964377972],
"Saudi Arabia": [44.5342871199886, 24.122854227726673],
"Senegal": [-14.472411019597182, 14.338613732351318],
"Sierra Leone": [-11.82013204896783, 8.575093438325089],
"Singapore": [103.8198, 1.3521],
"Slovakia": [19.47296461136286, 48.71540683451534],
"Slovenia": [14.771193461021065, 46.10807043333343],
"Solomon Islands": [160.156396875585, -9.628589831637283],
"Somalia": [45.706539856671256, 4.747960517798881],
"Somaliland": [46.25513292885051, 9.729995248781385],
"South Africa": [25.160630055943862, -29.008774283210798],
"South Korea": [127.87280447602005, 36.475157799464604],
"South Sudan": [30.253827921202955, 7.307277152400154],
"Spain": [-3.5708365129134876, 40.391564705240064],
"Sri Lanka": [80.70055059693914, 7.649883097905021],
"Sudan": [29.946243665370346, 15.99205876641883],
"Suriname": [-55.91029765824739, 4.126766010517478],
"Swaziland": [31.47664514492297, -26.563545981108437],
"Sweden": [16.752551082675275, 62.8354879894394],
"Switzerland": [8.205812860319684, 46.805544047201984],
"Syria": [38.50449031631095, 35.01769370592341],
"Taiwan": [120.9605, 23.6978],
"Tajikistan": [70.98712700685866, 38.55204366339685],
"Thailand": [101.01060253224972, 15.132319216387051],
"The Bahamas": [-78.02733865351972, 24.719920319716515],
"Togo": [0.949513544830056, 8.576560980195756],
"Trinidad and Tobago": [-61.270458833911896, 10.339930045317969],
"Tunisia": [9.546832905099203, 34.12673069029241],
"Turkey": [35.424986274948736, 38.998407240706534],
"Turkmenistan": [59.39292286662992, 39.1098173228857],
"Uganda": [32.36575488492427, 1.2757695499320103],
"Ukraine": [31.381193375918794, 49.01345298489402],
"United Arab Emirates": [54.30345828396012, 23.905698042805522],
"United Kingdom": [-36.367082569942795, -54.468348288665524],
"United Republic of Tanzania": [34.79490109621088, -6.276146393386636],
"United States of America": [-99.14669730710926, 39.52689381707972],
"Uruguay": [-56.01665573850212, -32.80651677440885],
"Uzbekistan": [63.148826727968626, 41.7502616897204],
"Vanuatu": [166.84228659210697, -15.266627310669035],
"Venezuela": [-66.1882174671549, 7.118299933056809],
"Vietnam": [106.29939096235873, 16.63088189612042],
"Western Sahara": [-12.218085234851758, 24.221135708939354],
"Yemen": [47.546351590294876, 15.935825615412996],
"Zambia": [27.775321721593084, -13.460954386993254],
"Zimbabwe": [29.850564118889274, -19.00677451616593]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {
DistributionItem,
TrafficDistributions,
} from '@/lib/services/openflare';
function toRankItems(items: DistributionItem[]) {
return items.map((item) => ({
label: item.key,
value: item.value,
}));
}
export function SourceDistributionChart({
items,
}: {
items: TrafficDistributions['source_countries'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{t('sourceTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('sourceDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color='#38bdf8'
emptyMessage={t('sourceEmpty')}
/>
</CardContent>
</Card>
);
}
export function StatusCodeDistributionChart({
items,
}: {
items: TrafficDistributions['status_codes'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{t('statusTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('statusDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items).map((item) => ({
...item,
label: t('httpLabel', { code: item.label }),
}))}
color='#f59e0b'
emptyMessage={t('statusEmpty')}
/>
</CardContent>
</Card>
);
}
export function TopDomainChart({
items,
}: {
items: TrafficDistributions['top_domains'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{t('domainTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('domainDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color='#34d399'
emptyMessage={t('domainEmpty')}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,169 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { DashboardNodeHealth } from '@/lib/services/openflare';
import { NodeStatusBadge } from '../../nodes/components/node-status-badge';
import {
formatRelativeTime,
getNodeStatusLabel,
getNodeStatusTone,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
isWSConnectedLastSeen,
} from '../../nodes/components/node-utils';
import { formatCompactNumber, formatPercent } from './dashboard-utils';
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeHealth');
const tn = useTranslations('nodes');
const sortedNodes = [...nodes].sort((left, right) => {
if (right.active_event_count !== left.active_event_count) {
return right.active_event_count - left.active_event_count;
}
const leftPressure = Math.max(
left.cpu_usage_percent,
left.memory_usage_percent,
left.storage_usage_percent,
);
const rightPressure = Math.max(
right.cpu_usage_percent,
right.memory_usage_percent,
right.storage_usage_percent,
);
return rightPressure - leftPressure;
});
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
<CardDescription className='text-xs'>
{t('description')}
</CardDescription>
</div>
<Button variant='outline' size='sm' className='h-8 text-xs' asChild>
<Link href='/nodes'>{t('openNodes')}</Link>
</Button>
</CardHeader>
<CardContent>
{sortedNodes.length === 0 ? (
<EmptyStateWithBorder
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>{t('colNode')}</TableHead>
<TableHead className='py-2 h-8'>{t('colStatus')}</TableHead>
<TableHead className='py-2 h-8'>{t('colCpuMem')}</TableHead>
<TableHead className='py-2 h-8'>{t('colReqErr')}</TableHead>
<TableHead className='py-2 h-8'>{t('colEvents')}</TableHead>
<TableHead className='py-2 h-8'>
{t('colHeartbeat')}
</TableHead>
<TableHead className='py-2 h-8 text-right'>
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedNodes.slice(0, 12).map((node) => (
<TableRow
key={node.node_id}
className='border-dashed align-top'
>
<TableCell className='py-3'>
<div className='space-y-1'>
<p className='font-medium'>{node.name}</p>
<p className='text-xs text-muted-foreground'>
{node.node_id}
</p>
<p className='text-xs text-muted-foreground'>
{node.geo_name || t('noGeo')}
</p>
</div>
</TableCell>
<TableCell className='py-3'>
<div className='flex flex-wrap gap-1.5'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status, tn)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(
node.openresty_status,
tn,
)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</div>
</TableCell>
<TableCell className='py-3 text-sm text-muted-foreground'>
{formatPercent(node.cpu_usage_percent)} /{' '}
{formatPercent(node.memory_usage_percent)}
</TableCell>
<TableCell className='py-3 text-sm text-muted-foreground'>
{formatCompactNumber(node.request_count)} /{' '}
{formatCompactNumber(node.error_count)}
</TableCell>
<TableCell className='py-3'>
<NodeStatusBadge
label={String(node.active_event_count)}
tone={
node.active_event_count > 0 ? 'danger' : 'success'
}
/>
</TableCell>
<TableCell className='py-3 text-xs text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at)
? t('wsConnected')
: node.last_seen_at
? formatRelativeTime(node.last_seen_at, tn)
: t('na')}
</TableCell>
<TableCell className='py-3 text-right'>
<Button
variant='ghost'
size='sm'
className='h-8 text-xs'
asChild
>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,71 @@
'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { DashboardNodeHealth } from '@/lib/services/openflare';
function buildNodeRankItems(
nodes: DashboardNodeHealth[],
selector: (node: DashboardNodeHealth) => number,
limit = 5,
) {
return [...nodes]
.sort((left, right) => {
const leftValue = selector(left);
const rightValue = selector(right);
if (leftValue === rightValue) {
return left.name.localeCompare(right.name, 'zh-CN');
}
return rightValue - leftValue;
})
.slice(0, limit)
.filter((node) => selector(node) > 0)
.map((node) => ({
label: node.name,
value: selector(node),
}));
}
export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeRank');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-6'>
<div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
{t('traffic')}
</p>
<RankChart
items={buildNodeRankItems(nodes, (node) => node.request_count)}
color='#38bdf8'
emptyMessage={t('trafficEmpty')}
/>
</div>
<div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
{t('pressure')}
</p>
<RankChart
items={buildNodeRankItems(nodes, (node) =>
Math.round(
Math.max(
node.cpu_usage_percent,
node.memory_usage_percent,
node.storage_usage_percent,
),
),
)}
color='#ef4444'
valueFormatter={(value) => `${value}%`}
emptyMessage={t('pressureEmpty')}
/>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,121 @@
'use client';
import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import type {
DashboardCapacity,
DashboardSummary,
DashboardTraffic,
} from '@/lib/services/openflare';
import { formatCompactNumber, formatPercent } from './dashboard-utils';
export function DashboardStatCards({
summary,
traffic,
capacity,
}: {
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
}) {
const t = useTranslations('dashboard.stats');
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
: 0;
return (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
{t('requests24h')}
</span>
<Activity className='size-4 text-primary' />
</CardHeader>
<CardContent className='space-y-2'>
<div className='text-2xl font-semibold tracking-tight'>
{formatCompactNumber(traffic.request_count)}
</div>
<p className='text-[10px] text-muted-foreground'>
{t('requestsMeta', {
visitors: formatCompactNumber(traffic.unique_visitors),
errors: formatCompactNumber(traffic.error_count),
qps: traffic.estimated_qps.toFixed(2),
})}
</p>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
{t('clusterCapacity')}
</span>
<Cpu className='size-4 text-primary' />
</CardHeader>
<CardContent className='space-y-2'>
<div className='text-2xl font-semibold tracking-tight'>
{formatPercent(capacity.average_cpu_usage_percent)}
<span className='ml-2 text-sm font-normal text-muted-foreground'>
CPU
</span>
</div>
<Progress
value={capacity.average_cpu_usage_percent}
aria-label={t('clusterCapacity')}
className='h-1.5'
/>
<p className='text-[10px] text-muted-foreground'>
{t('avgMemoryHighLoad', {
memory: formatPercent(capacity.average_memory_usage_percent),
cpu: capacity.high_cpu_nodes,
memoryNodes: capacity.high_memory_nodes,
storage: capacity.high_storage_nodes,
})}
</p>
</CardContent>
</Card>
<Card className='border-dashed shadow-none md:col-span-2 xl:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
{t('nodeOverview')}
</span>
<Server className='size-4 text-primary' />
</CardHeader>
<CardContent className='space-y-2'>
<div className='text-2xl font-semibold tracking-tight'>
{summary.online_nodes}
<span className='text-sm font-normal text-muted-foreground'>
{' '}
{t('onlineOf', { total: summary.total_nodes })}
</span>
</div>
<Progress
value={onlineRate}
aria-label={t('nodeOverview')}
className='h-1.5'
/>
<p className='text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1'>
<span className='inline-flex items-center gap-1'>
<Users className='size-3' />
{t('pending', { count: summary.pending_nodes })}
</span>
<span className='inline-flex items-center gap-1'>
<Gauge className='size-3' />
{t('unhealthyOffline', {
unhealthy: summary.unhealthy_nodes,
offline: summary.offline_nodes,
})}
</span>
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,88 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type {
CapacityTrendPoint,
NetworkTrendPoint,
} from '@/lib/services/openflare';
import { formatBytes, formatPercent, formatTrendHour } from './dashboard-utils';
/** 业务流量(来自访问日志)与容量趋势(节点 Agent 宿主机指标)合并展示。 */
export function TrafficCapacityTrendChart({
networkPoints,
capacityPoints,
}: {
networkPoints: NetworkTrendPoint[];
capacityPoints: CapacityTrendPoint[];
}) {
const t = useTranslations('dashboard.trafficCapacity');
const tc = useTranslations('dashboard.capacityTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
<TrendChart
labels={networkPoints.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
summaryScope='total'
summaryHint={t('networkHint')}
yAxisValueFormatter={formatBytes}
series={[
{
label: t('received'),
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: networkPoints.map((point) => point.bytes_received),
valueFormatter: formatBytes,
},
{
label: t('provided'),
color: '#38bdf8',
values: networkPoints.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
},
]}
/>
<TrendChart
labels={capacityPoints.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
summaryScope='average'
summaryHint={t('capacityHint')}
yAxisValueFormatter={formatPercent}
series={[
{
label: tc('avgCpu'),
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
values: capacityPoints.map(
(point) => point.average_cpu_usage_percent,
),
valueFormatter: formatPercent,
},
{
label: tc('avgMemory'),
color: '#2563eb',
values: capacityPoints.map(
(point) => point.average_memory_usage_percent,
),
valueFormatter: formatPercent,
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { TrafficTrendPoint } from '@/lib/services/openflare';
import { formatTrendHour } from './dashboard-utils';
export function TrafficTrendChart({
points,
title,
description,
}: {
points: TrafficTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('dashboard.trafficTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint={t('summaryHint')}
series={[
{
label: t('requests'),
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: points.map((point) => point.request_count),
},
{
label: t('status2xx'),
color: '#22c55e',
values: points.map((point) => point.status_2xx_count),
},
{
label: t('status4xx'),
color: '#f97316',
values: points.map((point) => point.status_4xx_count),
},
{
label: t('status5xx'),
color: '#ef4444',
values: points.map((point) => point.status_5xx_count),
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,674 @@
'use client';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import { MapChart, ScatterChart } from 'echarts/charts';
import { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useTheme } from 'next-themes';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import { EmptyState } from '@/components/layout/empty';
import type {
DashboardNodeHealth,
DistributionItem,
} from '@/lib/services/openflare';
import {
getNodeStatusLabel,
getOpenrestyStatusLabel,
} from '../../nodes/components/node-utils';
import countryCentroidsJson from './data/country-centroids.json';
import worldGeoJson from './data/world-geo.json';
echarts.use([
GeoComponent,
TooltipComponent,
MapChart,
ScatterChart,
CanvasRenderer,
]);
const fallbackCoordinates = [
[-122.4194, 37.7749],
[-46.6333, -23.5505],
[-0.1276, 51.5072],
[2.3522, 48.8566],
[77.209, 28.6139],
[121.4737, 31.2304],
[103.8198, 1.3521],
[151.2093, -33.8688],
[28.0473, -26.2041],
[139.6917, 35.6895],
] as const;
type Tone = 'healthy' | 'warning' | 'danger';
type WorldGeoJson = {
type: string;
features?: WorldFeature[];
};
type WorldFeature = {
properties?: {
name?: string;
};
geometry?: {
type?: 'Polygon' | 'MultiPolygon';
coordinates?: number[][][] | number[][][][];
};
};
type MapNodeDatum = {
id: number;
name: string;
geoName: string;
route: string;
derivedFromGeo: boolean;
requestCount: number;
errorCount: number;
activeEventCount: number;
status: DashboardNodeHealth['status'];
openrestyStatus: DashboardNodeHealth['openresty_status'];
value: [number, number, number];
itemStyle: {
color: string;
borderColor: string;
borderWidth: number;
};
emphasis: {
itemStyle: {
borderColor: string;
borderWidth: number;
};
};
};
type CountryRegionDatum = {
name: string;
value: number;
itemStyle: {
areaColor: string;
borderColor: string;
};
emphasis: {
itemStyle: {
areaColor: string;
borderColor: string;
};
};
};
let worldMapRegistrationAttempted = false;
let worldMapRegistrationSucceeded = false;
const baseWorldMapLayoutSizePercent = 118;
const baseWorldMapZoom = 1;
const worldMapAspectRatio = 5 / 3;
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/nodes';
}
return `/nodes/detail?id=${id}`;
}
function getNodeTone(node: DashboardNodeHealth): Tone {
if (
node.status === 'offline' ||
node.openresty_status === 'unhealthy' ||
node.active_event_count > 0
) {
return 'danger';
}
if (
node.cpu_usage_percent >= 80 ||
node.memory_usage_percent >= 85 ||
node.storage_usage_percent >= 85
) {
return 'warning';
}
return 'healthy';
}
const countryCentroids = countryCentroidsJson as Record<string, number[]>;
type CountryCentroid = [number, number];
function toCountryCentroid(
coords: number[] | undefined,
): CountryCentroid | null {
if (!coords || coords.length < 2) {
return null;
}
return [coords[0], coords[1]];
}
function resolveCountryCentroid(geoName: string): CountryCentroid | null {
const trimmed = geoName.trim();
if (!trimmed) {
return null;
}
const candidates = new Set<string>([trimmed]);
// ipinfo-style names: "Hong Kong, Hong Kong, HK"
for (const part of trimmed.split(',').map((item) => item.trim())) {
if (part) {
candidates.add(part);
}
}
// trailing ISO2 often present after city/region
const isoMatch = trimmed.match(/\b([A-Z]{2})\b/g);
if (isoMatch) {
for (const code of isoMatch) {
candidates.add(code);
}
}
for (const candidate of candidates) {
const direct = toCountryCentroid(countryCentroids[candidate]);
if (direct) {
return direct;
}
const lower = candidate.toLowerCase();
for (const [name, coordinates] of Object.entries(countryCentroids)) {
if (name.toLowerCase() === lower) {
return toCountryCentroid(coordinates);
}
}
}
// Soft contains match for longer composite labels (prefer longer country names).
const lowerFull = trimmed.toLowerCase();
let best: { length: number; coords: CountryCentroid } | null = null;
for (const [name, coordinates] of Object.entries(countryCentroids)) {
const lowerName = name.toLowerCase();
if (lowerFull.includes(lowerName) && lowerName.length >= 4) {
const coords = toCountryCentroid(coordinates);
if (!coords) {
continue;
}
if (!best || lowerName.length > best.length) {
best = { length: lowerName.length, coords };
}
}
}
return best?.coords ?? null;
}
function getNodeCoordinates(node: DashboardNodeHealth, index: number) {
if (
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number'
) {
return {
coordinates: [node.geo_longitude, node.geo_latitude] as [number, number],
derivedFromGeo: true,
};
}
const countryCentroid = resolveCountryCentroid(node.geo_name || '');
if (countryCentroid) {
return {
coordinates: countryCentroid,
derivedFromGeo: true,
};
}
return {
coordinates: [
...fallbackCoordinates[index % fallbackCoordinates.length],
] as [number, number],
derivedFromGeo: false,
};
}
function ensureWorldMapRegistered() {
if (worldMapRegistrationSucceeded || echarts.getMap('world')) {
worldMapRegistrationSucceeded = true;
return true;
}
if (worldMapRegistrationAttempted) {
return false;
}
worldMapRegistrationAttempted = true;
try {
const geoJson = worldGeoJson as WorldGeoJson;
if (
geoJson.type !== 'FeatureCollection' ||
!Array.isArray(geoJson.features)
) {
throw new Error('invalid world geojson payload');
}
echarts.registerMap(
'world',
geoJson as unknown as Parameters<typeof echarts.registerMap>[1],
);
if (!echarts.getMap('world')) {
throw new Error('world map registration failed');
}
worldMapRegistrationSucceeded = true;
return true;
} catch (error) {
const registrationError =
error instanceof Error
? error
: new Error('unknown world map registration error');
console.error('Failed to register ECharts world map', registrationError);
return false;
}
}
export function WorldStageMap({
nodes,
sourceCountries,
}: {
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const t = useTranslations('dashboard.map');
const tn = useTranslations('nodes');
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const router = useRouter();
const chartContainerRef = useRef<HTMLDivElement | null>(null);
const [mapReady, setMapReady] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const ready = ensureWorldMapRegistered();
setMapReady(ready);
setMapFailed(!ready);
}, []);
useEffect(() => {
if (!mapReady) {
return;
}
const container = chartContainerRef.current;
if (!container) {
return;
}
const updateSize = () => {
const nextWidth = container.clientWidth;
const nextHeight = container.clientHeight;
if (nextWidth <= 0 || nextHeight <= 0) {
return;
}
setContainerSize((previous) =>
previous.width === nextWidth && previous.height === nextHeight
? previous
: { width: nextWidth, height: nextHeight },
);
};
updateSize();
const frameId = window.requestAnimationFrame(updateSize);
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateSize);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', updateSize);
};
}
const observer = new ResizeObserver(() => {
updateSize();
});
observer.observe(container);
return () => {
window.cancelAnimationFrame(frameId);
observer.disconnect();
};
}, [mapReady]);
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#1e293b',
borderColor: 'rgba(148,163,184,0.32)',
labelColor: '#e2e8f0',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#f87171',
healthyBorder: '#6ee7b7',
warningBorder: '#fcd34d',
dangerBorder: '#fca5a5',
}
: {
areaColor: '#e2e8f0',
borderColor: 'rgba(100,116,139,0.45)',
labelColor: '#18181b',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#ef4444',
healthyBorder: '#6ee7b7',
warningBorder: '#fcd34d',
dangerBorder: '#fca5a5',
},
[isDark],
);
const mapNodes = useMemo<MapNodeDatum[]>(
() =>
nodes.map((node, index) => {
const { coordinates, derivedFromGeo } = getNodeCoordinates(node, index);
const tone = getNodeTone(node);
const toneColor =
tone === 'healthy'
? mapPalette.healthyColor
: tone === 'warning'
? mapPalette.warningColor
: mapPalette.dangerColor;
const toneBorder =
tone === 'healthy'
? mapPalette.healthyBorder
: tone === 'warning'
? mapPalette.warningBorder
: mapPalette.dangerBorder;
return {
id: node.id,
name: node.name,
geoName: node.geo_name || node.name,
route: buildNodeDetailHref(node.id),
derivedFromGeo,
requestCount: node.request_count,
errorCount: node.error_count,
activeEventCount: node.active_event_count,
status: node.status,
openrestyStatus: node.openresty_status,
value: [
coordinates[0],
coordinates[1],
Math.max(node.request_count, 1),
],
itemStyle: {
color: toneColor,
borderColor: toneBorder,
borderWidth: 1.5,
},
emphasis: {
itemStyle: {
borderColor: toneBorder,
borderWidth: 2,
},
},
};
}),
[mapPalette, nodes],
);
const countryRegions = useMemo<CountryRegionDatum[]>(() => {
const maxRequests = Math.max(
1,
...sourceCountries.map((item) => item.value || 0),
);
return sourceCountries
.filter((item) => item.key && item.value > 0)
.map((item) => {
const intensity = Math.max(0.18, item.value / maxRequests);
const areaOpacity = Number((0.14 + intensity * 0.58).toFixed(3));
const borderOpacity = Number((0.22 + intensity * 0.48).toFixed(3));
const areaColor = isDark
? `rgba(96, 165, 250, ${areaOpacity})`
: `rgba(59, 130, 246, ${areaOpacity})`;
const borderColor = isDark
? `rgba(147, 197, 253, ${borderOpacity})`
: `rgba(59, 130, 246, ${borderOpacity})`;
return {
name: item.key,
value: item.value,
itemStyle: {
areaColor,
borderColor,
},
emphasis: {
itemStyle: {
areaColor,
borderColor,
},
},
};
});
}, [isDark, sourceCountries]);
const responsiveMapScale = useMemo(() => {
const { width, height } = containerSize;
if (width <= 0 || height <= 0) {
return 1;
}
const expectedWidth = height * worldMapAspectRatio;
const fitWidth = Math.min(width, expectedWidth);
const widthScale = Math.min(Math.max(fitWidth / 180, 0.72), 1.08);
const heightScale = Math.min(Math.max(height / 252, 0.72), 1.08);
return Number(Math.min(widthScale, heightScale).toFixed(3));
}, [containerSize]);
const computedLayoutSize = useMemo(
() => `${Math.round(baseWorldMapLayoutSizePercent * responsiveMapScale)}%`,
[responsiveMapScale],
);
const computedZoom = useMemo(
() => Number((baseWorldMapZoom * responsiveMapScale).toFixed(3)),
[responsiveMapScale],
);
const mapOption = useMemo<EChartsCoreOption>(
() => ({
animation: false,
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
transitionDuration: 0,
backgroundColor: isDark
? 'rgba(24,24,27,0.96)'
: 'rgba(255,255,255,0.98)',
borderColor: isDark ? 'rgba(63,63,70,0.8)' : 'rgba(228,228,231,0.9)',
borderWidth: 1,
textStyle: {
color: isDark ? '#fafafa' : '#18181b',
fontSize: 12,
},
formatter: (params: unknown) => {
const payload = params as {
data?: MapNodeDatum | CountryRegionDatum;
seriesType?: string;
name?: string;
};
const data = payload.data;
if (
payload.seriesType !== 'scatter' &&
data &&
'value' in data &&
typeof data.value === 'number'
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
].join('');
}
if (!data || !('requestCount' in data)) {
return '';
}
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · ${t('presetPin')}`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>${t('requestsErrors', {
requests: data.requestCount.toLocaleString('zh-CN'),
errors: data.errorCount.toLocaleString('zh-CN'),
})}</div>`,
`<div>${t('eventsStatus', {
events: data.activeEventCount,
status: getNodeStatusLabel(data.status, tn),
})}</div>`,
`<div>${t('openrestyStatus', {
status: getOpenrestyStatusLabel(data.openrestyStatus, tn),
})}</div>`,
].join('');
},
},
geo: {
map: 'world',
roam: true,
silent: true,
layoutCenter: ['50%', '50%'],
layoutSize: computedLayoutSize,
zoom: computedZoom,
scaleLimit: {
min: Math.max(Number((computedZoom * 0.78).toFixed(3)), 0.35),
max: Math.max(Number((computedZoom * 2.2).toFixed(3)), 1.4),
},
regions: countryRegions,
itemStyle: {
areaColor: mapPalette.areaColor,
borderColor: mapPalette.borderColor,
borderWidth: 0.7,
},
emphasis: {
disabled: true,
},
},
series: [
{
type: 'map',
map: 'world',
geoIndex: 0,
data: countryRegions,
silent: true,
z: 1,
emphasis: {
disabled: true,
},
},
{
type: 'scatter',
coordinateSystem: 'geo',
data: mapNodes,
z: 3,
progressive: 64,
large: true,
largeThreshold: 24,
symbolSize: (value: unknown) => {
const size =
Array.isArray(value) && typeof value[2] === 'number'
? value[2]
: 1;
const responsiveBase = 8 + Math.log10(size + 1) * 3.6;
return Math.max(
7,
Math.min(18, responsiveBase * Math.max(responsiveMapScale, 0.88)),
);
},
label: {
show: false,
color: mapPalette.labelColor,
fontSize: 11,
},
emphasis: {
scale: 1.12,
label: {
show: mapNodes.length > 0 && mapNodes.length <= 5,
position: 'right',
distance: 8,
formatter: '{b}',
backgroundColor: isDark
? 'rgba(24,24,27,0.88)'
: 'rgba(255,255,255,0.94)',
borderColor: isDark
? 'rgba(63,63,70,0.7)'
: 'rgba(228,228,231,0.9)',
borderWidth: 1,
borderRadius: 999,
padding: [4, 8],
},
},
},
],
}),
[
computedLayoutSize,
computedZoom,
countryRegions,
isDark,
mapNodes,
mapPalette,
responsiveMapScale,
t,
tn,
],
);
if (!mapReady) {
return (
<div className='flex h-full items-center justify-center'>
<EmptyState
title={mapFailed ? t('loadFailed') : t('loading')}
description={mapFailed ? t('loadFailedDesc') : t('loadingDesc')}
/>
</div>
);
}
const chartReady = containerSize.width > 0 && containerSize.height > 0;
return (
<div ref={chartContainerRef} className='h-full min-h-0 w-full'>
{chartReady ? (
<ReactEChartsCore
echarts={echarts}
option={mapOption}
notMerge
lazyUpdate
opts={{ renderer: 'canvas' }}
onEvents={{
click: (params: { data?: MapNodeDatum }) => {
if (params.data?.route) {
router.push(params.data.route);
}
},
}}
style={{
height: containerSize.height,
width: containerSize.width,
}}
/>
) : (
<div className='flex h-full items-center justify-center'>
<EmptyState
title={t('loading')}
description={t('measuring')}
iconSize='sm'
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,331 @@
'use client';
import dynamic from 'next/dynamic';
import { type ComponentType, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
Activity,
Cpu,
Globe2,
HardDrive,
MemoryStick,
Network,
Server,
ShieldCheck,
} from 'lucide-react';
import { EmptyState } from '@/components/layout/empty';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import type {
DashboardCapacity,
DashboardNodeHealth,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
import { formatCompactNumber, formatPercent } from './dashboard-utils';
const WorldStageMap = dynamic(
() => import('./world-stage-map').then((module) => module.WorldStageMap),
{ ssr: false },
);
const LEGEND_ITEMS = [
{ dot: 'bg-blue-500', key: 'legendSource' },
{ dot: 'bg-emerald-500', key: 'legendHealthy' },
{ dot: 'bg-amber-500', key: 'legendPressure' },
{ dot: 'bg-destructive', key: 'legendUnhealthy' },
] as const;
function SummaryMetric({
label,
value,
hint,
icon: Icon,
progress,
className,
}: {
label: string;
value: string;
hint: string;
icon: ComponentType<{ className?: string }>;
progress?: number;
className?: string;
}) {
return (
<div
className={cn(
'flex min-w-0 flex-col gap-2 rounded-lg border border-dashed bg-muted/15 p-3',
className,
)}
>
<div className='flex items-center justify-between gap-2'>
<span className='truncate text-xs text-muted-foreground'>{label}</span>
<Icon className='size-3.5 shrink-0 text-primary/60' />
</div>
<span className='text-xl font-semibold tabular-nums leading-none tracking-tight'>
{value}
</span>
{typeof progress === 'number' ? (
<Progress value={progress} aria-label={label} className='h-1' />
) : null}
<span className='truncate text-[10px] text-muted-foreground'>{hint}</span>
</div>
);
}
function DetailMetric({
label,
value,
hint,
icon: Icon,
progress,
}: {
label: string;
value: string;
hint: string;
icon: ComponentType<{ className?: string }>;
progress?: number;
}) {
return (
<div className='flex min-w-0 flex-col gap-1 rounded-lg border border-dashed bg-background/60 px-3 py-2.5'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='truncate text-[11px] text-muted-foreground'>{label}</p>
<p className='mt-1 text-lg font-semibold tabular-nums leading-none tracking-tight'>
{value}
</p>
</div>
<Icon className='size-3.5 shrink-0 text-muted-foreground' />
</div>
{typeof progress === 'number' ? (
<Progress value={progress} aria-label={label} className='h-1' />
) : null}
<p className='truncate text-[10px] text-muted-foreground'>{hint}</p>
</div>
);
}
export function WorldStage({
summary,
traffic,
capacity,
nodes,
sourceCountries,
}: {
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const t = useTranslations('dashboard.world');
const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false);
useEffect(() => {
if (shouldRenderMap) {
return;
}
const mapViewport = mapViewportRef.current;
if (!mapViewport || typeof IntersectionObserver === 'undefined') {
setShouldRenderMap(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldRenderMap(true);
observer.disconnect();
}
},
{ rootMargin: '120px 0px' },
);
observer.observe(mapViewport);
return () => {
observer.disconnect();
};
}, [shouldRenderMap]);
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
: 0;
const healthyNodes = Math.max(
0,
summary.online_nodes - summary.unhealthy_nodes,
);
const healthyRate =
summary.total_nodes > 0 ? (healthyNodes / summary.total_nodes) * 100 : 0;
const geoConfiguredNodes = nodes.filter(
(node) =>
typeof node.geo_latitude === 'number' &&
typeof node.geo_longitude === 'number',
).length;
const mapModeLabel =
sourceCountries.length > 0
? t('modeVisitorSource')
: geoConfiguredNodes > 0
? t('modeNodeCoords')
: t('modeCoverage');
const headlineMetrics = [
{
label: t('onlineCoverage'),
value: formatPercent(onlineRate),
hint: t('onlineHint', {
online: summary.online_nodes,
total: summary.total_nodes,
}),
icon: Server,
progress: onlineRate,
},
{
label: t('runHealth'),
value: formatPercent(healthyRate),
hint: t('unhealthyCount', { count: summary.unhealthy_nodes }),
icon: ShieldCheck,
progress: healthyRate,
},
{
label: t('requests24h'),
value: formatCompactNumber(traffic.request_count),
hint: t('qpsReported', {
qps: traffic.estimated_qps.toFixed(1),
count: traffic.reported_nodes,
}),
icon: Activity,
},
{
label: t('nodeCoords'),
value: formatCompactNumber(geoConfiguredNodes),
hint:
geoConfiguredNodes > 0
? t('sourceCountries', { count: sourceCountries.length })
: t('fallbackPins'),
icon: Network,
},
] as const;
const detailMetrics = [
{
label: t('avgCpu'),
value: formatPercent(capacity.average_cpu_usage_percent),
hint: t('highCount', { count: capacity.high_cpu_nodes }),
icon: Cpu,
progress: capacity.average_cpu_usage_percent,
},
{
label: t('avgMemory'),
value: formatPercent(capacity.average_memory_usage_percent),
hint: t('highCount', { count: capacity.high_memory_nodes }),
icon: MemoryStick,
progress: capacity.average_memory_usage_percent,
},
{
label: t('highStorage'),
value: formatCompactNumber(capacity.high_storage_nodes),
hint: t('offlinePending', {
offline: summary.offline_nodes,
pending: summary.pending_nodes,
}),
icon: HardDrive,
},
] as const;
return (
<Card className='overflow-hidden border-dashed shadow-none'>
<CardHeader className='gap-3 space-y-0 pb-3'>
<div className='flex flex-wrap items-start justify-between gap-x-4 gap-y-3'>
<div className='min-w-0'>
<CardTitle className='flex items-center gap-1.5 text-sm font-semibold'>
<Globe2 className='size-4 shrink-0 text-primary' />
{t('title')}
<Badge
variant='outline'
className='ml-1 text-[10px] font-normal text-muted-foreground'
>
{mapModeLabel}
</Badge>
</CardTitle>
<CardDescription className='mt-1 text-xs'>
{t('description')}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5'>
{LEGEND_ITEMS.map((item) => (
<span
key={item.key}
className='inline-flex items-center gap-1 text-[10px] text-muted-foreground'
>
<span className={cn('size-1.5 rounded-full', item.dot)} />
{t(item.key)}
</span>
))}
</div>
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4 pt-0'>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
{headlineMetrics.map((metric) => (
<SummaryMetric key={metric.label} {...metric} />
))}
</div>
<div className='grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem] xl:items-stretch'>
<div className='flex min-w-0 items-center justify-center rounded-lg border border-dashed bg-muted/10 p-3'>
<div
ref={mapViewportRef}
className='relative aspect-[5/3] min-h-[260px] w-full max-w-[720px] overflow-hidden rounded-md bg-background/60 sm:min-h-0'
>
{shouldRenderMap ? (
<WorldStageMap
nodes={nodes}
sourceCountries={sourceCountries}
/>
) : (
<div className='flex h-full items-center justify-center px-4'>
<EmptyState
title={t('mapPreparing')}
description={t('mapPreparingDesc')}
iconSize='sm'
/>
</div>
)}
{shouldRenderMap && nodes.length === 0 ? (
<div className='pointer-events-none absolute inset-x-3 bottom-2 z-10'>
<p className='rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm'>
{t('noNodesOverlay')}
</p>
</div>
) : null}
</div>
</div>
<div className='grid gap-2 sm:grid-cols-2 xl:grid-cols-1'>
{detailMetrics.map((metric) => (
<DetailMetric key={metric.label} {...metric} />
))}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,31 @@
import type { LucideIcon } from 'lucide-react';
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
interface PlaceholderPageProps {
title: string;
icon: LucideIcon;
}
export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center gap-2'>
<Icon className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>{title}</h1>
</div>
<Card>
<CardHeader>
<CardTitle className='text-base'>开发中</CardTitle>
<CardDescription>该功能正在迁移开发中,敬请期待。</CardDescription>
</CardHeader>
</Card>
</div>
);
}
@@ -0,0 +1,108 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
function buildCleanupSchema(t: (key: string) => string) {
return z.object({
keepCount: z.number().int().min(3, t('keepMin')),
});
}
type CleanupFormValues = z.infer<ReturnType<typeof buildCleanupSchema>>;
interface CleanupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (keepCount: number) => void;
loading: boolean;
}
export function CleanupDialog({
open,
onOpenChange,
onConfirm,
loading,
}: CleanupDialogProps) {
const t = useTranslations('configVersions');
const tc = useTranslations('common');
const form = useForm<CleanupFormValues>({
resolver: zodResolver(buildCleanupSchema(t)),
defaultValues: { keepCount: 10 },
});
useEffect(() => {
if (open) {
form.reset({ keepCount: 10 });
}
}, [form, open]);
const handleConfirm = form.handleSubmit((values) => {
onConfirm(values.keepCount);
});
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>{t('cleanupDesc')}</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-2'>
<Label htmlFor='keepCount'>{t('keepCount')}</Label>
<Input
id='keepCount'
type='number'
min={3}
disabled={loading}
{...form.register('keepCount', { valueAsNumber: true })}
/>
<p className='text-xs text-muted-foreground'>{t('keepHint')}</p>
{form.formState.errors.keepCount ? (
<p className='text-xs text-destructive'>
{form.formState.errors.keepCount.message}
</p>
) : null}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{tc('cancel')}
</AlertDialogCancel>
<Button
variant='destructive'
onClick={() => void handleConfirm()}
disabled={loading}
>
{loading ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
{t('cleaning')}
</>
) : (
t('confirmCleanup')
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,204 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { ConfigDiffResult } from '@/lib/services/openflare';
interface DiffDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
diff: ConfigDiffResult | null;
loading: boolean;
error: string | null;
}
function DiffChipList({ title, items }: { title: string; items: string[] }) {
const t = useTranslations('configVersions');
return (
<div className='space-y-2'>
<div className='flex items-center justify-between gap-2'>
<p className='text-xs font-semibold'>{title}</p>
<Badge variant='outline' className='text-[10px] rounded-full'>
{t('itemCount', { count: items.length })}
</Badge>
</div>
{items.length > 0 ? (
<div className='flex flex-wrap gap-1.5'>
{items.map((item) => (
<Badge
key={item}
variant='secondary'
className='text-[10px] font-normal'
>
{item}
</Badge>
))}
</div>
) : (
<p className='text-xs text-muted-foreground'>{t('noChange')}</p>
)}
</div>
);
}
export function DiffDialog({
open,
onOpenChange,
diff,
loading,
error,
}: DiffDialogProps) {
const t = useTranslations('configVersions');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>{t('diffTitle')}</DialogTitle>
<DialogDescription>{t('diffDesc')}</DialogDescription>
</DialogHeader>
{loading ? (
<div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
{t('loadingDiff')}
</div>
) : error ? (
<p className='text-sm text-destructive'>{error}</p>
) : diff ? (
<div className='space-y-5'>
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('activeVersion')}
</p>
<p className='mt-1 text-sm font-medium'>
{diff.active_version || t('none')}
</p>
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('addedDomains')}
</p>
<p className='mt-1 text-lg font-semibold text-emerald-600'>
{diff.added_domains.length}
</p>
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('removedDomains')}
</p>
<p className='mt-1 text-lg font-semibold text-amber-600'>
{diff.removed_domains.length}
</p>
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('modifiedDomains')}
</p>
<p className='mt-1 text-lg font-semibold text-blue-600'>
{diff.modified_domains.length}
</p>
</div>
</div>
<div className='grid gap-4 md:grid-cols-3'>
<DiffChipList
title={t('addedDomains')}
items={diff.added_domains}
/>
<DiffChipList
title={t('removedDomains')}
items={diff.removed_domains}
/>
<DiffChipList
title={t('modifiedDomains')}
items={diff.modified_domains}
/>
</div>
<div className='flex flex-wrap gap-2'>
<Badge
variant={diff.main_config_changed ? 'destructive' : 'outline'}
>
{diff.main_config_changed
? t('mainChanged')
: t('mainUnchanged')}
</Badge>
<Badge
variant={diff.waf_config_changed ? 'destructive' : 'outline'}
>
{diff.waf_config_changed ? t('wafChanged') : t('wafUnchanged')}
</Badge>
<Badge variant='outline'>
{t('websiteDelta', {
from: diff.active_website_count,
to: diff.current_website_count,
})}
</Badge>
</div>
{diff.changed_option_details.length > 0 ? (
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'>
{t('optionKey')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('activeValue')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('pendingValue')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diff.changed_option_details.map((item) => (
<TableRow key={item.key} className='border-dashed'>
<TableCell className='text-xs font-medium'>
{item.key}
</TableCell>
<TableCell className='text-xs font-mono text-muted-foreground'>
{item.previous_value === ''
? t('emptyValue')
: item.previous_value}
</TableCell>
<TableCell className='text-xs font-mono text-muted-foreground'>
{item.current_value === ''
? t('emptyValue')
: item.current_value}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className='text-xs text-muted-foreground'>
{t('noOptionChanges')}
</p>
)}
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,155 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CodeBlock } from '@/components/ui/code-block';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { ConfigPreviewResult } from '@/lib/services/openflare';
interface PreviewSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
preview: ConfigPreviewResult | null;
loading: boolean;
error: string | null;
publishing: boolean;
canPublish: boolean;
onPublish: () => void;
}
export function PreviewSheet({
open,
onOpenChange,
preview,
loading,
error,
publishing,
canPublish,
onPublish,
}: PreviewSheetProps) {
const t = useTranslations('configVersions');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
<SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>{t('previewTitle')}</SheetTitle>
<SheetDescription>{t('previewDesc')}</SheetDescription>
</SheetHeader>
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
{loading ? (
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
{t('loadingPreview')}
</div>
) : error ? (
<p className='text-sm text-destructive'>{error}</p>
) : preview ? (
<div className='space-y-5 pb-4'>
<div className='flex flex-wrap gap-2'>
<Badge variant='outline' className='rounded-full text-[10px]'>
{t('routeCount', { count: preview.route_count })}
</Badge>
<Badge variant='outline' className='rounded-full text-[10px]'>
{t('websiteCount', { count: preview.website_count })}
</Badge>
<Badge
variant='outline'
className='rounded-full text-[10px] font-mono'
>
{preview.checksum.slice(0, 16)}...
</Badge>
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>{t('mainConfig')}</p>
<CodeBlock
code={preview.main_config}
language='nginx'
className='my-0 text-xs'
/>
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>{t('routeConfig')}</p>
<CodeBlock
code={preview.rendered_config}
language='nginx'
className='my-0 text-xs'
/>
</div>
{preview.support_files.length > 0 ? (
<div className='space-y-2'>
<p className='text-xs font-semibold'>
{t('supportFiles', {
count: preview.support_files.length,
})}
</p>
{preview.support_files.map((file) => (
<details
key={file.path}
className='rounded-lg border border-dashed px-3 py-2'
>
<summary className='cursor-pointer text-xs font-medium'>
{file.path}
</summary>
<CodeBlock
code={file.content}
language='text'
className='my-2 text-xs'
/>
</details>
))}
</div>
) : (
<p className='text-xs text-muted-foreground'>
{t('noSupportFiles')}
</p>
)}
{!canPublish ? (
<p className='text-xs text-muted-foreground'>
{t('noChanges')}
</p>
) : null}
</div>
) : null}
</div>
<SheetFooter className='px-5 py-4 border-t'>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={publishing}
>
{t('close')}
</Button>
<Button
onClick={onPublish}
disabled={!canPublish || publishing || loading}
>
{publishing ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
{t('publishing')}
</>
) : (
t('confirmPublish')
)}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CodeBlock } from '@/components/ui/code-block';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import {
type ConfigVersionDetail,
ConfigVersionService,
type ConfigVersionSummary,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
interface VersionSnapshotSheetProps {
version: ConfigVersionSummary | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VersionSnapshotSheet({
version,
open,
onOpenChange,
}: VersionSnapshotSheetProps) {
const t = useTranslations('configVersions');
const [detail, setDetail] = useState<ConfigVersionDetail | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !version) {
setDetail(null);
return;
}
let cancelled = false;
setLoading(true);
ConfigVersionService.getById(version.id)
.then((data) => {
if (!cancelled) setDetail(data);
})
.catch((err) => {
if (!cancelled) {
toast.error(t('snapshotLoadFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, version, t]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
<SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>
{version
? t('snapshotVersion', { version: version.version })
: t('snapshotTitle')}
</SheetTitle>
<SheetDescription>{t('snapshotDesc')}</SheetDescription>
</SheetHeader>
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
{loading ? (
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
{t('loadingSnapshot')}
</div>
) : detail ? (
<div className='space-y-5 pb-4'>
<div className='flex flex-wrap gap-2'>
{detail.is_active ? (
<Badge className='rounded-full text-[10px]'>
{t('active')}
</Badge>
) : (
<Badge variant='outline' className='rounded-full text-[10px]'>
{t('history')}
</Badge>
)}
<Badge
variant='outline'
className='rounded-full text-[10px] font-mono'
>
{detail.checksum}
</Badge>
</div>
<div className='grid gap-3 sm:grid-cols-2 text-xs'>
<div>
<p className='text-muted-foreground'>{t('createdBy')}</p>
<p className='font-medium'>
{detail.created_by || t('system')}
</p>
</div>
<div>
<p className='text-muted-foreground'>{t('createdAt')}</p>
<p className='font-medium'>
{formatDateTime(detail.created_at)}
</p>
</div>
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>{t('mainConfig')}</p>
<CodeBlock
code={detail.main_config}
language='nginx'
className='my-0 text-xs'
/>
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>{t('routeConfig')}</p>
<CodeBlock
code={detail.rendered_config}
language='nginx'
className='my-0 text-xs'
/>
</div>
</div>
) : null}
</div>
<SheetFooter className='px-5 py-4 border-t'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('close')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,532 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Eye,
GitCompare,
History,
Loader2,
Play,
RefreshCw,
Trash2,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
type ConfigDiffResult,
type ConfigPreviewResult,
ConfigVersionService,
type ConfigVersionSummary,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { CleanupDialog } from './components/cleanup-dialog';
import { DiffDialog } from './components/diff-dialog';
import { PreviewSheet } from './components/preview-sheet';
import { VersionSnapshotSheet } from './components/version-snapshot-sheet';
function truncateChecksum(checksum: string) {
if (!checksum) return '—';
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum;
}
function hasConfigDiff(diff: ConfigDiffResult) {
return (
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.added_sites.length > 0 ||
diff.removed_sites.length > 0 ||
diff.modified_sites.length > 0 ||
diff.main_config_changed ||
diff.waf_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
export default function ConfigVersionsPage() {
const t = useTranslations('configVersions');
const tc = useTranslations('common');
const [versions, setVersions] = useState<ConfigVersionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [preview, setPreview] = useState<ConfigPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [diffOpen, setDiffOpen] = useState(false);
const [diff, setDiff] = useState<ConfigDiffResult | null>(null);
const [diffLoading, setDiffLoading] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [snapshotVersion, setSnapshotVersion] =
useState<ConfigVersionSummary | null>(null);
const [snapshotOpen, setSnapshotOpen] = useState(false);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [forcePublishConfirmOpen, setForcePublishConfirmOpen] = useState(false);
const [activateTarget, setActivateTarget] =
useState<ConfigVersionSummary | null>(null);
const [cleanupOpen, setCleanupOpen] = useState(false);
const [publishing, setPublishing] = useState(false);
const [activating, setActivating] = useState(false);
const [cleaning, setCleaning] = useState(false);
const canPublish = useMemo(
() =>
Boolean(
preview && preview.route_count > 0 && diff && hasConfigDiff(diff),
),
[preview, diff],
);
const sortedVersions = useMemo(
() =>
[...versions].sort(
(left, right) =>
new Date(right.created_at).getTime() -
new Date(left.created_at).getTime(),
),
[versions],
);
const fetchVersions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await ConfigVersionService.list();
setVersions(data);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void fetchVersions();
}, [fetchVersions]);
const loadPreviewData = async () => {
setPreviewLoading(true);
setPreviewError(null);
setDiffLoading(true);
setDiffError(null);
try {
const [previewData, diffData] = await Promise.all([
ConfigVersionService.preview(),
ConfigVersionService.diff(),
]);
setPreview(previewData);
setDiff(diffData);
} catch (err) {
const message =
err instanceof Error ? err.message : t('previewLoadFailed');
setPreviewError(message);
setDiffError(message);
} finally {
setPreviewLoading(false);
setDiffLoading(false);
}
};
const handleOpenPreview = async () => {
setPreviewOpen(true);
if (!preview) {
await loadPreviewData();
}
};
const handleOpenDiff = async () => {
setDiffOpen(true);
if (!diff) {
setDiffLoading(true);
setDiffError(null);
try {
const diffData = await ConfigVersionService.diff();
setDiff(diffData);
} catch (err) {
setDiffError(err instanceof Error ? err.message : t('diffLoadFailed'));
} finally {
setDiffLoading(false);
}
}
};
const handlePublish = async (force = false) => {
setPublishing(true);
try {
const version = await ConfigVersionService.publish(force);
toast.success(t('publishSuccess'), {
description: t('publishSuccessDesc', { version: version.version }),
});
setPreviewOpen(false);
setPublishConfirmOpen(false);
setForcePublishConfirmOpen(false);
setPreview(null);
setDiff(null);
await fetchVersions();
} catch (err) {
toast.error(t('publishFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setPublishing(false);
}
};
const handleActivate = async () => {
if (!activateTarget) return;
setActivating(true);
try {
const version = await ConfigVersionService.activate(activateTarget.id);
toast.success(t('activateSuccess'), {
description: t('activateSuccessDesc', { version: version.version }),
});
setActivateTarget(null);
await fetchVersions();
} catch (err) {
toast.error(t('activateFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setActivating(false);
}
};
const handleCleanup = async (keepCount: number) => {
setCleaning(true);
try {
const result = await ConfigVersionService.cleanup({
keep_count: keepCount,
});
toast.success(t('cleanupDone'), {
description: t('cleanupDoneDesc', { count: result.deleted_count }),
});
setCleanupOpen(false);
await fetchVersions();
} catch (err) {
toast.error(t('cleanupFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setCleaning(false);
}
};
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<History className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('description')}</p>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => void fetchVersions()}
disabled={loading}
>
<RefreshCw
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
/>
{t('refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setCleanupOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
{t('cleanupOld')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => void handleOpenDiff()}
>
<GitCompare className='size-3.5 mr-1' />
{t('viewDiff')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setForcePublishConfirmOpen(true)}
>
{t('forcePublish')}
</Button>
<Button size='sm' onClick={() => void handleOpenPreview()}>
<Eye className='size-3.5 mr-1' />
{t('previewPublish')}
</Button>
</div>
</div>
{error ? (
<ErrorInline message={error} onRetry={() => void fetchVersions()} />
) : null}
<div className='border border-dashed shadow-none rounded-lg overflow-hidden bg-background'>
{loading ? (
<LoadingStateWithBorder />
) : sortedVersions.length === 0 ? (
<EmptyStateWithBorder
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'>
{t('colVersion')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colStatus')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colAuthor')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChecksum')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colCreatedAt')}
</TableHead>
<TableHead className='text-xs font-semibold text-right'>
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedVersions.map((version) => (
<TableRow
key={version.id}
className='border-dashed hover:bg-muted/10 transition-colors'
>
<TableCell className='font-mono text-xs font-semibold'>
{version.version}
</TableCell>
<TableCell>
{version.is_active ? (
<Badge
variant='outline'
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
>
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
{t('active')}
</Badge>
) : (
<Badge
variant='outline'
className='text-[10px] rounded-full py-0 px-2'
>
{t('history')}
</Badge>
)}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{version.created_by || t('system')}
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground'
title={version.checksum}
>
{truncateChecksum(version.checksum)}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{formatDateTime(version.created_at)}
</TableCell>
<TableCell className='text-right'>
<div className='flex items-center justify-end gap-1.5'>
<Button
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={() => {
setSnapshotVersion(version);
setSnapshotOpen(true);
}}
>
<Eye className='size-3 mr-1' />
{t('snapshot')}
</Button>
{!version.is_active ? (
<Button
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={() => setActivateTarget(version)}
>
<Play className='size-3 mr-1' />
{t('activate')}
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<PreviewSheet
open={previewOpen}
onOpenChange={setPreviewOpen}
preview={preview}
loading={previewLoading}
error={previewError}
publishing={publishing}
canPublish={canPublish}
onPublish={() => setPublishConfirmOpen(true)}
/>
<DiffDialog
open={diffOpen}
onOpenChange={setDiffOpen}
diff={diff}
loading={diffLoading}
error={diffError}
/>
<VersionSnapshotSheet
version={snapshotVersion}
open={snapshotOpen}
onOpenChange={setSnapshotOpen}
/>
<CleanupDialog
open={cleanupOpen}
onOpenChange={setCleanupOpen}
onConfirm={(keepCount) => void handleCleanup(keepCount)}
loading={cleaning}
/>
<AlertDialog
open={publishConfirmOpen}
onOpenChange={setPublishConfirmOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('confirmPublishTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirmPublishDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>
{tc('cancel')}
</AlertDialogCancel>
<Button
onClick={() => void handlePublish(false)}
disabled={publishing}
>
{publishing ? (
<Loader2 className='size-4 animate-spin' />
) : (
t('confirmPublish')
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={forcePublishConfirmOpen}
onOpenChange={setForcePublishConfirmOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('confirmForceTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirmForceDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>
{tc('cancel')}
</AlertDialogCancel>
<Button
variant='destructive'
onClick={() => void handlePublish(true)}
disabled={publishing}
>
{publishing ? (
<Loader2 className='size-4 animate-spin' />
) : (
t('forcePublish')
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={activateTarget !== null}
onOpenChange={(open) => {
if (!open) setActivateTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('confirmActivateTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{activateTarget
? t('confirmActivateDesc', { version: activateTarget.version })
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={activating}>
{tc('cancel')}
</AlertDialogCancel>
<Button onClick={() => void handleActivate()} disabled={activating}>
{activating ? (
<Loader2 className='size-4 animate-spin' />
) : (
t('confirmActivate')
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Cloud, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { DnsAccountItem } from '@/lib/services/openflare';
import { DnsAccountService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { DnsAccountCreateDialog } from '../websites/components/dns-account-create-dialog';
import { getErrorMessage } from '../websites/components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
export default function DnsAccountsPage() {
const t = useTranslations('dnsAccounts');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DnsAccountItem | null>(null);
const dnsAccountsQuery = useQuery({
queryKey: dnsAccountsQueryKey,
queryFn: () => DnsAccountService.list(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => DnsAccountService.deleteById(id),
onSuccess: async () => {
toast.success(t('deleted'));
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: dnsAccountsQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const accounts = useMemo(
() => dnsAccountsQuery.data ?? [],
[dnsAccountsQuery.data],
);
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button
size='sm'
className='h-7 text-xs'
onClick={() => setCreateOpen(true)}
>
<Plus className='size-3.5 mr-1' />
{t('addAccount')}
</Button>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base font-semibold'>
{t('listTitle')}
</CardTitle>
<CardDescription>{t('listDesc')}</CardDescription>
</CardHeader>
<CardContent>
{dnsAccountsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description={t('loadingList')}
/>
) : dnsAccountsQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(
dnsAccountsQuery.error,
t('requestFailed'),
)}
onRetry={() => void dnsAccountsQuery.refetch()}
className='justify-center'
/>
</div>
) : accounts.length === 0 ? (
<EmptyStateWithBorder icon={Cloud} description={t('emptyList')} />
) : (
<div className='space-y-3'>
{accounts.map((account) => (
<div
key={account.id}
className='flex items-start justify-between gap-3 rounded-lg border bg-card px-4 py-3'
>
<div className='space-y-1'>
<p className='text-sm font-semibold'>
{account.name}{' '}
<span className='text-xs font-normal text-muted-foreground'>
({account.type})
</span>
</p>
<p className='text-xs text-muted-foreground'>
{t('createdAt', {
date: formatDateTime(account.created_at),
})}
</p>
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs text-destructive'
onClick={() => setDeleteTarget(account)}
>
<Trash2 className='size-3' />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<DnsAccountCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => toast.success(t('created'))}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{tc('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { HomeMain } from '@/components/common/home/home-main';
import { redirect } from 'next/navigation';
export default function HomePage() {
return <HomeMain />;
redirect('/');
}
+361
View File
@@ -0,0 +1,361 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Network, Plus, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type {
WAFIPGroup,
WAFIPGroupAutoTestResult,
WAFIPGroupPayload,
} from '@/lib/services/openflare';
import { WafService } from '@/lib/services/openflare';
import {
buildIPGroupPayloadFromGroup,
getErrorMessage,
parseAutomaticConfig,
} from '../waf/components/helpers';
import { IPGroupDialog } from '../waf/components/ip-group-dialog';
import { IPGroupTestDialog } from '../waf/components/ip-group-test-dialog';
import { IPGroupViewDialog } from '../waf/components/ip-group-view-dialog';
import { IPGroupsTable } from '../waf/components/ip-groups-table';
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
export default function WafIPGroupsPage() {
const t = useTranslations('ipGroups');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<WAFIPGroup | null>(null);
const [deleteTarget, setDeleteTarget] = useState<WAFIPGroup | null>(null);
const [testOpen, setTestOpen] = useState(false);
const [testResult, setTestResult] = useState<WAFIPGroupAutoTestResult | null>(
null,
);
const [syncingId, setSyncingId] = useState<number | null>(null);
const [viewOpen, setViewOpen] = useState(false);
const [viewingGroup, setViewingGroup] = useState<WAFIPGroup | null>(null);
const [removingIp, setRemovingIp] = useState<string | null>(null);
const groupsQuery = useQuery({
queryKey: ipGroupsQueryKey,
queryFn: () => WafService.listIPGroups(),
});
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ipGroupsQueryKey }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'rule-groups'],
}),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-versions', 'diff'],
}),
]);
};
const saveMutation = useMutation({
mutationFn: async ({
group,
payload,
}: {
group: WAFIPGroup | null;
payload: WAFIPGroupPayload;
}) => {
if (group) {
return WafService.updateIPGroup(group.id, payload);
}
return WafService.createIPGroup(payload);
},
onSuccess: async () => {
toast.success(editingGroup ? t('updated') : t('created'));
setEditingGroup(null);
setEditorOpen(false);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error, t('operationFailed')));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => WafService.deleteIPGroup(id),
onSuccess: async () => {
toast.success(t('deleted'));
setDeleteTarget(null);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error, t('operationFailed')));
},
});
const syncMutation = useMutation({
mutationFn: (id: number) => WafService.syncIPGroup(id),
onMutate: (id) => {
setSyncingId(id);
},
onSuccess: async (result) => {
toast.success(result.message || t('synced'));
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error, t('operationFailed')));
},
onSettled: () => {
setSyncingId(null);
},
});
const viewGroupQuery = useQuery({
queryKey: ['openflare', 'waf', 'ip-groups', viewingGroup?.id],
queryFn: () => WafService.getIPGroup(viewingGroup!.id),
enabled: viewOpen && viewingGroup !== null,
});
const removeIpMutation = useMutation({
mutationFn: async ({ group, ip }: { group: WAFIPGroup; ip: string }) => {
const nextIpList = group.ip_list.filter((item) => item !== ip);
return WafService.updateIPGroup(
group.id,
buildIPGroupPayloadFromGroup(group, nextIpList),
);
},
onMutate: ({ ip }) => {
setRemovingIp(ip);
},
onSuccess: async (updatedGroup) => {
toast.success(t('ipRemoved'));
setViewingGroup(updatedGroup);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error, t('operationFailed')));
},
onSettled: () => {
setRemovingIp(null);
},
});
const testMutation = useMutation({
mutationFn: (group: WAFIPGroup) =>
WafService.testIPGroup({
auto_config: parseAutomaticConfig(
JSON.stringify(group.auto_config ?? {}, null, 2),
t('autoConfigMustBeObject'),
),
}),
onSuccess: (result) => {
setTestResult(result);
toast.success(
result.matched_count > 0
? t('testHit', { count: result.matched_count })
: t('testMiss'),
);
},
onError: (error) => {
setTestResult(null);
toast.error(getErrorMessage(error, t('operationFailed')));
},
});
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ipGroupsQueryKey });
};
const handleCreate = () => {
setEditingGroup(null);
setEditorOpen(true);
};
const handleEdit = (group: WAFIPGroup) => {
setEditingGroup(group);
setEditorOpen(true);
};
const handleTest = (group: WAFIPGroup) => {
setTestResult(null);
setTestOpen(true);
testMutation.mutate(group);
};
const handleView = (group: WAFIPGroup) => {
setViewingGroup(group);
setViewOpen(true);
};
const handleRemoveIp = async (ip: string) => {
const group = viewGroupQuery.data ?? viewingGroup;
if (!group) return;
await removeIpMutation.mutateAsync({ group, ip });
};
const groups = groupsQuery.data ?? [];
const viewGroup = viewGroupQuery.data ?? viewingGroup;
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Network className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={handleCreate}
>
<Plus className='size-3.5 mr-1' />
{t('create')}
</Button>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between gap-3'>
<div>
<CardTitle className='text-base font-semibold'>
{t('listTitle')}
</CardTitle>
<CardDescription>{t('listDesc')}</CardDescription>
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={handleRefresh}
disabled={groupsQuery.isFetching}
>
{groupsQuery.isFetching ? (
<Loader2 className='size-3.5 mr-1 animate-spin' />
) : (
<RefreshCw className='size-3.5 mr-1' />
)}
{t('refresh')}
</Button>
</div>
</CardHeader>
<CardContent>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder icon={Network} description={t('loading')} />
) : groupsQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(
groupsQuery.error,
t('operationFailed'),
)}
onRetry={handleRefresh}
className='justify-center'
/>
</div>
) : groups.length === 0 ? (
<EmptyStateWithBorder icon={Network} description={t('empty')} />
) : (
<IPGroupsTable
groups={groups}
syncingId={syncingId}
onView={handleView}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onSync={(group) => syncMutation.mutate(group.id)}
onTest={handleTest}
/>
)}
</CardContent>
</Card>
<IPGroupDialog
open={editorOpen}
group={editingGroup}
submitting={saveMutation.isPending}
onOpenChange={(open) => {
setEditorOpen(open);
if (!open) setEditingGroup(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync({ group: editingGroup, payload });
}}
/>
<IPGroupTestDialog
open={testOpen}
loading={testMutation.isPending}
result={testResult}
onOpenChange={setTestOpen}
/>
<IPGroupViewDialog
open={viewOpen}
group={viewGroup}
loading={viewGroupQuery.isFetching && !viewGroupQuery.data}
removingIp={removingIp}
onOpenChange={(open) => {
setViewOpen(open);
if (!open) {
setViewingGroup(null);
}
}}
onRemoveIp={handleRemoveIp}
/>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,209 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLink, Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatDateTime } from '@/lib/utils';
import type {
NodeAgentReleaseInfo,
NodeItem,
ReleaseChannel,
} from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { NodeStatusBadge } from './node-status-badge';
import { formatRelativeTime, getErrorMessage } from './node-utils';
export function AgentUpdateDialog({
open,
node,
submitting,
onClose,
onConfirm,
}: {
open: boolean;
node: NodeItem;
submitting: boolean;
onClose: () => void;
onConfirm: (
release: NodeAgentReleaseInfo | null,
channel: ReleaseChannel,
) => Promise<void>;
}) {
const t = useTranslations('nodes');
const [channel, setChannel] = useState<ReleaseChannel>('stable');
const releaseQuery = useQuery({
queryKey: ['openflare', 'node-agent-release', node.id, channel],
queryFn: () => NodeService.getAgentRelease(node.id, channel),
enabled: open,
});
const release = releaseQuery.data ?? null;
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-xl'>
<DialogHeader>
<DialogTitle>{t('update.title')}</DialogTitle>
<DialogDescription>{t('update.description')}</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-3'>
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>
{t('update.currentVersion')}
</p>
<p className='mt-1 text-sm font-medium'>
{node.version || 'unknown'}
</p>
</div>
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>
{t('update.channel')}
</p>
<p className='mt-1 text-sm font-medium'>
{channel === 'preview'
? t('channel.preview')
: t('channel.stable')}
</p>
</div>
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>
{t('update.updateStatus')}
</p>
<div className='mt-1'>
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? t('update.waitingPreview')
: t('update.waitingUpdate')
: t('update.notRequested')
}
tone={node.update_requested ? 'warning' : 'info'}
/>
</div>
</div>
</div>
{releaseQuery.isFetching ? (
<div className='flex items-center justify-center py-8 text-sm text-muted-foreground'>
<Loader2 className='size-4 mr-2 animate-spin' />
{t('update.checking')}
</div>
) : releaseQuery.isError ? (
<p className='text-sm text-destructive'>
{getErrorMessage(releaseQuery.error, t('requestFailed'))}
</p>
) : release ? (
<div className='space-y-3 rounded-lg border p-4'>
<div className='flex flex-wrap items-center gap-2'>
<NodeStatusBadge
label={
release.has_update
? t('update.hasUpdate')
: t('update.upToDate')
}
tone={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<NodeStatusBadge
label={t('update.previewRelease')}
tone='warning'
/>
) : (
<NodeStatusBadge
label={t('update.stableRelease')}
tone='info'
/>
)}
</div>
<div className='grid gap-3 sm:grid-cols-2 text-sm'>
<div>
<p className='text-xs text-muted-foreground'>
{t('update.targetVersion')}
</p>
<p className='mt-1 font-medium'>
{release.tag_name || t('update.notFound')}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground'>
{t('update.publishedAt')}
</p>
<p className='mt-1'>
{release.published_at
? `${formatRelativeTime(release.published_at, t)} · ${formatDateTime(release.published_at)}`
: '—'}
</p>
</div>
</div>
<p className='text-sm text-muted-foreground whitespace-pre-wrap'>
{release.body || t('update.noNotes')}
</p>
{release.html_url ? (
<a
href={release.html_url}
target='_blank'
rel='noreferrer'
className='inline-flex items-center text-sm text-primary hover:underline'
>
{t('update.viewRelease')}
<ExternalLink className='size-3 ml-1' />
</a>
) : null}
</div>
) : null}
</div>
<DialogFooter className='gap-2 sm:gap-0'>
<Button
type='button'
variant='outline'
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('stable')}
>
{t('update.checkStable')}
</Button>
<Button
type='button'
variant='outline'
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('preview')}
>
{t('update.checkPreview')}
</Button>
<Button
type='button'
disabled={
submitting ||
releaseQuery.isFetching ||
!release?.has_update ||
node.update_requested
}
onClick={() => void onConfirm(release, channel)}
>
{submitting
? t('dispatching')
: channel === 'preview'
? t('update.upgradePreview')
: t('update.upgradeStable')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,81 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { DiskIOTrendPoint } from '@/lib/services/openflare';
import { formatBytesPerSecond } from '@/lib/utils/metrics';
import { formatTrendHour } from '../../components/dashboard/dashboard-utils';
/** Backend disk points are per-hour totals; chart displays bytes/s within each hour. */
const DISK_BUCKET_SECONDS = 3600;
function diskBytesToRate(bytes: number) {
return bytes > 0 ? bytes / DISK_BUCKET_SECONDS : 0;
}
function formatDiskRate(bytesPerSecond: number) {
return formatBytesPerSecond(bytesPerSecond, 1, { zeroText: '0 B' });
}
export function DiskIOTrendChart({
points,
title,
description,
}: {
points: DiskIOTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('nodes.diskIo');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
summaryScope='average'
summaryHint={t('summaryHint')}
yAxisValueFormatter={formatDiskRate}
series={[
{
label: t('read'),
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: points.map((point) =>
diskBytesToRate(point.disk_read_bytes),
),
valueFormatter: formatDiskRate,
},
{
label: t('write'),
color: '#fb7185',
values: points.map((point) =>
diskBytesToRate(point.disk_write_bytes),
),
valueFormatter: formatDiskRate,
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,51 @@
'use client';
import { useTranslations } from 'next-intl';
import { Progress } from '@/components/ui/progress';
import { formatCompactNumber } from '../../components/dashboard/dashboard-utils';
export function DistributionList({
items,
emptyMessage,
}: {
items: Array<{ label: string; value: number }>;
emptyMessage?: string;
}) {
const t = useTranslations('nodes.obs');
const resolvedEmpty = emptyMessage ?? t('distEmpty');
const sortedItems = [...items]
.sort((left, right) => right.value - left.value)
.slice(0, 8);
const maxValue = sortedItems[0]?.value ?? 0;
if (sortedItems.length === 0) {
return (
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
{resolvedEmpty}
</div>
);
}
return (
<div className='space-y-3'>
{sortedItems.map((item) => {
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<div key={item.label} className='space-y-1.5'>
<div className='flex items-center justify-between gap-3 text-xs'>
<span className='truncate font-medium'>
{item.label || t('unknownLabel')}
</span>
<span className='shrink-0 font-mono tabular-nums text-muted-foreground'>
{formatCompactNumber(item.value)}
</span>
</div>
<Progress value={ratio} className='h-1.5' />
</div>
);
})}
</div>
);
}
@@ -0,0 +1,412 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
Activity,
Cpu,
FileText,
Fingerprint,
Package,
RefreshCw,
RotateCcw,
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { formatDateTime } from '@/lib/utils';
import type {
NodeAgentReleaseInfo,
NodeItem,
ReleaseChannel,
} from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { AgentUpdateDialog } from './agent-update-dialog';
import { InstallCommand } from './install-command';
import { NodeDetailShell } from './node-detail-shell';
import {
NodeErrorBanner,
NodeInfoRow,
NodeSectionCard,
} from './node-detail-primitives';
import { NodeEditorDialog } from './node-editor-dialog';
import { NodeObservability } from './node-observability';
import { NodeStatusBadge } from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getNodeStatusLabel,
getNodeStatusTone,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
isMeaningfulTime,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success(t('edge.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(t('edge.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const restartMutation = useMutation({
mutationFn: () => NodeService.requestOpenrestyRestart(node.id),
onSuccess: async (updated) => {
toast.success(t('edge.toastRestart', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name:
release?.channel === 'preview'
? release.tag_name || undefined
: undefined,
}),
onSuccess: async (updated) => {
toast.success(
t('edge.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success(t('edge.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', node.id],
}),
]);
};
const headerActions = (
<>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={() => setEditorOpen(true)}
>
{t('edit')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
{t('refresh')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8'
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8'
disabled={restartMutation.isPending}
onClick={() => restartMutation.mutate()}
>
{restartMutation.isPending
? t('dispatching')
: t('edge.restartOpenresty')}
</Button>
<Button
variant='secondary'
size='sm'
className='h-8'
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? t('viewUpgrade') : t('edge.upgrade')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8 text-destructive hover:text-destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
{t('delete')}
</Button>
</>
);
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title={t('edge.runStatus')}
description={t('edge.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('edge.openrestyHealth')}>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('edge.lastSeen')}>
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
<NodeInfoRow label={t('edge.geo')}>
{node.geo_name || t('notConfigured')}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title={t('edge.syncTitle')}
description={t('edge.syncDesc')}
action={
<Button variant='outline' size='sm' className='h-8' asChild>
<Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
>
<FileText className='size-3.5 mr-1.5' />
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label={t('edge.currentVersion')}>
{node.current_version || t('notApplied')}
</NodeInfoRow>
<NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('edge.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label={t('edge.agentVersion')}>
{node.version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label={t('edge.nginxVersion')}>
{node.ext_version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label={t('edge.autoUpdate')}>
{node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
<InstallCommand node={node} variant='edge' />
<NodeSectionCard
title={t('edge.runtimeMessages')}
description={t('edge.runtimeMessagesDesc')}
>
<div className='space-y-4 text-sm'>
<div>
<p className='mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground'>
{t('edge.openrestyMessage')}
</p>
<p className='rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap'>
{node.openresty_message || t('edge.noExtraError')}
</p>
</div>
{node.restart_openresty_requested ? (
<div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'>
<Activity className='size-4' />
{t('edge.restartPending')}
</div>
) : null}
</div>
</NodeSectionCard>
</div>
);
return (
<>
<NodeDetailShell
title={node.name}
typeLabel='Edge'
typeTone='info'
statusBadges={[
{
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getOpenrestyStatusLabel(node.openresty_status, t),
tone: getOpenrestyStatusTone(node.openresty_status),
},
]}
actions={headerActions}
kpis={[
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: t('edge.kpiAgent'),
value: node.version || 'unknown',
icon: Cpu,
},
{
label: t('kpiCurrentConfig'),
value: node.current_version || t('notApplied'),
icon: Package,
},
{
label: t('kpiLastSeen'),
value: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
overview={
<NodeObservability
nodeId={node.id}
node={node}
variant='edge'
connectionHint={t('edge.connectionHint')}
/>
}
manage={manageTab}
defaultTab='overview'
/>
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,216 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Copy } from 'lucide-react';
import { useTranslations } from 'next-intl';
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 { type NodeItem, StatusService } from '@/lib/services/openflare';
import {
buildEdgeDockerInstallCommand,
buildRelayDockerInstallCommand,
buildRelayInstallCommand,
buildTunnelDockerInstallCommand,
buildTunnelInstallCommand,
getServerUrl,
} from './node-utils';
type InstallVariant = 'edge' | 'relay' | 'tunnel';
export function InstallCommand({
node,
variant,
}: {
node: NodeItem;
variant: InstallVariant;
}) {
const t = useTranslations('nodes.install');
const tn = useTranslations('nodes');
const [serverUrl, setServerUrl] = useState('');
const variantMeta: Record<
InstallVariant,
{
title: string;
description: string;
tokenLabel: string;
scriptLabel?: string;
dockerLabel: string;
}
> = {
edge: {
title: t('edgeTitle'),
description: t('edgeDesc'),
tokenLabel: t('agentToken'),
dockerLabel: t('dockerLabel'),
},
relay: {
title: t('relayTitle'),
description: t('relayDesc'),
tokenLabel: t('discoveryToken'),
scriptLabel: t('scriptLabel'),
dockerLabel: t('dockerLabel'),
},
tunnel: {
title: t('tunnelTitle'),
description: t('tunnelDesc'),
tokenLabel: t('tunnelToken'),
scriptLabel: t('scriptLabel'),
dockerLabel: t('dockerLabel'),
},
};
const meta = variantMeta[variant];
const statusQuery = useQuery({
queryKey: ['openflare', 'public-status'],
queryFn: () => StatusService.getPublicStatus(),
staleTime: 5 * 60 * 1000,
});
const serverVersion = statusQuery.data?.version;
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
const normalizedServerUrl = getServerUrl(serverUrl);
const scriptCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token || variant === 'edge') {
return '';
}
return variant === 'relay'
? buildRelayInstallCommand(normalizedServerUrl, node.access_token)
: buildTunnelInstallCommand(normalizedServerUrl, node.access_token);
}, [normalizedServerUrl, node.access_token, variant]);
const dockerCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token) {
return '';
}
if (variant === 'edge') {
return buildEdgeDockerInstallCommand(
normalizedServerUrl,
node.access_token,
serverVersion,
);
}
return variant === 'relay'
? buildRelayDockerInstallCommand(
normalizedServerUrl,
node.access_token,
serverVersion,
)
: buildTunnelDockerInstallCommand(
normalizedServerUrl,
node.access_token,
serverVersion,
);
}, [normalizedServerUrl, node.access_token, variant, serverVersion]);
const handleCopy = async (value: string, message: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(message);
} catch {
toast.error(t('copyFailed'));
}
};
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex-row items-start justify-between space-y-0 gap-4'>
<div>
<CardTitle className='text-base font-semibold'>
{meta.title}
</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
{scriptCommand ? (
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={() => void handleCopy(scriptCommand, t('copiedScript'))}
>
<Copy className='size-3.5 mr-1' />
{t('copyScript')}
</Button>
) : null}
{dockerCommand ? (
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={() => void handleCopy(dockerCommand, t('copiedDocker'))}
>
<Copy className='size-3.5 mr-1' />
{t('copyDocker')}
</Button>
) : null}
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground'>{t('nodeId')}</p>
<p className='mt-1 text-sm break-all font-medium'>{node.node_id}</p>
</div>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground'>{meta.tokenLabel}</p>
<p className='mt-1 text-sm break-all font-medium'>
{node.access_token || tn('na')}
</p>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor={`server-url-${variant}`}>{t('serverUrl')}</Label>
<Input
id={`server-url-${variant}`}
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
placeholder='https://openflare.example.com'
/>
<p className='text-xs text-muted-foreground'>{t('serverUrlHint')}</p>
</div>
{!node.access_token ? (
<p className='text-sm text-muted-foreground'>{t('noToken')}</p>
) : !normalizedServerUrl ? (
<p className='text-sm text-muted-foreground'>{t('needServerUrl')}</p>
) : (
<>
{scriptCommand && meta.scriptLabel ? (
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
{scriptCommand}
</pre>
</div>
) : null}
<div className='space-y-2'>
<p className='text-sm font-medium'>{meta.dockerLabel}</p>
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
{dockerCommand}
</pre>
</div>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,68 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { NetworkTrendPoint } from '@/lib/services/openflare';
import {
formatBytes,
formatTrendHour,
} from '../../components/dashboard/dashboard-utils';
export function NetworkTrendChart({
points,
title,
description,
}: {
points: NetworkTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('nodes.networkTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint={t('summaryHint')}
yAxisValueFormatter={formatBytes}
series={[
{
label: t('received'),
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: points.map((point) => point.bytes_received),
valueFormatter: formatBytes,
},
{
label: t('provided'),
color: '#38bdf8',
values: points.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,99 @@
'use client';
import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { cn } from '@/lib/utils';
export function NodeInfoRow({
label,
children,
className,
}: {
label: string;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'flex items-start justify-between gap-4 py-2.5 text-sm',
className,
)}
>
<span className='shrink-0 text-muted-foreground'>{label}</span>
<div className='min-w-0 text-right font-medium'>{children}</div>
</div>
);
}
export function NodeKpiCard({
label,
value,
icon: Icon,
}: {
label: string;
value: ReactNode;
icon?: LucideIcon;
}) {
return (
<div className='rounded-xl border bg-card/60 px-4 py-3.5 backdrop-blur-sm'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
{Icon ? <Icon className='size-3.5 opacity-70' /> : null}
<span>{label}</span>
</div>
<p className='mt-2 text-sm font-semibold tracking-tight break-all'>
{value}
</p>
</div>
);
}
export function NodeSectionCard({
title,
description,
action,
children,
className,
}: {
title: string;
description?: string;
action?: React.ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<Card className={cn('border shadow-none', className)}>
<CardHeader
className={cn(
'pb-3',
action ? 'flex-row items-start justify-between space-y-0' : '',
)}
>
<div className='space-y-1'>
<CardTitle className='text-base font-semibold'>{title}</CardTitle>
{description ? (
<CardDescription>{description}</CardDescription>
) : null}
</div>
{action}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export function NodeErrorBanner({ message }: { message: string }) {
return (
<div className='rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm text-destructive'>
{message}
</div>
);
}
@@ -0,0 +1,182 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { type ReactNode, useCallback, useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { NodeKpiCard } from './node-detail-primitives';
import { NodeStatusBadge } from './node-status-badge';
import type { StatusTone } from './node-utils';
export type NodeDetailTabId = 'overview' | 'manage';
export type NodeDetailTabConfig = {
id: NodeDetailTabId;
label: string;
icon?: LucideIcon;
};
export type NodeDetailKpi = {
label: string;
value: ReactNode;
icon?: LucideIcon;
};
const TAB_IDS: NodeDetailTabId[] = ['overview', 'manage'];
function resolveTab(value: string | null): NodeDetailTabId | null {
if (value === 'overview' || value === 'dashboard') {
return 'overview';
}
if (value === 'manage') {
return 'manage';
}
return null;
}
export function NodeDetailShell({
title,
typeLabel,
typeTone = 'info',
statusBadges,
actions,
kpis,
overview,
manage,
defaultTab = 'overview',
}: {
title: string;
typeLabel: string;
typeTone?: StatusTone;
statusBadges: Array<{ label: string; tone: StatusTone }>;
actions: ReactNode;
kpis: NodeDetailKpi[];
overview: ReactNode;
manage: ReactNode;
defaultTab?: NodeDetailTabId;
}) {
const t = useTranslations('nodes');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeTab = useMemo(() => {
const tab = searchParams.get('tab');
return resolveTab(tab) ?? defaultTab;
}, [defaultTab, searchParams]);
const setActiveTab = useCallback(
(tab: NodeDetailTabId) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tab);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
const handleTabChange = useCallback(
(value: string) => {
const tab = resolveTab(value);
if (tab) {
setActiveTab(tab);
}
},
[setActiveTab],
);
return (
<div className='py-6 px-1 space-y-6'>
<section className='overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30'>
<div className='flex flex-col gap-5 p-5 md:p-6'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='flex min-w-0 items-start gap-3'>
<Button
variant='ghost'
size='sm'
className='mt-0.5 h-8 w-8 shrink-0 p-0'
asChild
>
<Link href='/nodes' aria-label={t('backToList')}>
<ArrowLeft className='size-4' />
</Link>
</Button>
<div className='min-w-0 space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<h1 className='text-2xl font-semibold tracking-tight'>
{title}
</h1>
<NodeStatusBadge label={typeLabel} tone={typeTone} />
</div>
<div className='flex flex-wrap items-center gap-2'>
{statusBadges.map((badge) => (
<NodeStatusBadge
key={badge.label}
label={badge.label}
tone={badge.tone}
/>
))}
</div>
</div>
</div>
<div className='flex flex-wrap items-center gap-2'>{actions}</div>
</div>
{kpis.length > 0 ? (
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
{kpis.map((kpi) => (
<NodeKpiCard
key={kpi.label}
label={kpi.label}
value={kpi.value}
icon={kpi.icon}
/>
))}
</div>
) : null}
</div>
</section>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className='w-full gap-0'
>
<div className='space-y-3 pb-1'>
<TabsList
variant='line'
className='h-auto w-full justify-start gap-6 bg-transparent p-0'
>
{TAB_IDS.map((tabId) => (
<TabsTrigger
key={tabId}
value={tabId}
className={cn(
'h-auto flex-none rounded-none px-0 pb-3 pt-1 text-sm font-semibold',
'data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground',
)}
>
{tabId === 'overview' ? t('tabOverview') : t('tabManage')}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value='overview' className='mt-6 outline-none'>
{activeTab === 'overview' ? overview : null}
</TabsContent>
<TabsContent value='manage' className='mt-6 outline-none'>
{activeTab === 'manage' ? manage : null}
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,365 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import type {
NodeItem,
NodeMutationPayload,
NodeType,
} from '@/lib/services/openflare';
function buildNodeSchema(t: (key: string) => string) {
return z
.object({
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z.string().trim().min(1, t('editor.errName')).max(255),
ip: z.string(),
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_name: z.string(),
geo_latitude: z.string(),
geo_longitude: z.string(),
relay_bind_port: z.string(),
relay_vhost_http_port: z.string(),
})
.superRefine((value, context) => {
if (value.ip_manual_override && !value.ip.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['ip'],
message: t('editor.errLockIp'),
});
}
if (value.node_type === 'tunnel_relay') {
const bindPort = Number(value.relay_bind_port);
const vhostPort = Number(value.relay_vhost_http_port);
if (!Number.isFinite(bindPort) || bindPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_bind_port'],
message: t('editor.errBindPort'),
});
}
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: t('editor.errVhostPort'),
});
}
}
});
}
type NodeFormValues = z.infer<ReturnType<typeof buildNodeSchema>>;
const defaultForm: NodeFormValues = {
node_type: 'edge_node',
name: '',
ip: '',
ip_manual_override: false,
auto_update_enabled: false,
geo_manual_override: false,
geo_name: '',
geo_latitude: '',
geo_longitude: '',
relay_bind_port: '7000',
relay_vhost_http_port: '8080',
};
function buildFormValues(node?: NodeItem | null): NodeFormValues {
if (!node) return defaultForm;
return {
node_type: node.node_type,
name: node.name,
ip: node.ip,
ip_manual_override: node.ip_manual_override,
auto_update_enabled: node.auto_update_enabled,
geo_manual_override: node.geo_manual_override,
geo_name: node.geo_name,
geo_latitude:
node.geo_latitude === undefined || node.geo_latitude === null
? ''
: String(node.geo_latitude),
geo_longitude:
node.geo_longitude === undefined || node.geo_longitude === null
? ''
: String(node.geo_longitude),
relay_bind_port: String(node.relay_bind_port || 7000),
relay_vhost_http_port: String(node.relay_vhost_http_port || 8080),
};
}
function toPayload(form: NodeFormValues): NodeMutationPayload {
const base: NodeMutationPayload = {
node_type: form.node_type,
name: form.name.trim(),
ip: form.ip.trim(),
ip_manual_override: form.ip_manual_override,
auto_update_enabled: form.auto_update_enabled,
geo_manual_override: form.geo_manual_override,
geo_name: form.geo_manual_override ? form.geo_name.trim() : '',
geo_latitude:
form.geo_manual_override && form.geo_latitude
? Number(form.geo_latitude)
: null,
geo_longitude:
form.geo_manual_override && form.geo_longitude
? Number(form.geo_longitude)
: null,
};
if (form.node_type === 'tunnel_relay') {
return {
...base,
relay_bind_port: Number(form.relay_bind_port),
relay_vhost_http_port: Number(form.relay_vhost_http_port),
relay_web_server_enabled: true,
};
}
return base;
}
export function NodeEditorDialog({
open,
node,
submitting,
onClose,
onSubmit,
}: {
open: boolean;
node: NodeItem | null;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
}) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const form = useForm<NodeFormValues>({
resolver: zodResolver(buildNodeSchema(t)),
defaultValues: defaultForm,
});
useEffect(() => {
if (open) {
form.reset(buildFormValues(node));
}
}, [form, open, node]);
const nodeType = form.watch('node_type');
const ipManualOverride = form.watch('ip_manual_override');
const geoManualOverride = form.watch('geo_manual_override');
const handleSubmit = form.handleSubmit(async (values) => {
await onSubmit(toPayload(values));
});
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>
{node ? t('editor.editTitle') : t('editor.createTitle')}
</DialogTitle>
<DialogDescription>{t('editor.description')}</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => void handleSubmit(event)}
className='space-y-4'
>
<div className='space-y-2'>
<Label>{t('editor.nodeType')}</Label>
<Select
value={nodeType}
disabled={Boolean(node)}
onValueChange={(value) =>
form.setValue('node_type', value as NodeType)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='edge_node'>
{t('editor.typeEdge')}
</SelectItem>
<SelectItem value='tunnel_relay'>
{t('editor.typeRelay')}
</SelectItem>
<SelectItem value='tunnel_client'>
{t('editor.typeTunnel')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='node-name'>{t('editor.name')}</Label>
<Input
id='node-name'
placeholder={t('editor.namePlaceholder')}
{...form.register('name')}
/>
{form.formState.errors.name ? (
<p className='text-xs text-destructive'>
{form.formState.errors.name.message}
</p>
) : null}
</div>
<div className='space-y-2'>
<Label htmlFor='node-ip'>{t('editor.ip')}</Label>
<Input
id='node-ip'
placeholder={t('editor.ipPlaceholder')}
{...form.register('ip')}
/>
{form.formState.errors.ip ? (
<p className='text-xs text-destructive'>
{form.formState.errors.ip.message}
</p>
) : null}
</div>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>{t('editor.lockIp')}</p>
<p className='text-xs text-muted-foreground'>
{t('editor.lockIpDesc')}
</p>
</div>
<Switch
checked={ipManualOverride}
onCheckedChange={(checked) =>
form.setValue('ip_manual_override', checked)
}
/>
</div>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>{t('editor.autoUpdate')}</p>
<p className='text-xs text-muted-foreground'>
{t('editor.autoUpdateDesc')}
</p>
</div>
<Switch
checked={form.watch('auto_update_enabled')}
onCheckedChange={(checked) =>
form.setValue('auto_update_enabled', checked)
}
/>
</div>
{nodeType === 'tunnel_relay' ? (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='relay-bind-port'>{t('editor.bindPort')}</Label>
<Input
id='relay-bind-port'
{...form.register('relay_bind_port')}
/>
{form.formState.errors.relay_bind_port ? (
<p className='text-xs text-destructive'>
{form.formState.errors.relay_bind_port.message}
</p>
) : null}
</div>
<div className='space-y-2'>
<Label htmlFor='relay-vhost-port'>
{t('editor.vhostPort')}
</Label>
<Input
id='relay-vhost-port'
{...form.register('relay_vhost_http_port')}
/>
{form.formState.errors.relay_vhost_http_port ? (
<p className='text-xs text-destructive'>
{form.formState.errors.relay_vhost_http_port.message}
</p>
) : null}
</div>
</div>
) : null}
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>{t('editor.manualGeo')}</p>
<p className='text-xs text-muted-foreground'>
{t('editor.manualGeoDesc')}
</p>
</div>
<Switch
checked={geoManualOverride}
onCheckedChange={(checked) =>
form.setValue('geo_manual_override', checked)
}
/>
</div>
{geoManualOverride ? (
<div className='grid gap-3 sm:grid-cols-3'>
<div className='space-y-2 sm:col-span-3'>
<Label htmlFor='geo-name'>{t('editor.geoName')}</Label>
<Input id='geo-name' {...form.register('geo_name')} />
</div>
<div className='space-y-2'>
<Label htmlFor='geo-lat'>{t('editor.latitude')}</Label>
<Input id='geo-lat' {...form.register('geo_latitude')} />
</div>
<div className='space-y-2 sm:col-span-2'>
<Label htmlFor='geo-lng'>{t('editor.longitude')}</Label>
<Input id='geo-lng' {...form.register('geo_longitude')} />
</div>
</div>
) : null}
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={onClose}
disabled={submitting}
>
{tc('cancel')}
</Button>
<Button type='submit' disabled={submitting}>
{submitting
? t('editor.saving')
: node
? t('editor.saveEdit')
: t('editor.createAction')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,942 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { formatDateTime } from '@/lib/utils';
import type {
NodeHealthEvent,
NodeItem,
NodeSystemProfile,
} from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { CapacityTrendChart } from '../../components/dashboard/capacity-trend-chart';
import { TrafficTrendChart } from '../../components/dashboard/traffic-trend-chart';
import { DiskIOTrendChart } from './disk-io-trend-chart';
import { DistributionList } from './distribution-list';
import { NetworkTrendChart } from './network-trend-chart';
import { NodeStatusBadge } from './node-status-badge';
import {
formatBytes,
formatMetricCount,
formatPercent,
formatRelativeTime,
formatUptime,
formatUsageRatio,
getErrorMessage,
getHealthEventLabel,
getHealthEventTone,
getNodeStatusLabel,
getNodeStatusTone,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
isMeaningfulTime,
} from './node-utils';
type HealthEventFilter = 'all' | 'active' | 'resolved';
type NodeObservabilityVariant = 'edge' | 'compact';
function MetricBar({
label,
value,
progress,
hint,
}: {
label: string;
value: string;
progress?: number | null;
hint?: string;
}) {
return (
<div className='space-y-2 rounded-lg border px-3 py-3'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{label}
</p>
{hint ? (
<p className='mt-1 text-xs text-muted-foreground'>{hint}</p>
) : null}
</div>
<p className='text-sm font-medium'>{value}</p>
</div>
{progress !== null && progress !== undefined ? (
<div className='h-2 overflow-hidden rounded-full bg-muted'>
<div
className='h-full rounded-full bg-primary transition-[width]'
style={{ width: `${progress}%` }}
/>
</div>
) : null}
</div>
);
}
function SummaryStat({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-2'>
<CardDescription>{label}</CardDescription>
<CardTitle className='text-base font-semibold'>{value}</CardTitle>
<p className='text-sm text-muted-foreground'>{hint}</p>
</CardHeader>
</Card>
);
}
function SystemProfileCard({
profile,
nodeName,
}: {
profile: NodeSystemProfile;
nodeName: string;
}) {
const t = useTranslations('nodes');
return (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-4 rounded-lg border px-4 py-4'>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.hostname')}
</p>
<p className='mt-2 text-sm'>{profile.hostname || nodeName}</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.os')}
</p>
<p className='mt-2 text-sm'>
{profile.os_name || 'unknown'}
{profile.os_version ? ` ${profile.os_version}` : ''}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.kernelArch')}
</p>
<p className='mt-2 text-sm'>
{profile.kernel_version || 'unknown'} ·{' '}
{profile.architecture || 'unknown'}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.uptime')}
</p>
<p className='mt-2 text-sm'>
{formatUptime(profile.uptime_seconds, t)}
</p>
</div>
</div>
<div className='space-y-4 rounded-lg border px-4 py-4'>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.cpu')}
</p>
<p className='mt-2 text-sm'>{profile.cpu_model || 'unknown'}</p>
<p className='mt-1 text-xs text-muted-foreground'>
{t('obs.cpuCores', { count: profile.cpu_cores || 0 })}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.totalMemory')}
</p>
<p className='mt-2 text-sm'>
{formatBytes(profile.total_memory_bytes)}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.totalStorage')}
</p>
<p className='mt-2 text-sm'>
{formatBytes(profile.total_disk_bytes)}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.reportedAt')}
</p>
<p className='mt-2 text-sm'>
{isMeaningfulTime(profile.reported_at)
? formatDateTime(profile.reported_at)
: '—'}
</p>
</div>
</div>
</div>
);
}
function HealthEventTimeline({
events,
allEventsCount,
healthEventFilter,
onFilterChange,
onCleanup,
cleanupPending,
}: {
events: NodeHealthEvent[];
allEventsCount: number;
healthEventFilter: HealthEventFilter;
onFilterChange: (filter: HealthEventFilter) => void;
onCleanup: () => void;
cleanupPending: boolean;
}) {
const t = useTranslations('nodes');
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex-row items-center justify-between space-y-0'>
<div>
<CardTitle className='text-base font-semibold'>
{t('obs.eventsTitle')}
</CardTitle>
<CardDescription>{t('obs.eventsDesc')}</CardDescription>
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs text-destructive hover:text-destructive'
disabled={cleanupPending || allEventsCount === 0}
onClick={onCleanup}
>
<Trash2 className='size-3.5 mr-1' />
{t('obs.cleanupLogs')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
{allEventsCount > 0 ? (
<>
<div className='flex flex-wrap gap-2'>
{(
[
['all', t('obs.filterAll')],
['active', t('obs.filterActive')],
['resolved', t('obs.filterResolved')],
] as const
).map(([filter, label]) => (
<Button
key={filter}
type='button'
variant={
healthEventFilter === filter ? 'secondary' : 'outline'
}
size='sm'
className='h-7 text-xs rounded-full'
onClick={() => onFilterChange(filter)}
>
{label}
</Button>
))}
</div>
{events.length === 0 ? (
<p className='text-sm text-muted-foreground'>
{t('obs.filterEmpty')}
</p>
) : null}
{events.slice(0, 8).map((event) => (
<div
key={`${event.event_type}-${event.last_triggered_at}-${event.status}`}
className='rounded-lg border px-4 py-3'
>
<div className='flex flex-wrap items-center gap-2'>
<NodeStatusBadge
label={getHealthEventLabel(event)}
tone={getHealthEventTone(event)}
/>
<NodeStatusBadge
label={
event.status === 'active'
? t('obs.filterActive')
: t('obs.filterResolved')
}
tone={event.status === 'active' ? 'warning' : 'success'}
/>
</div>
<p className='mt-2 text-sm text-muted-foreground'>
{event.message || t('obs.noMessage')}
</p>
<div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'>
<p>
{t('obs.firstTriggered')}
{isMeaningfulTime(event.first_triggered_at)
? ` ${formatDateTime(event.first_triggered_at)}`
: ' —'}
</p>
<p>
{t('obs.lastTriggered')}
{isMeaningfulTime(event.last_triggered_at)
? ` ${formatDateTime(event.last_triggered_at)}`
: ' —'}
</p>
<p>
{t('obs.resolvedAt')}
{isMeaningfulTime(event.resolved_at) && event.resolved_at
? ` ${formatDateTime(event.resolved_at)}`
: ' —'}
</p>
</div>
</div>
))}
</>
) : (
<p className='text-sm text-muted-foreground'>{t('obs.noEvents')}</p>
)}
</CardContent>
</Card>
);
}
export function NodeObservability({
nodeId,
node,
variant = 'edge',
connectionHint,
}: {
nodeId: number;
node?: NodeItem;
variant?: NodeObservabilityVariant;
connectionHint?: string;
}) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const resolvedConnectionHint = connectionHint ?? t('edge.connectionHint');
const queryClient = useQueryClient();
const [healthEventFilter, setHealthEventFilter] =
useState<HealthEventFilter>('all');
const [cleanupOpen, setCleanupOpen] = useState(false);
const observabilityQuery = useQuery({
queryKey: ['openflare', 'node-observability', nodeId],
queryFn: () =>
NodeService.getObservability(nodeId, { hours: 24, limit: 48 }),
refetchInterval: 30000,
});
const cleanupMutation = useMutation({
mutationFn: () => NodeService.cleanupHealthEvents(nodeId),
onSuccess: async (result) => {
toast.success(
result.deleted_count > 0
? t('obs.cleaned', { count: result.deleted_count })
: t('obs.nothingToClean'),
);
setCleanupOpen(false);
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', nodeId],
}),
queryClient.invalidateQueries({
queryKey: ['openflare', 'dashboard', 'overview'],
}),
]);
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const observability = observabilityQuery.data ?? null;
const profile = observability?.profile ?? null;
const latestMetric = observability?.metric_snapshots?.[0] ?? null;
const activeHealthEvents = useMemo(
() =>
observability?.health_events.filter(
(event) => event.status === 'active',
) ?? [],
[observability?.health_events],
);
const resolvedHealthEvents = useMemo(
() =>
observability?.health_events.filter(
(event) => event.status === 'resolved',
) ?? [],
[observability?.health_events],
);
const filteredHealthEvents = useMemo(() => {
switch (healthEventFilter) {
case 'active':
return activeHealthEvents;
case 'resolved':
return resolvedHealthEvents;
default:
return observability?.health_events ?? [];
}
}, [
activeHealthEvents,
healthEventFilter,
observability?.health_events,
resolvedHealthEvents,
]);
const trafficSummary = observability?.analytics?.traffic ?? null;
const healthSummary = observability?.analytics?.health ?? null;
const distributions = observability?.analytics?.distributions;
const statusCodeDistribution = useMemo(
() =>
(distributions?.status_codes ?? []).map((item) => ({
label: item.key,
value: item.value,
})),
[distributions?.status_codes],
);
const topDomains = useMemo(
() =>
(distributions?.top_domains ?? []).map((item) => ({
label: item.key,
value: item.value,
})),
[distributions?.top_domains],
);
const topSourceCountry = distributions?.source_countries?.[0] ?? null;
const latestHealthEvent = activeHealthEvents[0] ?? null;
const dominantStatusCode = statusCodeDistribution[0] ?? null;
const dominantDomain = topDomains[0] ?? null;
const memoryUsageRatio = formatUsageRatio(
latestMetric?.memory_used_bytes,
latestMetric?.memory_total_bytes,
);
const storageUsageRatio = formatUsageRatio(
latestMetric?.storage_used_bytes,
latestMetric?.storage_total_bytes,
);
const trends = observability?.trends;
const nodeName = node?.name ?? observability?.node_id ?? String(nodeId);
if (observabilityQuery.isLoading) {
return (
<Card className='border-dashed shadow-none'>
<CardContent className='flex items-center justify-center py-10 text-sm text-muted-foreground'>
<Loader2 className='size-4 mr-2 animate-spin' />
{t('obs.loading')}
</CardContent>
</Card>
);
}
if (observabilityQuery.isError) {
return (
<Card className='border-dashed shadow-none'>
<CardContent className='py-6'>
<p className='text-sm text-destructive'>
{getErrorMessage(observabilityQuery.error, t('requestFailed'))}
</p>
</CardContent>
</Card>
);
}
if (variant === 'compact') {
return (
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-3'>
<SummaryStat
label={t('obs.diag')}
value={
activeHealthEvents.length
? t('obs.activeIssues', { count: activeHealthEvents.length })
: t('obs.stable')
}
hint={
latestHealthEvent
? getHealthEventLabel(latestHealthEvent)
: t('obs.noActiveEvents')
}
/>
<SummaryStat
label={t('obs.systemCore')}
value={profile?.hostname || '—'}
hint={
profile
? `${profile.os_name || '—'} · ${profile.architecture || '—'}`
: '—'
}
/>
<SummaryStat
label={t('obs.uptime')}
value={formatUptime(profile?.uptime_seconds, t)}
hint={t('obs.uptimeFromProfile')}
/>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
{t('obs.profileTitle')}
</CardTitle>
<CardDescription>{t('obs.profileDesc')}</CardDescription>
</CardHeader>
<CardContent>
{profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} />
) : (
<p className='text-sm text-muted-foreground'>
{t('obs.noProfile')}
</p>
)}
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
{t('obs.snapshotTitle')}
</CardTitle>
<CardDescription>{t('obs.snapshotDesc')}</CardDescription>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetric.captured_at)
? t('obs.snapshotAgo', {
time: formatRelativeTime(
latestMetric.captured_at,
t,
),
})
: undefined
}
/>
<MetricBar
label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label={t('obs.connections')}
value={formatMetricCount(
latestMetric.openresty_connections,
)}
progress={null}
hint={resolvedConnectionHint}
/>
</div>
) : (
<p className='text-sm text-muted-foreground'>
{t('obs.noSnapshot')}
</p>
)}
</CardContent>
</Card>
</div>
<HealthEventTimeline
events={filteredHealthEvents}
allEventsCount={observability?.health_events.length ?? 0}
healthEventFilter={healthEventFilter}
onFilterChange={setHealthEventFilter}
onCleanup={() => setCleanupOpen(true)}
cleanupPending={cleanupMutation.isPending}
/>
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('obs.cleanupDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
{cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
return (
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<SummaryStat
label={t('obs.diag')}
value={
activeHealthEvents.length
? t('obs.activeIssues', { count: activeHealthEvents.length })
: t('obs.stable')
}
hint={
latestHealthEvent
? getHealthEventLabel(latestHealthEvent)
: t('obs.noActiveEvents')
}
/>
<SummaryStat
label={t('obs.windowRequests')}
value={formatMetricCount(trafficSummary?.request_count)}
hint={
trafficSummary
? t('obs.windowVisitorsHint', {
visitors: formatMetricCount(
trafficSummary.unique_visitor_count,
),
rate: trafficSummary.error_rate_percent.toFixed(1),
})
: t('obs.noWindowSummary')
}
/>
<SummaryStat
label={t('obs.capacityPressure')}
value={
healthSummary?.has_capacity_risk
? t('obs.needsAttention')
: t('obs.normalRange')
}
hint={
latestMetric
? t('obs.cpuStorage', {
cpu: formatPercent(latestMetric.cpu_usage_percent),
storage: formatPercent(storageUsageRatio),
})
: t('obs.noResourceSnapshot')
}
/>
<SummaryStat
label={t('obs.sourceSignal')}
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
? t('obs.requestTimes', {
count: topSourceCountry.value.toLocaleString('zh-CN'),
})
: t('obs.noSourceDist')
}
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
{t('obs.systemInfo')}
</CardTitle>
</CardHeader>
<CardContent>
{profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} />
) : (
<p className='text-sm text-muted-foreground'>
{t('obs.noProfileLong')}
</p>
)}
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
{t('obs.realtimeResources')}
</CardTitle>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetric.captured_at)
? t('obs.snapshotAgo', {
time: formatRelativeTime(latestMetric.captured_at, t),
})
: undefined
}
/>
<MetricBar
label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label={t('obs.connections')}
value={formatMetricCount(latestMetric.openresty_connections)}
progress={null}
hint={resolvedConnectionHint}
/>
</div>
) : (
<p className='text-sm text-muted-foreground'>
{t('obs.noSnapshotLong')}
</p>
)}
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
{t('obs.networkTraffic')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{latestMetric ? (
<>
{node ? (
<div className='flex flex-wrap gap-2'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
<NodeStatusBadge
label={
activeHealthEvents.length
? t('obs.activeIssues', {
count: activeHealthEvents.length,
})
: t('obs.noActiveIssue')
}
tone={activeHealthEvents.length ? 'warning' : 'success'}
/>
</div>
) : null}
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3 sm:col-span-2'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.openrestyConn')}
</p>
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
<p>
{t('obs.currentConn')}
{latestMetric.openresty_connections ?? 0}
</p>
<p className='text-xs'>{t('obs.throughputHint')}</p>
</div>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.windowVisitors')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.unique_visitor_count)}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? t('obs.reqQps', {
requests: formatMetricCount(
trafficSummary.request_count,
),
qps: trafficSummary.estimated_qps.toFixed(2),
})
: t('obs.noTrafficSummary')}
</p>
</div>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.windowErrors')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.error_count)}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? t('obs.errorRate', {
rate: trafficSummary.error_rate_percent.toFixed(1),
})
: t('obs.noErrorSummary')}
</p>
</div>
</div>
</>
) : (
<p className='text-sm text-muted-foreground'>
{t('obs.noNetworkSnapshot')}
</p>
)}
</CardContent>
</Card>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<TrafficTrendChart
points={trends?.traffic_24h ?? []}
description={t('obs.trafficTrendDesc')}
/>
<CapacityTrendChart
points={trends?.capacity_24h ?? []}
description={t('obs.capacityTrendDesc')}
/>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<NetworkTrendChart points={trends?.network_24h ?? []} />
<DiskIOTrendChart points={trends?.disk_io_24h ?? []} />
</div>
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.05fr]'>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
{t('obs.structureTitle')}
</CardTitle>
<CardDescription>{t('obs.structureDesc')}</CardDescription>
</CardHeader>
<CardContent>
<div className='mb-6 grid gap-4 md:grid-cols-3'>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.mainStatus')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{dominantStatusCode?.label ?? '—'}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{dominantStatusCode
? t('obs.times', { count: dominantStatusCode.value })
: t('obs.noStatusDist')}
</p>
</div>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.topDomain')}
</p>
<p className='mt-3 truncate text-2xl font-semibold'>
{dominantDomain?.label ?? '—'}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{dominantDomain
? t('obs.times', { count: dominantDomain.value })
: t('obs.noDomainDist')}
</p>
</div>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.resolvedEvents')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{resolvedHealthEvents.length}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{t('obs.resolvedHint')}
</p>
</div>
</div>
<div className='grid gap-6 xl:grid-cols-2'>
<div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.statusDist')}
</p>
<DistributionList
items={statusCodeDistribution}
emptyMessage={t('obs.noStatusDist')}
/>
</div>
<div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
{t('obs.topDomain')}
</p>
<DistributionList
items={topDomains}
emptyMessage={t('obs.noDomainDist')}
/>
</div>
</div>
</CardContent>
</Card>
<HealthEventTimeline
events={filteredHealthEvents}
allEventsCount={observability?.health_events.length ?? 0}
healthEventFilter={healthEventFilter}
onFilterChange={setHealthEventFilter}
onCleanup={() => setCleanupOpen(true)}
cleanupPending={cleanupMutation.isPending}
/>
</div>
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('obs.cleanupDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
{cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,34 @@
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { StatusTone } from './node-utils';
const toneClassName: Record<StatusTone, string> = {
success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600',
warning: 'bg-amber-500/10 border-amber-500/20 text-amber-600',
danger: 'bg-destructive/10 border-destructive/20 text-destructive',
info: 'bg-blue-500/10 border-blue-500/20 text-blue-600',
};
export function NodeStatusBadge({
label,
tone,
className,
}: {
label: string;
tone: StatusTone;
className?: string;
}) {
return (
<Badge
variant='outline'
className={cn(
'text-[10px] rounded-full py-0 px-2 font-medium',
toneClassName[tone],
className,
)}
>
{label}
</Badge>
);
}
@@ -0,0 +1,94 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
const filters: Array<{ key: NodeFilter; href: string }> = [
{ key: 'all', href: '/nodes' },
{ key: 'edge', href: '/nodes?filter=edge' },
{ key: 'relay', href: '/nodes?filter=relay' },
{ key: 'tunnel', href: '/nodes?filter=tunnel' },
];
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter {
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
if (
current === 'relay' ||
current === 'tunnel' ||
current === 'edge' ||
current === 'all'
) {
return current;
}
return 'all';
}
export function filterNodesByType<T extends { node_type: string }>(
nodes: T[],
filter: NodeFilter,
): T[] {
switch (filter) {
case 'relay':
return nodes.filter((node) => node.node_type === 'tunnel_relay');
case 'tunnel':
return nodes.filter((node) => node.node_type === 'tunnel_client');
case 'edge':
return nodes.filter((node) => node.node_type === 'edge_node');
case 'all':
default:
return nodes;
}
}
export function getFilterDescription(
filter: NodeFilter,
t: (key: string) => string,
) {
switch (filter) {
case 'relay':
return t('filter.descRelay');
case 'tunnel':
return t('filter.descTunnel');
case 'edge':
return t('filter.descEdge');
case 'all':
default:
return t('filter.descAll');
}
}
export function NodeTypeFilter() {
const t = useTranslations('nodes');
const searchParams = useSearchParams();
const activeFilter = getNodeFilter(searchParams);
const filterLabels: Record<NodeFilter, string> = {
all: t('filter.all'),
edge: 'Edge',
relay: 'Relay',
tunnel: 'Tunnel',
};
return (
<div className='flex flex-wrap gap-2'>
{filters.map((item) => (
<Link
key={item.key}
href={item.href}
className={cn(
'inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition',
activeFilter === item.key
? 'border-foreground/30 bg-accent text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50',
)}
>
{filterLabels[item.key]}
</Link>
))}
</div>
);
}
@@ -0,0 +1,340 @@
import type {
ApplyResult,
NodeItem,
NodeStatus,
OpenrestyStatus,
} from '@/lib/services/openflare';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export const FLARED_WS_CONNECTED_LAST_SEEN =
'__OPENFLARE_FLARED_WS_CONNECTED__';
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
export type NodeMessageT = (
key: string,
values?: Record<string, string | number | Date>,
) => string;
export function isWSConnectedLastSeen(value: string | null | undefined) {
return (
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
);
}
export function isMeaningfulTime(
value: string | null | undefined,
): value is string {
return (
Boolean(value) &&
!isWSConnectedLastSeen(value) &&
!String(value).startsWith('0001-01-01')
);
}
export function formatRelativeTime(value: string, t: NodeMessageT) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
if (diffMinutes < 1) return t('relative.justNow');
if (diffMinutes < 60) return t('relative.minutesAgo', { count: diffMinutes });
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return t('relative.hoursAgo', { count: diffHours });
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return t('relative.daysAgo', { count: diffDays });
return t('relative.monthsAgo', { count: Math.floor(diffDays / 30) });
}
export function getNodeStatusTone(status: NodeStatus): StatusTone {
if (status === 'online') return 'success';
if (status === 'pending') return 'warning';
return 'danger';
}
export function getNodeStatusLabel(status: NodeStatus, t: NodeMessageT) {
if (status === 'online') return t('status.online');
if (status === 'pending') return t('status.pending');
return t('status.offline');
}
export function getApplyTone(result: ApplyResult): StatusTone {
if (result === 'success') return 'success';
if (result === 'warning') return 'warning';
if (result === 'failed') return 'danger';
return 'warning';
}
export function getApplyLabel(result: ApplyResult, t: NodeMessageT) {
if (result === 'success') return t('apply.success');
if (result === 'warning') return t('apply.warning');
if (result === 'failed') return t('apply.failed');
return t('apply.none');
}
export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
if (status === 'healthy') return 'success';
if (status === 'unhealthy') return 'danger';
return 'warning';
}
export function getOpenrestyStatusLabel(
status: OpenrestyStatus,
t: NodeMessageT,
) {
if (status === 'healthy') return t('health.healthy');
if (status === 'unhealthy') return t('health.unhealthy');
return t('health.unknown');
}
export function getRelayStatusTone(
status: string | null | undefined,
): StatusTone {
if (status === 'healthy') return 'success';
if (status === 'unhealthy') return 'danger';
return 'warning';
}
export function getRelayStatusLabel(
status: string | null | undefined,
t: NodeMessageT,
) {
if (status === 'healthy') return t('health.healthy');
if (status === 'unhealthy') return t('health.unhealthy');
return t('health.unknown');
}
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
if (nodeType === 'tunnel_relay') return 'Relay';
if (nodeType === 'tunnel_client') return 'Tunnel';
return 'Edge';
}
export function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const relayInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh';
const flaredInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh';
export function getImageTag(version?: string): string {
if (!version) {
return 'latest';
}
const v = version.toLowerCase();
if (
v === 'dev' ||
v.includes('alpha') ||
v.includes('beta') ||
v.includes('rc')
) {
return 'beta';
}
if (v.startsWith('v')) {
return version;
}
return 'latest';
}
export function buildEdgeDockerInstallCommand(
serverUrl: string,
agentToken: string,
version?: string,
) {
const tag = getImageTag(version);
const image = `ghcr.io/rain-kl/openflare-agent:${tag}`;
return [
`docker pull ${image}`,
`docker rm -f openflare-agent 2>/dev/null || true`,
`docker run -d --name openflare-agent --restart unless-stopped \\`,
` -p 80:80 -p 443:443/tcp -p 443:443/udp \\`,
` -v openflare-agent-pages:/data/var/lib/openflare/pages \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
` ${image}`,
].join('\n');
}
export function buildRelayInstallCommand(
serverUrl: string,
discoveryToken: string,
) {
return [
`curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --discovery-token ${discoveryToken}`,
].join('\n');
}
export function buildRelayDockerInstallCommand(
serverUrl: string,
discoveryToken: string,
version?: string,
) {
const tag = getImageTag(version);
const image = `ghcr.io/rain-kl/openflare-relay:${tag}`;
return [
`docker pull ${image}`,
`docker rm -f openflare-relay 2>/dev/null || true`,
`docker run -d --name openflare-relay --net host --restart unless-stopped \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_DISCOVERY_TOKEN=${discoveryToken} \\`,
` ${image}`,
].join('\n');
}
export function buildTunnelInstallCommand(
serverUrl: string,
tunnelToken: string,
) {
return [
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --tunnel-token ${tunnelToken}`,
].join('\n');
}
export function buildTunnelDockerInstallCommand(
serverUrl: string,
tunnelToken: string,
version?: string,
) {
const tag = getImageTag(version);
const image = `ghcr.io/rain-kl/openflared:${tag}`;
return [
`docker pull ${image}`,
`docker rm -f openflared 2>/dev/null || true`,
`docker run -d --name openflared --restart unless-stopped \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_TUNNEL_TOKEN=${tunnelToken} \\`,
` ${image}`,
].join('\n');
}
export function formatBytes(bytes?: number | null, decimals = 1) {
if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
return '—';
}
if (bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** index;
return `${value.toFixed(decimals)} ${units[index]}`;
}
export function formatPercent(value?: number | null) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
return `${value.toFixed(1)}%`;
}
export function formatMetricCount(value?: number | null) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
return value.toLocaleString('zh-CN');
}
export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
if (windowSeconds <= 0) {
return '—';
}
return `${formatBytes(value / windowSeconds)}/s`;
}
export function formatUsageRatio(used?: number | null, total?: number | null) {
if (!used || !total || total <= 0) {
return null;
}
return Math.max(0, Math.min(100, (used / total) * 100));
}
export function formatUptime(
seconds: number | null | undefined,
t: NodeMessageT,
) {
if (!seconds || seconds <= 0) {
return '—';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return t('uptime.daysHours', { days, hours });
}
if (hours > 0) {
return t('uptime.hoursMinutes', { hours, minutes });
}
return t('uptime.minutes', { minutes });
}
export function getHealthEventTone(event: {
status: string;
severity: string;
}): StatusTone {
if (event.status === 'resolved') {
return 'success';
}
if (event.severity === 'critical') {
return 'danger';
}
if (event.severity === 'warning') {
return 'warning';
}
return 'info';
}
export function getHealthEventLabel(event: { event_type: string }) {
return event.event_type.replaceAll('_', ' ');
}
export function getFlaredStatusLabel(node: NodeItem, t: NodeMessageT) {
if (isWSConnectedLastSeen(node.last_seen_at)) {
return t('status.wsConnected');
}
if (node.status === 'online') {
return t('status.running');
}
if (node.status === 'pending') {
return t('status.pending');
}
return t('status.offline');
}
export function getFlaredStatusTone(node: NodeItem): StatusTone {
if (isWSConnectedLastSeen(node.last_seen_at) || node.status === 'online') {
return 'success';
}
if (node.status === 'pending') {
return 'warning';
}
return 'danger';
}
@@ -0,0 +1,180 @@
'use client';
import Link from 'next/link';
import { Pencil, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatDateTime } from '@/lib/utils';
import type { NodeItem } from '@/lib/services/openflare';
import { NodeStatusBadge } from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getNodeStatusLabel,
getNodeStatusTone,
getNodeTypeLabel,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
getRelayStatusLabel,
getRelayStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
export function NodesTable({
nodes,
deletingId,
onEdit,
onDelete,
}: {
nodes: NodeItem[];
deletingId: number | null;
onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>{t('table.node')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.status')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.version')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.runHealth')}</TableHead>
<TableHead className='py-2 h-8'>
{t('table.currentVersion')}
</TableHead>
<TableHead className='py-2 h-8'>{t('table.latestApply')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.lastSeen')}</TableHead>
<TableHead className='py-2 h-8 text-right'>
{t('table.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => (
<TableRow key={node.id} className='border-dashed align-top'>
<TableCell className='py-3'>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<p className='font-medium'>{node.name}</p>
<NodeStatusBadge
label={getNodeTypeLabel(node.node_type)}
tone='info'
/>
</div>
<p className='text-xs text-muted-foreground'>
{t('ipLine', { ip: node.ip || '—' })}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</p>
<p className='text-xs text-muted-foreground'>
{t('locationLine', {
location: node.geo_name || t('noGeo'),
})}
</p>
</div>
</TableCell>
<TableCell className='py-3'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</TableCell>
<TableCell className='py-3 text-muted-foreground'>
{node.version || 'unknown'}
</TableCell>
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)}
/>
) : node.node_type === 'tunnel_client' ? (
<NodeStatusBadge
label={
node.status === 'online' ? t('running') : t('unknown')
}
tone={node.status === 'online' ? 'success' : 'warning'}
/>
) : (
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
)}
</TableCell>
<TableCell className='py-3 text-muted-foreground'>
{node.current_version ||
(node.node_type === 'tunnel_relay'
? t('liveConfig')
: t('notApplied'))}
</TableCell>
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<span className='text-sm text-muted-foreground'>—</span>
) : (
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
)}
</TableCell>
<TableCell className='py-3 text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at)
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</TableCell>
<TableCell className='py-3'>
<div className='flex flex-wrap justify-end gap-1'>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
asChild
>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => onEdit(node)}
>
<Pencil className='size-3 mr-1' />
{t('edit')}
</Button>
<Button
variant='outline'
size='sm'
className='h-7 text-xs text-destructive hover:text-destructive'
disabled={deletingId === node.id}
onClick={() => onDelete(node)}
>
<Trash2 className='size-3 mr-1' />
{t('delete')}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,578 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import {
Activity,
Copy,
ExternalLink,
FileText,
Fingerprint,
Loader2,
Network,
RefreshCw,
RotateCcw,
Server,
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { formatDateTime } from '@/lib/utils';
import type {
NodeAgentReleaseInfo,
NodeItem,
ReleaseChannel,
} from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
import { AgentUpdateDialog } from './agent-update-dialog';
import { InstallCommand } from './install-command';
import { NodeDetailShell } from './node-detail-shell';
import {
NodeErrorBanner,
NodeInfoRow,
NodeSectionCard,
} from './node-detail-primitives';
import { NodeEditorDialog } from './node-editor-dialog';
import { NodeObservability } from './node-observability';
import { NodeStatusBadge } from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getNodeStatusLabel,
getNodeStatusTone,
getRelayStatusLabel,
getRelayStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
function systemConfigMap(configs: SystemConfig[]) {
return configs.reduce<Record<string, SystemConfig>>((accumulator, config) => {
accumulator[config.key] = config;
return accumulator;
}, {});
}
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [webServerPort, setWebServerPort] = useState('17500');
const systemConfigsQuery = useQuery({
queryKey: ['admin', 'system-configs'],
queryFn: () => services.adminSystemConfig.listSystemConfigs(),
});
const configs = useMemo(
() => systemConfigMap(systemConfigsQuery.data ?? []),
[systemConfigsQuery.data],
);
useEffect(() => {
if (systemConfigsQuery.data) {
setWebServerPort(configs['relay_frps_web_ui_port']?.value || '17500');
}
}, [systemConfigsQuery.data, configs]);
const savePortMutation = useMutation({
mutationFn: async (port: string) => {
const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
throw new Error(t('relay.invalidPort'));
}
const portCfg = configs['relay_frps_web_ui_port'];
await services.adminSystemConfig.updateSystemConfig(
'relay_frps_web_ui_port',
{
value: String(portNum),
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
},
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success(t('relay.toastPortSaved'));
},
onError: (error) =>
toast.error(
error instanceof Error ? error.message : t('relay.portSaveFailed'),
),
});
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success(t('relay.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const webServerMutation = useMutation({
mutationFn: (enabled: boolean) =>
NodeService.updateNode(node.id, {
node_type: node.node_type,
name: node.name,
ip: node.ip,
ip_manual_override: node.ip_manual_override,
auto_update_enabled: node.auto_update_enabled,
geo_name: node.geo_name,
geo_latitude: node.geo_latitude ?? null,
geo_longitude: node.geo_longitude ?? null,
geo_manual_override: node.geo_manual_override,
relay_bind_port: node.relay_bind_port,
relay_vhost_http_port: node.relay_vhost_http_port,
relay_web_server_enabled: enabled,
}),
onSuccess: async () => {
toast.success(t('relay.toastWebui'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(t('relay.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name:
release?.channel === 'preview'
? release.tag_name || undefined
: undefined,
}),
onSuccess: async (updated) => {
toast.success(
t('relay.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success(t('relay.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', node.id],
}),
]);
};
const webServerPortNum = parseInt(
configs['relay_frps_web_ui_port']?.value || '',
10,
);
const resolvedPort =
!isNaN(webServerPortNum) && webServerPortNum > 0
? webServerPortNum
: (node.relay_bind_port || 7000) + 500;
const webUiUrl =
node.relay_web_server_enabled && node.relay_bind_port
? `http://${node.ip || '127.0.0.1'}:${resolvedPort}`
: null;
const headerActions = (
<>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={() => setEditorOpen(true)}
>
{t('edit')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
{t('refresh')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8'
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='secondary'
size='sm'
className='h-8'
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? t('viewUpgrade') : t('relay.upgrade')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8 text-destructive hover:text-destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
{t('delete')}
</Button>
</>
);
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title={t('relay.runStatus')}
description={t('relay.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('relay.health')}>
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at)
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label={t('relay.connections')}>
{node.relay_frps_connections ?? '—'} /{' '}
{node.relay_frps_proxy_count ?? '—'}
</NodeInfoRow>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title={t('relay.portsTitle')}
description={t('relay.portsDesc')}
action={
<Button variant='outline' size='sm' className='h-8' asChild>
<Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
>
<FileText className='size-3.5 mr-1.5' />
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label={t('relay.bindPort')}>
{node.relay_bind_port || '—'}
</NodeInfoRow>
<NodeInfoRow label={t('relay.vhostPort')}>
{node.relay_vhost_http_port || '—'}
</NodeInfoRow>
<NodeInfoRow label={t('relay.agentAddr')}>
<span className='break-all'>
{node.relay_agent_access_addr || '—'}
</span>
</NodeInfoRow>
<NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('relay.relayVersion')}>
{node.version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label={t('relay.frpsVersion')}>
{node.ext_version || 'unknown'}
</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
<NodeSectionCard
title={t('relay.webuiTitle')}
description={t('relay.webuiDesc')}
>
<div className='space-y-4'>
<div className='flex items-center justify-between rounded-xl border px-4 py-4'>
<div className='space-y-1'>
<Label>{t('relay.enableWebui')}</Label>
<p className='text-xs text-muted-foreground'>
{t('relay.enableWebuiHint')}
</p>
</div>
<Switch
checked={node.relay_web_server_enabled}
disabled={webServerMutation.isPending}
onCheckedChange={(checked) => webServerMutation.mutate(checked)}
/>
</div>
<div className='rounded-xl border p-4 space-y-3'>
<Label htmlFor='web_server_port' className='text-xs font-semibold'>
{t('relay.webuiPort')}
</Label>
<div className='flex max-w-sm items-center gap-2'>
<Input
id='web_server_port'
type='number'
min={1}
max={65535}
value={webServerPort}
onChange={(e) => setWebServerPort(e.target.value)}
placeholder={t('relay.webuiPortPlaceholder')}
className='bg-card text-xs h-9'
disabled={
savePortMutation.isPending || systemConfigsQuery.isLoading
}
/>
<Button
size='sm'
className='h-9'
onClick={() => savePortMutation.mutate(webServerPort)}
disabled={
savePortMutation.isPending || systemConfigsQuery.isLoading
}
>
{savePortMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
tc('save')
)}
</Button>
</div>
<p className='text-[11px] text-muted-foreground'>
{t('relay.actualPort', { port: resolvedPort })}
</p>
</div>
{webUiUrl ? (
<div className='flex flex-wrap items-center gap-3 text-sm'>
<a
href={webUiUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center font-medium text-primary hover:underline'
>
{t('relay.openWebui')}
<ExternalLink className='size-3.5 ml-1.5' />
</a>
<span className='text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border'>
{webUiUrl}
</span>
<Button
variant='ghost'
size='icon'
className='size-7 text-muted-foreground hover:text-foreground'
onClick={() => {
void navigator.clipboard.writeText(webUiUrl);
toast.success(t('relay.copiedLink'));
}}
title={t('relay.copyLink')}
>
<Copy className='size-3.5' />
</Button>
</div>
) : (
<p className='text-sm text-muted-foreground'>
{t('relay.webuiDisabled')}
</p>
)}
</div>
</NodeSectionCard>
<InstallCommand node={node} variant='relay' />
<NodeSectionCard title={t('relay.metadata')}>
<div className='divide-y'>
<NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label={t('relay.createdAt')}>
{formatDateTime(node.created_at)}
</NodeInfoRow>
<NodeInfoRow label={t('relay.updatedAt')}>
{formatDateTime(node.updated_at)}
</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
);
return (
<>
<NodeDetailShell
title={node.name}
typeLabel='Relay'
typeTone='warning'
statusBadges={[
{
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getRelayStatusLabel(node.relay_status, t),
tone: getRelayStatusTone(node.relay_status),
},
]}
actions={headerActions}
kpis={[
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: t('relay.relayVersion'),
value: node.version || 'unknown',
icon: Server,
},
{
label: t('relay.kpiFrps'),
value: node.ext_version || 'unknown',
icon: Network,
},
{
label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at)
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
overview={
<NodeObservability
nodeId={node.id}
variant='compact'
connectionHint={t('relay.connectionHint')}
/>
}
manage={manageTab}
defaultTab='overview'
/>
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('relay.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('relay.deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,406 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
Activity,
FileText,
Fingerprint,
KeyRound,
Package,
RefreshCw,
RotateCcw,
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { formatDateTime } from '@/lib/utils';
import type {
NodeAgentReleaseInfo,
NodeItem,
ReleaseChannel,
} from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { AgentUpdateDialog } from './agent-update-dialog';
import { InstallCommand } from './install-command';
import { NodeDetailShell } from './node-detail-shell';
import {
NodeErrorBanner,
NodeInfoRow,
NodeSectionCard,
} from './node-detail-primitives';
import { NodeEditorDialog } from './node-editor-dialog';
import { NodeObservability } from './node-observability';
import { NodeStatusBadge } from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getFlaredStatusLabel,
getFlaredStatusTone,
getNodeStatusLabel,
getNodeStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success(t('tunnel.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(t('tunnel.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name:
release?.channel === 'preview'
? release.tag_name || undefined
: undefined,
}),
onSuccess: async (updated) => {
toast.success(
t('tunnel.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success(t('tunnel.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', node.id],
}),
]);
};
const headerActions = (
<>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={() => setEditorOpen(true)}
>
{t('edit')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8'
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
{t('refresh')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8'
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='secondary'
size='sm'
className='h-8'
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? t('viewUpgrade') : t('tunnel.upgrade')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8 text-destructive hover:text-destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
{t('delete')}
</Button>
</>
);
const manageTab = (
<div className='space-y-6'>
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title={t('tunnel.runStatus')}
description={t('tunnel.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('tunnel.flaredStatus')}>
<NodeStatusBadge
label={getFlaredStatusLabel(node, t)}
tone={getFlaredStatusTone(node)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at)
? t('wsEstablished')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
<NodeInfoRow label={t('tunnel.autoUpdate')}>
{node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title={t('tunnel.syncTitle')}
description={t('tunnel.syncDesc')}
action={
<Button variant='outline' size='sm' className='h-8' asChild>
<Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
>
<FileText className='size-3.5 mr-1.5' />
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label={t('tunnel.currentVersion')}>
{node.current_version || t('notApplied')}
</NodeInfoRow>
<NodeInfoRow label={t('tunnel.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label={t('tunnel.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
: t('na')}
</NodeInfoRow>
{node.latest_apply_checksum ? (
<NodeInfoRow label={t('tunnel.fileCount')}>
{t('tunnel.fileCountValue', {
count: node.latest_support_file_count,
})}
</NodeInfoRow>
) : null}
<NodeInfoRow label={t('tunnel.upgradeStatus')}>
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? t('update.waitingPreview')
: t('update.waitingUpdate')
: t('update.notRequested')
}
tone={node.update_requested ? 'warning' : 'info'}
/>
</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
<InstallCommand node={node} variant='tunnel' />
<NodeSectionCard
title={t('tunnel.credentials')}
description={t('tunnel.credentialsDesc')}
>
<div className='divide-y'>
<NodeInfoRow label={t('tunnel.tunnelToken')}>
<span className='font-mono text-xs break-all'>
{node.access_token || t('na')}
</span>
</NodeInfoRow>
<NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label={t('tunnel.createdAt')}>
{formatDateTime(node.created_at)}
</NodeInfoRow>
<NodeInfoRow label={t('tunnel.updatedAt')}>
{formatDateTime(node.updated_at)}
</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
);
return (
<>
<NodeDetailShell
title={node.name}
typeLabel='Tunnel'
typeTone='info'
statusBadges={[
{
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getFlaredStatusLabel(node, t),
tone: getFlaredStatusTone(node),
},
]}
actions={headerActions}
kpis={[
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: t('tunnel.kpiOpenflared'),
value: node.version || 'unknown',
icon: Package,
},
{
label: t('kpiCurrentConfig'),
value: node.current_version || t('notApplied'),
icon: KeyRound,
},
{
label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at)
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
overview={
<NodeObservability
nodeId={node.id}
variant='compact'
connectionHint={t('tunnel.connectionHint')}
/>
}
manage={manageTab}
defaultTab='overview'
/>
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('tunnel.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('tunnel.deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,97 @@
'use client';
import Link from 'next/link';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { NodeService } from '@/lib/services/openflare';
import { EdgeNodeDetail } from '../components/edge-node-detail';
import { RelayNodeDetail } from '../components/relay-node-detail';
import { TunnelNodeDetail } from '../components/tunnel-node-detail';
import { getErrorMessage } from '../components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodeDetailPageClient() {
const t = useTranslations('nodes');
const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? '';
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const node = useMemo(() => {
if (!nodeId) return null;
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
if (!nodeId) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder icon={Server} description={t('missingId')} />
</div>
);
}
if (nodesQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={Server}
description={t('loadingDetail')}
/>
</div>
);
}
if (nodesQuery.isError) {
return (
<div className='py-6 px-1'>
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
onRetry={() => void nodesQuery.refetch()}
className='justify-center'
/>
</div>
</div>
);
}
if (!node) {
return (
<div className='py-6 px-1 space-y-4'>
<Button variant='ghost' size='sm' className='h-8 px-2' asChild>
<Link href='/nodes'>
<ArrowLeft className='size-4 mr-1' />
{t('backToList')}
</Link>
</Button>
<EmptyStateWithBorder icon={Server} description={t('notFound')} />
</div>
);
}
if (node.node_type === 'tunnel_relay') {
return <RelayNodeDetail node={node} />;
}
if (node.node_type === 'tunnel_client') {
return <TunnelNodeDetail node={node} />;
}
return <EdgeNodeDetail node={node} />;
}
+23
View File
@@ -0,0 +1,23 @@
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { NodeDetailPageClient } from './page-client';
function NodeDetailPageFallback() {
return (
<div className='py-6 px-1 space-y-6'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-10 w-full max-w-xl' />
<Skeleton className='h-64 w-full' />
</div>
);
}
export default function NodeDetailPage() {
return (
<Suspense fallback={<NodeDetailPageFallback />}>
<NodeDetailPageClient />
</Suspense>
);
}
+247
View File
@@ -0,0 +1,247 @@
'use client';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { NodeItem, NodeMutationPayload } from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { NodeEditorDialog } from './components/node-editor-dialog';
import {
filterNodesByType,
getFilterDescription,
getNodeFilter,
NodeTypeFilter,
} from './components/node-type-filter';
import { NodesTable } from './components/nodes-table';
import { getErrorMessage } from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodesPageClient() {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(null);
const nodeFilter = useMemo(
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const filteredNodes = useMemo(
() => filterNodesByType(nodes, nodeFilter),
[nodeFilter, nodes],
);
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
if (editingNode) {
return NodeService.updateNode(editingNode.id, payload);
}
return NodeService.createNode(payload);
},
onSuccess: async () => {
toast.success(editingNode ? t('updated') : t('created'));
setEditingNode(null);
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error, t('requestFailed')));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => NodeService.deleteNode(id),
onSuccess: async () => {
toast.success(t('deleted'));
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error, t('requestFailed')));
},
});
const handleCreate = () => {
setEditingNode(null);
setEditorOpen(true);
};
const handleEdit = (node: NodeItem) => {
setEditingNode(node);
setEditorOpen(true);
};
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey });
};
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Server className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
<Link href='/apply-logs'>{t('applyLogs')}</Link>
</Button>
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={handleCreate}
>
<Plus className='size-3.5 mr-1' />
{t('create')}
</Button>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between gap-3'>
<div>
<CardTitle className='text-base font-semibold'>
{t('listTitle')}
</CardTitle>
<CardDescription>
{getFilterDescription(nodeFilter, t)}
</CardDescription>
</div>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={handleRefresh}
disabled={nodesQuery.isFetching}
>
{nodesQuery.isFetching ? (
<Loader2 className='size-3.5 mr-1 animate-spin' />
) : (
<RefreshCw className='size-3.5 mr-1' />
)}
{t('refreshNow')}
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<NodeTypeFilter />
{nodesQuery.isLoading ? (
<LoadingStateWithBorder
icon={Server}
description={t('loadingList')}
/>
) : nodesQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
onRetry={handleRefresh}
className='justify-center'
/>
</div>
) : filteredNodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
}
/>
) : (
<NodesTable
nodes={filteredNodes}
deletingId={
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
}
onEdit={handleEdit}
onDelete={setDeleteTarget}
/>
)}
</CardContent>
</Card>
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={saveMutation.isPending}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { NodesPageClient } from './page-client';
function NodesPageFallback() {
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-8 w-40' />
<div className='flex gap-2'>
<Skeleton className='h-7 w-20' />
<Skeleton className='h-7 w-24' />
</div>
</div>
<Skeleton className='h-96 w-full' />
</div>
);
}
export default function NodesPage() {
return (
<Suspense fallback={<NodesPageFallback />}>
<NodesPageClient />
</Suspense>
);
}
@@ -0,0 +1,179 @@
'use client';
import { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { z } from 'zod';
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 { Textarea } from '@/components/ui/textarea';
import {
type OriginItem,
type OriginMutationPayload,
OriginService,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
type OriginFormValues = {
name: string;
address: string;
remark: string;
};
const originsQueryKey = ['openflare', 'origins'] as const;
function toFormValues(origin?: OriginItem | null): OriginFormValues {
if (!origin) return { name: '', address: '', remark: '' };
return {
name: origin.name,
address: origin.address,
remark: origin.remark || '',
};
}
function toPayload(values: OriginFormValues): OriginMutationPayload {
return {
name: values.name.trim(),
address: values.address.trim(),
remark: values.remark.trim(),
};
}
interface OriginEditorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
origin?: OriginItem | null;
onSaved?: () => void;
}
export function OriginEditorDialog({
open,
onOpenChange,
origin,
onSaved,
}: OriginEditorDialogProps) {
const t = useTranslations('origins');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const originSchema = z.object({
name: z.string().max(255),
address: z
.string()
.trim()
.min(1, t('addressRequired'))
.refine(
(value) => !/[/?#]/.test(value) && !value.includes('://'),
t('addressInvalid'),
),
remark: z.string().max(255),
});
const form = useForm<OriginFormValues>({
resolver: zodResolver(originSchema),
defaultValues: toFormValues(origin),
});
useEffect(() => {
if (open) form.reset(toFormValues(origin));
}, [form, origin, open]);
const mutation = useMutation({
mutationFn: async (values: OriginFormValues) => {
const payload = toPayload(values);
return origin
? OriginService.update(origin.id, payload)
: OriginService.create(payload);
},
onSuccess: async () => {
toast.success(origin ? t('updated') : t('created'));
await queryClient.invalidateQueries({ queryKey: originsQueryKey });
if (origin) {
await queryClient.invalidateQueries({
queryKey: ['openflare', 'origins', String(origin.id)],
});
}
onSaved?.();
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('saveFailed'));
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{origin ? t('editOrigin') : t('create')}</DialogTitle>
<DialogDescription>{t('editorDesc')}</DialogDescription>
</DialogHeader>
<form
id='origin-editor-form'
className='space-y-4'
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className='space-y-1.5'>
<Label htmlFor='address'>{t('address')}</Label>
<Input
id='address'
placeholder='origin.internal'
{...form.register('address')}
/>
{form.formState.errors.address ? (
<p className='text-xs text-destructive'>
{form.formState.errors.address.message}
</p>
) : null}
</div>
<div className='space-y-1.5'>
<Label htmlFor='name'>{t('name')}</Label>
<Input
id='name'
placeholder={t('namePlaceholder')}
{...form.register('name')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='remark'>{t('remark')}</Label>
<Textarea id='remark' rows={2} {...form.register('remark')} />
</div>
</form>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{tc('cancel')}
</Button>
<Button
type='submit'
form='origin-editor-form'
disabled={mutation.isPending}
>
{mutation.isPending ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
{t('saving')}
</>
) : origin ? (
t('saveChanges')
) : (
t('create')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,272 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import { ArrowLeft, MapPin, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { OriginService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { OriginEditorDialog } from '../components/origin-editor-dialog';
export function OriginDetailPageClient() {
const t = useTranslations('origins');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const originId = searchParams.get('id')?.trim() ?? '';
const parsedId = Number(originId);
const enabled = originId !== '' && Number.isFinite(parsedId);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const originQuery = useQuery({
queryKey: ['openflare', 'origins', originId],
queryFn: () => OriginService.getById(parsedId),
enabled,
});
const deleteMutation = useMutation({
mutationFn: () => OriginService.deleteById(parsedId),
onSuccess: async () => {
toast.success(t('deleted'));
await queryClient.invalidateQueries({
queryKey: ['openflare', 'origins'],
});
window.location.href = '/origins';
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'));
},
});
const origin = originQuery.data;
if (!enabled) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder description={t('missingId')} />
</div>
);
}
if (originQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder
icon={MapPin}
description={t('loadingDetail')}
/>
</div>
);
}
if (originQuery.isError) {
return (
<div className='py-6 px-1'>
<ErrorInline
message={
originQuery.error instanceof Error
? originQuery.error.message
: t('loadFailed')
}
onRetry={() => void originQuery.refetch()}
/>
</div>
);
}
if (!origin) {
return (
<div className='py-6 px-1 space-y-4'>
<Button variant='ghost' size='sm' asChild>
<Link href='/origins'>
<ArrowLeft className='size-4 mr-1' />
{t('backToList')}
</Link>
</Button>
<EmptyStateWithBorder description={t('notFound')} />
</div>
);
}
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-2'>
<Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild>
<Link href='/origins'>
<ArrowLeft className='size-4 mr-1' />
{t('backToList')}
</Link>
</Button>
<div className='flex items-center gap-2'>
<MapPin className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{origin.name}
</h1>
</div>
<p className='text-sm text-muted-foreground'>{origin.address}</p>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => setEditorOpen(true)}
>
{t('editOrigin')}
</Button>
<Button
variant='destructive'
size='sm'
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
{t('deleteOrigin')}
</Button>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('boundRoutes')}
</p>
<Badge variant='outline' className='mt-2 text-[10px]'>
{t('routeCount', { count: origin.route_count })}
</Badge>
</div>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('createdAt')}
</p>
<p className='mt-2 text-sm'>{formatDateTime(origin.created_at)}</p>
</div>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('updatedAt')}
</p>
<p className='mt-2 text-sm'>{formatDateTime(origin.updated_at)}</p>
</div>
<div className='rounded-lg border border-dashed px-4 py-3 sm:col-span-2 xl:col-span-1'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{t('remark')}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{origin.remark || t('noRemark')}
</p>
</div>
</div>
<div className='border border-dashed rounded-lg overflow-hidden bg-background'>
<div className='px-4 py-3 border-b border-dashed'>
<h2 className='text-sm font-semibold'>{t('relatedRoutes')}</h2>
<p className='text-xs text-muted-foreground mt-1'>
{t('relatedRoutesDesc')}
</p>
</div>
{origin.routes.length === 0 ? (
<EmptyStateWithBorder
title={t('noRelatedRoutes')}
description={t('noRelatedRoutesDesc')}
/>
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'>
{t('domain')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('address')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('status')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('updatedAt')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{origin.routes.map((route) => (
<TableRow key={route.id} className='border-dashed'>
<TableCell className='text-xs font-medium'>
{route.domain}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{route.origin_url}
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{route.enabled ? t('enabled') : t('disabled')}
</Badge>
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{formatDateTime(route.updated_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<OriginEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
origin={origin}
onSaved={() => void originQuery.refetch()}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteDesc', { name: origin.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => deleteMutation.mutate()}
>
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,23 @@
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { OriginDetailPageClient } from './page-client';
function OriginDetailPageFallback() {
return (
<div className='py-6 px-1 space-y-6'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-10 w-full max-w-xl' />
<Skeleton className='h-64 w-full' />
</div>
);
}
export default function OriginDetailPage() {
return (
<Suspense fallback={<OriginDetailPageFallback />}>
<OriginDetailPageClient />
</Suspense>
);
}

Some files were not shown because too many files have changed in this diff Show More