mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 23:06:36 +08:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # AGENTS.md # backend/docs/docs.go # backend/docs/swagger.json # backend/docs/swagger.yaml # frontend/components/providers/title-updater.tsx # frontend/messages/fragments/admin.en.json # frontend/messages/fragments/admin.zh-CN.json # frontend/proxy.ts
This commit is contained in:
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeMain } from '@/components/common/home/home-main';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomeMain />;
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user