[优化] go 引用调整

This commit is contained in:
ryan
2026-06-06 10:26:20 +08:00
parent ee1110b752
commit 3cfefb4367
552 changed files with 1642 additions and 2185 deletions
@@ -0,0 +1,134 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { marked } from 'marked';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getPublicStatus } from '@/features/auth/api/public';
import { getAboutContent } from '@/features/settings/api/settings';
import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function AboutPage() {
const aboutQuery = useQuery({
queryKey: ['about'],
queryFn: getAboutContent,
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
if (aboutQuery.isLoading || statusQuery.isLoading) {
return <LoadingState />;
}
if (aboutQuery.isError) {
return (
<ErrorState
title="关于页加载失败"
description={getErrorMessage(aboutQuery.error)}
/>
);
}
if (statusQuery.isError) {
return (
<ErrorState
title="系统状态加载失败"
description={getErrorMessage(statusQuery.error)}
/>
);
}
const aboutContent = aboutQuery.data?.trim() ?? '';
const status = statusQuery.data;
if (!status) {
return (
<EmptyState
title="暂无系统信息"
description="未能获取 OpenFlare 的公开状态信息。"
/>
);
}
return (
<div className="space-y-6">
<AppCard
title="关于 OpenFlare"
description="公开展示当前系统简介、版本信息与项目入口。"
action={<StatusBadge label={status.version || 'dev'} variant="info" />}
>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
系统名称
</p>
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
{status.system_name || 'OpenFlare'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Server 启动时间
</p>
<p className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
{formatDateTime(new Date(status.start_time * 1000))}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
项目仓库
</p>
<a
href="https://github.com/Rain-kl/OpenFlare"
target="_blank"
rel="noreferrer"
className="mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
github.com/Rain-kl/OpenFlare
</a>
</div>
</div>
</AppCard>
{aboutContent ? (
<AppCard
title="项目介绍"
description="以下内容由系统设置中的“关于内容”维护。"
>
<div
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
dangerouslySetInnerHTML={{
__html: marked.parse(aboutContent) as string,
}}
/>
</AppCard>
) : (
<EmptyState
title="尚未配置关于内容"
description="可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。"
/>
)}
<div className="flex flex-wrap gap-3 text-sm">
<Link
href="/login"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
前往登录
</Link>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { apiRequest } from '@/lib/api/client';
import type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryList,
AccessLogIPTrend,
AccessLogIPTrendFilters,
AccessLogList,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
FoldedAccessLogFilters,
FoldedAccessLogList,
} from '@/features/access-logs/types';
function buildSearchParams(filters: object) {
const searchParams = new URLSearchParams();
Object.entries(
filters as Record<string, string | number | undefined>,
).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
searchParams.set(key, String(value));
});
return searchParams.toString();
}
export function getAccessLogs(filters: AccessLogFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogList>(`/access-logs/${query ? `?${query}` : ''}`);
}
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
const query = buildSearchParams(filters);
return apiRequest<FoldedAccessLogList>(
`/access-logs/folds${query ? `?${query}` : ''}`,
);
}
export function getFoldedAccessLogIPs(filters: FoldedAccessLogIPFilters) {
const query = buildSearchParams(filters);
return apiRequest<FoldedAccessLogIPList>(
`/access-logs/folds/ip-summary${query ? `?${query}` : ''}`,
);
}
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogIPSummaryList>(
`/access-logs/ip-summary${query ? `?${query}` : ''}`,
);
}
export function getAccessLogIPTrend(filters: AccessLogIPTrendFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogIPTrend>(
`/access-logs/ip-summary/trend${query ? `?${query}` : ''}`,
);
}
export function cleanupAccessLogs(payload: AccessLogCleanupPayload) {
return apiRequest<AccessLogCleanupResult>('/access-logs/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
export interface AccessLogFilters {
node_id?: string;
remote_addr?: string;
host?: string;
path?: string;
p?: number;
page_size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AccessLogItem {
id: number;
node_id: string;
node_name: string;
logged_at: string;
remote_addr: string;
region: string;
host: string;
path: string;
status_code: number;
}
export interface AccessLogList {
items: AccessLogItem[];
page: number;
page_size: number;
has_more: boolean;
total_record: number;
total_ip: number;
}
export interface FoldedAccessLogFilters extends AccessLogFilters {
fold_minutes: 3 | 5;
}
export interface FoldedAccessLogItem {
bucket_started_at: string;
request_count: number;
unique_ip_count: number;
unique_host_count: number;
success_count: number;
client_error_count: number;
server_error_count: number;
}
export interface FoldedAccessLogList {
items: FoldedAccessLogItem[];
page: number;
page_size: number;
has_more: boolean;
total_bucket: number;
total_record: number;
total_ip: number;
fold_minutes: number;
}
export interface FoldedAccessLogIPFilters extends FoldedAccessLogFilters {
bucket_started_at: string;
}
export interface FoldedAccessLogIPItem {
remote_addr: string;
request_count: number;
success_count: number;
client_error_count: number;
server_error_count: number;
last_seen_at: string;
}
export interface FoldedAccessLogIPList {
items: FoldedAccessLogIPItem[];
page: number;
page_size: number;
has_more: boolean;
total_ip: number;
bucket_started_at: string;
fold_minutes: number;
sort_by: string;
sort_order: 'asc' | 'desc';
}
export interface AccessLogIPSummaryFilters {
node_id?: string;
remote_addr?: string;
host?: string;
p?: number;
page_size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
export interface AccessLogIPSummaryItem {
remote_addr: string;
total_requests: number;
recent_requests: number;
last_seen_at: string;
}
export interface AccessLogIPSummaryList {
items: AccessLogIPSummaryItem[];
page: number;
page_size: number;
has_more: boolean;
total_ip: number;
sort_by: string;
sort_order: 'asc' | 'desc';
}
export interface AccessLogIPTrendFilters {
node_id?: string;
remote_addr: string;
host?: string;
hours?: number;
bucket_minutes?: number;
}
export interface AccessLogIPTrendPoint {
bucket_started_at: string;
request_count: number;
}
export interface AccessLogIPTrend {
remote_addr: string;
hours: number;
bucket_minutes: number;
points: AccessLogIPTrendPoint[];
}
export interface AccessLogCleanupPayload {
retention_days: number;
}
export interface AccessLogCleanupResult {
retention_days: number;
deleted_count: number;
cutoff: string;
}
@@ -0,0 +1,6 @@
import { apiRequest } from '@/lib/api/client';
import type { AcmeAccountItem } from '@/features/acme-accounts/types';
export function getDefaultAcmeAccount() {
return apiRequest<AcmeAccountItem>('/acme-accounts/default');
}
@@ -0,0 +1,7 @@
export interface AcmeAccountItem {
id: number;
email: string;
url: string;
created_at: string;
updated_at: string;
}
@@ -0,0 +1,31 @@
import { apiRequest } from '@/lib/api/client';
import type {
ApplyLogCleanupPayload,
ApplyLogCleanupResult,
ApplyLogList,
ApplyLogListQuery,
} from '@/features/apply-logs/types';
export function getApplyLogs(query: ApplyLogListQuery = {}) {
const params = new URLSearchParams();
const normalizedNodeId = query.node_id?.trim();
if (normalizedNodeId) {
params.set('node_id', normalizedNodeId);
}
if (query.pageNo) {
params.set('pageNo', String(query.pageNo));
}
if (query.pageSize) {
params.set('pageSize', String(query.pageSize));
}
const suffix = params.size > 0 ? `?${params.toString()}` : '';
return apiRequest<ApplyLogList>(`/apply-logs/${suffix}`);
}
export function cleanupApplyLogs(payload: ApplyLogCleanupPayload) {
return apiRequest<ApplyLogCleanupResult>('/apply-logs/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -0,0 +1,553 @@
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AppModal } from '@/components/ui/app-modal';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
cleanupApplyLogs,
getApplyLogs,
} from '@/features/apply-logs/api/apply-logs';
import type {
ApplyLogCleanupPayload,
ApplyLogItem,
} from '@/features/apply-logs/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
const applyLogsQueryKey = (nodeId: string, pageNo: number, pageSize: number) =>
['apply-logs', nodeId, pageNo, pageSize] as const;
const pageSizeOptions = [20, 50, 100];
const emptyApplyLogRows: ApplyLogItem[] = [];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function getResultMeta(result: string) {
if (result === 'success') {
return { label: '成功', variant: 'success' as const };
}
if (result === 'warning') {
return { label: '警告', variant: 'warning' as const };
}
return { label: '失败', variant: 'danger' as const };
}
function truncateHash(value: string) {
if (!value) {
return '—';
}
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
}
function buildSummary(
rows: ApplyLogItem[],
total: number,
current: number,
totalPage: number,
) {
const nodeIds = new Set(rows.map((item) => item.node_id));
return [
{ label: '总记录数', value: total },
{ label: '当前页', value: current },
{ label: '总页数', value: totalPage },
{ label: '当前页节点数', value: nodeIds.size },
];
}
export function ApplyLogsPage() {
const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState('');
const [nodeFilter, setNodeFilter] = useState('');
const [pageNo, setPageNo] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [selectedLog, setSelectedLog] = useState<ApplyLogItem | null>(null);
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
const [cleanupMode, setCleanupMode] = useState<'all' | 'custom'>('custom');
const [customRetentionDays, setCustomRetentionDays] = useState('30');
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
useEffect(() => {
const nodeID = new URLSearchParams(window.location.search)
.get('node_id')
?.trim();
if (!nodeID) {
return;
}
setNodeFilterInput(nodeID);
setNodeFilter(nodeID);
setPageNo(1);
}, []);
const logsQuery = useQuery({
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
queryFn: () =>
getApplyLogs({
node_id: nodeFilter || undefined,
pageNo,
pageSize,
}),
placeholderData: (previous) => previous,
});
const cleanupMutation = useMutation({
mutationFn: (payload: ApplyLogCleanupPayload) => cleanupApplyLogs(payload),
onSuccess: async (result) => {
setCleanupModalOpen(false);
setPageNo(1);
setFeedback({
tone: 'success',
message: result.delete_all
? `已删除全部应用日志,共 ${result.deleted_count} 条。`
: `已清理保留期之外的应用日志,共 ${result.deleted_count} 条。`,
});
await queryClient.invalidateQueries({ queryKey: ['apply-logs'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const rows = logsQuery.data?.rows ?? emptyApplyLogRows;
const current = logsQuery.data?.current ?? pageNo;
const total = logsQuery.data?.total ?? 0;
const totalPage = logsQuery.data?.totalPage ?? 0;
const summary = useMemo(
() => buildSummary(rows, total, current, totalPage),
[rows, total, current, totalPage],
);
useEffect(() => {
if (rows.length === 0 && selectedLog) {
setSelectedLog(null);
return;
}
if (selectedLog && !rows.some((item) => item.id === selectedLog.id)) {
setSelectedLog(null);
}
}, [rows, selectedLog]);
const handleSearch = () => {
setFeedback(null);
setPageNo(1);
setNodeFilter(nodeFilterInput.trim());
};
const handleReset = () => {
setFeedback(null);
setNodeFilter('');
setNodeFilterInput('');
setPageNo(1);
};
const handleRefresh = async () => {
setFeedback(null);
await queryClient.invalidateQueries({
queryKey: applyLogsQueryKey(nodeFilter, pageNo, pageSize),
});
};
const handleCleanupConfirm = () => {
const payload: ApplyLogCleanupPayload =
cleanupMode === 'all'
? { delete_all: true }
: {
retention_days: Number.parseInt(customRetentionDays, 10),
};
cleanupMutation.mutate(payload);
};
return (
<div className="space-y-6">
<PageHeader
title="应用日志"
description="查看节点应用配置的成功、警告和失败记录,支持分页查询、详情弹窗和按保留天数清理。"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton type="button" onClick={handleRefresh}>
刷新
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setFeedback(null);
setCleanupModalOpen(true);
}}
>
删除日志
</PrimaryButton>
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回节点
</Link>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard
title="日志摘要"
description="后端按分页返回应用日志,页面仅展示当前页数据和总量信息。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{item.value}
</p>
</div>
))}
</div>
</AppCard>
<AppCard
title="过滤与列表"
description="支持按 node_id 过滤,并按页查看应用结果。默认每页 20 条。"
>
<div className="space-y-5">
<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)_180px]">
<ResourceField label="Node ID">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder="输入 node_id 过滤应用日志"
/>
</ResourceField>
<ResourceField label="每页条数">
<ResourceSelect
value={String(pageSize)}
onChange={(event) => {
setPageSize(Number.parseInt(event.target.value, 10));
setPageNo(1);
}}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option} 条
</option>
))}
</ResourceSelect>
</ResourceField>
</div>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
</div>
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState
title="应用日志加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : rows.length === 0 ? (
<EmptyState
title="暂无应用日志"
description="当前筛选条件下没有可展示的应用记录。"
/>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">Node ID</th>
<th className="px-3 py-3 font-medium">版本</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">消息</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{rows.map((log) => {
const resultMeta = getResultMeta(log.result);
return (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{log.node_id}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={resultMeta.label}
variant={resultMeta.variant}
/>
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={log.checksum}
>
{truncateHash(log.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="space-y-1">
<p>{formatDateTime(log.created_at)}</p>
<p className="text-xs">
{formatRelativeTime(log.created_at)}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="max-w-72 break-words whitespace-pre-wrap">
{log.message || '—'}
</div>
</td>
<td className="px-3 py-4">
<SecondaryButton
type="button"
className="px-3 py-2 text-xs"
onClick={() => setSelectedLog(log)}
>
查看详情
</SecondaryButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]">
第 {current} / {Math.max(totalPage, 1)} 页,共 {total}{' '}
条记录。
</p>
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
disabled={current <= 1}
onClick={() =>
setPageNo((previous) => Math.max(1, previous - 1))
}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
disabled={totalPage === 0 || current >= totalPage}
onClick={() =>
setPageNo((previous) =>
totalPage > 0
? Math.min(totalPage, previous + 1)
: previous,
)
}
>
下一页
</SecondaryButton>
</div>
</div>
</>
)}
</div>
</AppCard>
<AppModal
isOpen={selectedLog !== null}
title="应用日志详情"
description="查看单条应用日志的完整结果、消息和校验信息。"
size="lg"
onClose={() => setSelectedLog(null)}
>
{selectedLog ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge
label={`Node:${selectedLog.node_id}`}
variant="info"
/>
<StatusBadge
label={`版本:${selectedLog.version}`}
variant="warning"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<MetricCard
label="创建时间"
value={formatDateTime(selectedLog.created_at)}
/>
<MetricCard
label="相对时间"
value={formatRelativeTime(selectedLog.created_at)}
/>
<MetricCard
label="目标 Checksum"
value={selectedLog.checksum || '—'}
breakAll
/>
<MetricCard
label="支持文件数"
value={String(selectedLog.support_file_count)}
/>
<MetricCard
label="主配置摘要"
value={selectedLog.main_config_checksum || '—'}
breakAll
/>
<MetricCard
label="路由配置摘要"
value={selectedLog.route_config_checksum || '—'}
breakAll
/>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
消息
</p>
<pre className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
{selectedLog.message || '—'}
</pre>
</div>
</div>
) : null}
</AppModal>
<AppModal
isOpen={isCleanupModalOpen}
title="删除应用日志"
description="可以选择删除全部应用日志,或仅保留最近自定义天数内的记录。"
onClose={() => setCleanupModalOpen(false)}
footer={
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton
type="button"
onClick={() => setCleanupModalOpen(false)}
>
取消
</SecondaryButton>
<PrimaryButton
type="button"
disabled={cleanupMutation.isPending}
onClick={handleCleanupConfirm}
>
{cleanupMutation.isPending ? '处理中...' : '确认删除'}
</PrimaryButton>
</div>
}
>
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCleanupMode('all')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupMode === 'all'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
全部删除
</button>
<button
type="button"
onClick={() => setCleanupMode('custom')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupMode === 'custom'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
保留自定义天数
</button>
</div>
{cleanupMode === 'custom' ? (
<ResourceField label="保留天数" hint="当前支持 1 到 3650 天。">
<ResourceInput
value={customRetentionDays}
onChange={(event) => setCustomRetentionDays(event.target.value)}
type="number"
min={1}
max={3650}
placeholder="输入保留天数"
/>
</ResourceField>
) : null}
{cleanupMutation.isError ? (
<ErrorState
title="删除应用日志失败"
description={getErrorMessage(cleanupMutation.error)}
/>
) : null}
</div>
</AppModal>
</div>
);
}
function MetricCard({
label,
value,
breakAll = false,
}: {
label: string;
value: string;
breakAll?: boolean;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p
className={`mt-2 text-sm text-[var(--foreground-primary)] ${
breakAll ? 'break-all' : ''
}`}
>
{value}
</p>
</div>
);
}
@@ -0,0 +1,37 @@
export interface ApplyLogItem {
id: number;
node_id: string;
version: string;
result: 'success' | 'failed' | string;
message: string;
checksum: string;
main_config_checksum: string;
route_config_checksum: string;
support_file_count: number;
created_at: string;
}
export interface ApplyLogList {
rows: ApplyLogItem[];
current: number;
total: number;
totalPage: number;
}
export interface ApplyLogListQuery {
node_id?: string;
pageNo?: number;
pageSize?: number;
}
export interface ApplyLogCleanupPayload {
delete_all?: boolean;
retention_days?: number;
}
export interface ApplyLogCleanupResult {
delete_all: boolean;
retention_days: number;
deleted_count: number;
cutoff?: string;
}
@@ -0,0 +1,118 @@
import { apiRequest } from '@/lib/api/client';
import {
clearStoredOpenFlareToken,
setStoredOpenFlareToken,
} from '@/lib/api/auth-token';
import type {
AuthUser,
LoginPayload,
PasswordResetRequestPayload,
RegisterPayload,
} from '@/types/auth';
export function getCurrentUser() {
return apiRequest<AuthUser>('/user/self');
}
export function login(payload: LoginPayload) {
return apiRequest<AuthUser>('/user/login', {
method: 'POST',
body: JSON.stringify(payload),
}).then((user) => {
if (user.token) {
setStoredOpenFlareToken(user.token);
}
return user;
});
}
export function logout() {
return apiRequest<void>('/user/logout').finally(() => {
clearStoredOpenFlareToken();
});
}
export function register(payload: RegisterPayload) {
return apiRequest<void>('/user/register', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function sendEmailVerification(email: string) {
const searchParams = new URLSearchParams({ email });
return apiRequest<void>(`/verification?${searchParams.toString()}`);
}
export function sendPasswordResetEmail(email: string) {
const searchParams = new URLSearchParams({ email });
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
}
export function resetPassword(payload: PasswordResetRequestPayload) {
return apiRequest<string>('/user/reset', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function exchangeGitHubCode(code: string) {
return apiRequest<AuthUser>(
`/oauth/github?code=${encodeURIComponent(code)}`,
).then((user) => {
if (user.token) {
setStoredOpenFlareToken(user.token);
}
return user;
});
}
export interface OAuthAuthorizeResult {
authorize_url: string;
}
export interface OAuthCallbackResult {
status: 'logged_in' | 'registered' | 'linked' | 'link_required';
user?: AuthUser;
}
export interface LinkExistingOAuthPayload {
username: string;
password: string;
}
export function getOAuthAuthorizeUrl(source: number | string) {
return apiRequest<OAuthAuthorizeResult>(
`/oauth/${encodeURIComponent(String(source))}/authorize`,
);
}
export function exchangeOAuthCode(
source: number | string,
code: string,
state: string,
) {
const searchParams = new URLSearchParams({ code, state });
return apiRequest<OAuthCallbackResult>(
`/oauth/${encodeURIComponent(String(source))}/callback?${searchParams.toString()}`,
).then((result) => {
if (result.user?.token) {
setStoredOpenFlareToken(result.user.token);
}
return result;
});
}
export function linkExistingOAuthAccount(payload: LinkExistingOAuthPayload) {
return apiRequest<OAuthCallbackResult>('/oauth/link-existing', {
method: 'POST',
body: JSON.stringify(payload),
}).then((result) => {
if (result.user?.token) {
setStoredOpenFlareToken(result.user.token);
}
return result;
});
}
@@ -0,0 +1,6 @@
import {apiRequest} from '@/lib/api/client';
import type {PublicStatus} from '@/types/public-status';
export function getPublicStatus() {
return apiRequest<PublicStatus>('/status');
}
@@ -0,0 +1,73 @@
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
} from 'react';
import { cn } from '@/lib/utils/cn';
export function AuthFormField({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="block space-y-2">
<span className="text-sm font-medium text-[var(--foreground-primary)]">
{label}
</span>
{children}
{hint ? (
<span className="block text-xs text-[var(--foreground-secondary)]">
{hint}
</span>
) : null}
</label>
);
}
export function AuthInput(props: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={cn(
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]',
props.className,
)}
/>
);
}
export function AuthButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={cn(
'inline-flex w-full items-center justify-center rounded-2xl bg-[var(--brand-primary)] px-4 py-3 text-sm font-medium text-[var(--foreground-inverse)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
/>
);
}
export function SecondaryButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={cn(
'inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
/>
);
}
@@ -0,0 +1,38 @@
'use client';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
interface DashboardAuthGuardProps {
children: ReactNode;
}
export function DashboardAuthGuard({ children }: DashboardAuthGuardProps) {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
const redirect =
pathname && pathname !== '/'
? `?redirect=${encodeURIComponent(pathname)}`
: '';
router.replace(`/login${redirect}`);
}
}, [isAuthenticated, isLoading, pathname, router]);
if (isLoading || !isAuthenticated) {
return (
<main className="flex min-h-screen items-center justify-center px-4 py-8">
<LoadingState className="w-full max-w-md" />
</main>
);
}
return <>{children}</>;
}
@@ -0,0 +1,69 @@
'use client';
import {useMutation} from '@tanstack/react-query';
import {useRouter, useSearchParams} from 'next/navigation';
import {useEffect, useRef, useState} from 'react';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {useAuth} from '@/components/providers/auth-provider';
import {AppCard} from '@/components/ui/app-card';
import {exchangeGitHubCode} from '@/features/auth/api/auth';
export function GitHubOAuthCallback() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const handledCodeRef = useRef<string | null>(null);
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
const [message, setMessage] = useState<{
tone: 'danger' | 'success';
text: string;
} | null>(null);
const code = searchParams?.get('code')?.trim() ?? '';
const mutation = useMutation({
mutationFn: exchangeGitHubCode,
onSuccess: (user) => {
setUser(user);
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
router.replace('/');
},
onError: (error: Error) => {
setPrompt('授权处理失败');
setMessage({
tone: 'danger',
text: error.message || 'GitHub 授权失败,请稍后重试。',
});
},
});
useEffect(() => {
if (!code) {
setPrompt('缺少授权 code');
setMessage({
tone: 'danger',
text: '未收到 GitHub 授权参数,请返回登录页重试。',
});
return;
}
if (handledCodeRef.current === code) {
return;
}
handledCodeRef.current = code;
mutation.mutate(code);
}, [code, mutation]);
return (
<AppCard title="GitHub OAuth 回调" description={prompt}>
<div className="space-y-4">
{mutation.isPending ? <LoadingState /> : null}
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
</div>
</AppCard>
);
}
@@ -0,0 +1,164 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQuery} from '@tanstack/react-query';
import Link from 'next/link';
import {useRouter, useSearchParams} from 'next/navigation';
import {useState} from 'react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {InlineMessage} from '@/components/feedback/inline-message';
import {useAuth} from '@/components/providers/auth-provider';
import {AppCard} from '@/components/ui/app-card';
import {getOAuthAuthorizeUrl, login} from '@/features/auth/api/auth';
import {getPublicStatus} from '@/features/auth/api/public';
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
const TEXT = {
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
loginFailed:
'\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
oauthUnavailable: '第三方登录当前不可用。',
title: '\u7528\u6237\u767b\u5f55',
username: '\u7528\u6237\u540d',
password: '\u5bc6\u7801',
loginPending: '\u767b\u5f55\u4e2d...',
login: '\u767b\u5f55',
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
register: '\u6ce8\u518c',
};
const loginSchema = z.object({
username: z.string().min(1, TEXT.usernameRequired),
password: z.string().min(1, TEXT.passwordRequired),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const redirect = searchParams?.get('redirect') || '/';
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
},
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
setUser(user);
router.replace(redirect);
},
onError: (error: Error) => {
setErrorMessage(error.message || TEXT.loginFailed);
},
});
const oauthMutation = useMutation({
mutationFn: getOAuthAuthorizeUrl,
onSuccess: (result) => {
window.location.href = result.authorize_url;
},
onError: (error: Error) => {
setErrorMessage(error.message || TEXT.oauthUnavailable);
},
});
const handleSubmit = form.handleSubmit((values) => {
setErrorMessage('');
loginMutation.mutate(values);
});
const handleOAuthLogin = (sourceName: string) => {
setErrorMessage('');
oauthMutation.mutate(sourceName);
};
return (
<PublicAuthGuard>
<AppCard title={TEXT.title}>
<form className="space-y-4" onSubmit={handleSubmit}>
<AuthFormField label={TEXT.username}>
<AuthInput
placeholder={TEXT.username}
{...form.register('username')}
/>
{form.formState.errors.username ? (
<span className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.password}>
<AuthInput
type="password"
placeholder={TEXT.password}
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
{errorMessage ? (
<InlineMessage tone="danger" message={errorMessage} />
) : null}
<div>
<AuthButton type="submit" disabled={loginMutation.isPending}>
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
</AuthButton>
</div>
{(statusQuery.data?.auth_sources ?? []).length > 0 ? (
<div className="flex flex-col items-center gap-3 pt-1">
<div className="text-xs text-[var(--foreground-secondary)]">
第三方账号登录
</div>
<div className="flex flex-wrap justify-center gap-3">
{(statusQuery.data?.auth_sources ?? []).map((source) => (
<SecondaryButton
key={source.id}
type="button"
onClick={() => handleOAuthLogin(source.name)}
className="min-w-36"
disabled={oauthMutation.isPending}
>
{source.display_name || source.name} 登录
</SecondaryButton>
))}
</div>
</div>
) : null}
</form>
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]">
<Link
href="/reset"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
{TEXT.forgotPassword}
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -0,0 +1,136 @@
'use client';
import {useMutation} from '@tanstack/react-query';
import Link from 'next/link';
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
import {useEffect, useRef, useState} from 'react';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {useAuth} from '@/components/providers/auth-provider';
import {AppCard} from '@/components/ui/app-card';
import {exchangeOAuthCode} from '@/features/auth/api/auth';
function parseOAuthSource(pathname: string | null, sourceParam: string) {
const normalizedParam = sourceParam.trim();
if (normalizedParam) {
return normalizedParam;
}
const pathMatch = pathname?.match(/^\/oauth\/([^/?#]+)$/);
if (!pathMatch) {
return '';
}
return decodeURIComponent(pathMatch[1]);
}
export function OAuthCallback({ sourceId }: { sourceId?: number }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const handledRef = useRef('');
const [prompt, setPrompt] = useState('正在处理授权结果...');
const [message, setMessage] = useState<{
tone: 'danger' | 'success';
text: string;
} | null>(null);
const code = searchParams?.get('code')?.trim() ?? '';
const state = searchParams?.get('state')?.trim() ?? '';
const oauthError = searchParams?.get('error')?.trim() ?? '';
const oauthErrorDescription =
searchParams?.get('error_description')?.trim() ?? '';
const resolvedSource =
sourceId ?? parseOAuthSource(pathname, searchParams?.get('source') ?? '');
const mutation = useMutation({
mutationFn: () => exchangeOAuthCode(resolvedSource, code, state),
onSuccess: (result) => {
if (result.status === 'link_required') {
setMessage({ tone: 'success', text: '请绑定已有账号以完成登录。' });
router.replace('/oauth/link');
return;
}
if (result.user) {
setUser(result.user);
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
router.replace('/');
return;
}
setPrompt('授权处理失败');
setMessage({ tone: 'danger', text: '授权结果缺少用户信息。' });
},
onError: (error: Error) => {
setPrompt('授权处理失败');
setMessage({
tone: 'danger',
text: error.message || '授权失败,请稍后重试。',
});
},
});
useEffect(() => {
if (oauthError || oauthErrorDescription) {
setPrompt('授权处理失败');
setMessage({
tone: 'danger',
text: oauthErrorDescription || oauthError,
});
return;
}
if (String(resolvedSource).trim() === '') {
setPrompt('缺少认证源参数');
setMessage({
tone: 'danger',
text: '未收到认证源参数,请返回登录页重试。',
});
return;
}
if (!code || !state) {
setPrompt('缺少授权参数');
setMessage({
tone: 'danger',
text: '未收到完整授权参数,请返回登录页重试。',
});
return;
}
const key = `${resolvedSource}:${code}:${state}`;
if (handledRef.current === key) {
return;
}
handledRef.current = key;
mutation.mutate();
}, [
code,
mutation,
oauthError,
oauthErrorDescription,
resolvedSource,
state,
]);
return (
<AppCard title="第三方登录回调" description={prompt}>
<div className="space-y-4">
{mutation.isPending ? <LoadingState /> : null}
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
{message?.tone === 'danger' ? (
<div className="flex justify-center">
<Link
href="/login"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回登录
</Link>
</div>
) : null}
</div>
</AppCard>
);
}
@@ -0,0 +1,106 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation} from '@tanstack/react-query';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useState} from 'react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {InlineMessage} from '@/components/feedback/inline-message';
import {useAuth} from '@/components/providers/auth-provider';
import {AppCard} from '@/components/ui/app-card';
import {linkExistingOAuthAccount} from '@/features/auth/api/auth';
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
const schema = z.object({
username: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
});
type FormValues = z.infer<typeof schema>;
export function OAuthLinkExistingForm() {
const router = useRouter();
const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
password: '',
},
});
const mutation = useMutation({
mutationFn: linkExistingOAuthAccount,
onSuccess: (result) => {
if (result.user) {
setUser(result.user);
router.replace('/');
return;
}
setErrorMessage('绑定成功但未返回用户信息,请重新登录。');
},
onError: (error: Error) => {
setErrorMessage(error.message || '绑定失败,请稍后重试。');
},
});
const handleSubmit = form.handleSubmit((values) => {
setErrorMessage('');
mutation.mutate(values);
});
return (
<AppCard
title="绑定已有账号"
description="当前第三方账号尚未绑定本地用户,请使用已有账号完成关联。"
>
<form className="space-y-4" onSubmit={handleSubmit}>
<AuthFormField label="用户名">
<AuthInput
placeholder="请输入用户名"
{...form.register('username')}
/>
{form.formState.errors.username ? (
<span className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label="密码">
<AuthInput
type="password"
placeholder="请输入密码"
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
{errorMessage ? (
<InlineMessage tone="danger" message={errorMessage} />
) : null}
<AuthButton type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '绑定中...' : '绑定并登录'}
</AuthButton>
</form>
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
<Link
href="/login"
className="text-[var(--brand-primary)] transition hover:opacity-80"
>
返回登录
</Link>
</div>
</AppCard>
);
}
@@ -0,0 +1,101 @@
'use client';
import {useMutation} from '@tanstack/react-query';
import Link from 'next/link';
import {useSearchParams} from 'next/navigation';
import {useState} from 'react';
import {InlineMessage} from '@/components/feedback/inline-message';
import {AppCard} from '@/components/ui/app-card';
import {resetPassword} from '@/features/auth/api/auth';
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
export function PasswordResetConfirmForm() {
const searchParams = useSearchParams();
const email = searchParams?.get('email') || '';
const token = searchParams?.get('token') || '';
const [message, setMessage] = useState<{
tone: 'success' | 'danger';
text: string;
} | null>(null);
const mutation = useMutation({
mutationFn: () => resetPassword({ email, token }),
onSuccess: async (password) => {
try {
await navigator.clipboard.writeText(password);
setMessage({
tone: 'success',
text: `密码已重置,新密码已复制到剪贴板:${password}`,
});
} catch {
setMessage({ tone: 'success', text: `密码已重置:${password}` });
}
},
onError: (error: Error) => {
setMessage({
tone: 'danger',
text: error.message || '密码重置失败,请重新获取链接。',
});
},
});
const missingParams = !email || !token;
return (
<AppCard
title="密码重置确认"
description="确认后,系统会生成新的随机密码。"
>
<div className="space-y-4">
<AuthFormField label="邮箱地址">
<AuthInput value={email} readOnly />
</AuthFormField>
{missingParams ? (
<InlineMessage
tone="danger"
message="重置链接缺少必要参数,请重新发起密码重置。"
/>
) : null}
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<div className="flex flex-col gap-3 sm:flex-row">
<AuthButton
type="button"
disabled={missingParams || mutation.isPending}
onClick={() => mutation.mutate()}
>
{mutation.isPending ? '处理中...' : '确认重置密码'}
</AuthButton>
{message?.tone === 'success' ? (
<SecondaryButton
type="button"
onClick={async () => {
const password = message.text.split(':').pop() || '';
if (password) {
await navigator.clipboard.writeText(password);
}
}}
>
再次复制密码
</SecondaryButton>
) : null}
</div>
<div className="text-sm text-[var(--foreground-secondary)]">
处理完成后可返回
<Link
href="/login"
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
>
登录页
</Link>
</div>
</div>
</AppCard>
);
}
@@ -0,0 +1,19 @@
'use client';
import {useSearchParams} from 'next/navigation';
import {PasswordResetConfirmForm} from '@/features/auth/components/password-reset-confirm-form';
import {PasswordResetRequestForm} from '@/features/auth/components/password-reset-request-form';
export function PasswordResetFlow() {
const searchParams = useSearchParams();
const hasConfirmationParams = Boolean(
searchParams?.get('email') && searchParams?.get('token'),
);
return hasConfirmationParams ? (
<PasswordResetConfirmForm />
) : (
<PasswordResetRequestForm />
);
}
@@ -0,0 +1,94 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation} from '@tanstack/react-query';
import Link from 'next/link';
import {useState} from 'react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {InlineMessage} from '@/components/feedback/inline-message';
import {AppCard} from '@/components/ui/app-card';
import {sendPasswordResetEmail} from '@/features/auth/api/auth';
import {AuthButton, AuthFormField, AuthInput,} from '@/features/auth/components/auth-form-primitives';
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
const resetRequestSchema = z.object({
email: z.string().email('请输入有效邮箱地址'),
});
type ResetRequestFormValues = z.infer<typeof resetRequestSchema>;
export function PasswordResetRequestForm() {
const [message, setMessage] = useState<{
tone: 'success' | 'danger' | 'info';
text: string;
} | null>(null);
const form = useForm<ResetRequestFormValues>({
resolver: zodResolver(resetRequestSchema),
defaultValues: { email: '' },
});
const mutation = useMutation({
mutationFn: (values: ResetRequestFormValues) =>
sendPasswordResetEmail(values.email),
onSuccess: () => {
setMessage({ tone: 'success', text: '重置邮件发送成功,请检查邮箱。' });
form.reset();
},
onError: (error: Error) => {
setMessage({
tone: 'danger',
text: error.message || '重置邮件发送失败,请稍后重试。',
});
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
mutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard
title="密码重置"
description="提交后,系统会向你的注册邮箱发送重置链接。"
>
<form className="space-y-4" onSubmit={handleSubmit}>
<AuthFormField label="邮箱地址">
<AuthInput
type="email"
placeholder="请输入邮箱地址"
{...form.register('email')}
/>
{form.formState.errors.email ? (
<span className="text-xs text-[var(--status-danger-foreground)]">
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<AuthButton type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '提交中...' : '发送重置邮件'}
</AuthButton>
</form>
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
想起密码了?
<Link
href="/login"
className="ml-2 text-[var(--brand-primary)] transition hover:opacity-80"
>
返回登录
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -0,0 +1,35 @@
'use client';
import type {ReactNode} from 'react';
import {useEffect} from 'react';
import {useRouter, useSearchParams} from 'next/navigation';
import {LoadingState} from '@/components/feedback/loading-state';
import {useAuth} from '@/components/providers/auth-provider';
interface PublicAuthGuardProps {
children: ReactNode;
}
export function PublicAuthGuard({ children }: PublicAuthGuardProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && isAuthenticated) {
const redirect = searchParams?.get('redirect');
router.replace(redirect || '/');
}
}, [isAuthenticated, isLoading, router, searchParams]);
if (isLoading) {
return <LoadingState />;
}
if (isAuthenticated) {
return null;
}
return <>{children}</>;
}
@@ -0,0 +1,23 @@
import {FeaturePlaceholder} from '@/components/feedback/feature-placeholder';
interface PublicRoutePlaceholderProps {
title: string;
description: string;
}
export function PublicRoutePlaceholder({
title,
description,
}: PublicRoutePlaceholderProps) {
return (
<FeaturePlaceholder
title={title}
description={description}
milestones={[
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
]}
/>
);
}
@@ -0,0 +1,48 @@
import { apiRequest } from '@/lib/api/client';
import type {
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionDetail,
ConfigVersionSummary,
} from '@/features/config-versions/types';
export function getConfigVersions() {
return apiRequest<ConfigVersionSummary[]>('/config-versions/');
}
export function getConfigVersion(id: number) {
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}`);
}
export function getActiveConfigVersion() {
return apiRequest<ConfigVersionDetail>('/config-versions/active');
}
export function getConfigVersionPreview() {
return apiRequest<ConfigPreviewResult>('/config-versions/preview');
}
export function getConfigVersionDiff() {
return apiRequest<ConfigDiffResult>('/config-versions/diff');
}
export function publishConfigVersion(force?: boolean) {
const query = force ? '?force=true' : '';
return apiRequest<ConfigVersionDetail>(`/config-versions/publish${query}`, {
method: 'POST',
});
}
export function activateConfigVersion(id: number) {
return apiRequest<ConfigVersionDetail>(`/config-versions/${id}/activate`, {
method: 'POST',
});
}
export function cleanupConfigVersions(payload: { keep_count: number }) {
return apiRequest<{ deleted_count: number }>('/config-versions/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import { InlineMessage } from '@/components/feedback/inline-message';
import { AppModal } from '@/components/ui/app-modal';
import {
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
export function CleanupConfigVersionsModal({
isOpen,
onClose,
onConfirm,
isPending,
}: {
isOpen: boolean;
onClose: () => void;
onConfirm: (keepCount: number) => void;
isPending: boolean;
}) {
const [keepCount, setKeepCount] = useState<number>(10);
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (keepCount < 3) {
setError('最少保留 3 个历史快照');
return;
}
setError(null);
onConfirm(keepCount);
};
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="清理历史快照"
description="删除旧的历史快照配置,系统将始终自动保护当前已激活的版本不被删除。"
size="md"
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose} disabled={isPending}>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="cleanup-form"
disabled={isPending}
className="border-[var(--status-danger-foreground)] bg-[var(--status-danger-foreground)] hover:bg-[var(--status-danger-foreground)] hover:opacity-90"
>
{isPending ? '清理中...' : '确认清理'}
</PrimaryButton>
</div>
}
>
<form id="cleanup-form" onSubmit={handleSubmit} className="space-y-4">
{error && <InlineMessage tone="danger" message={error} />}
<div>
<label
htmlFor="keepCount"
className="mb-1 block text-sm font-medium text-[var(--foreground-primary)]"
>
保留最近快照个数
</label>
<input
id="keepCount"
type="number"
min={3}
value={keepCount}
onChange={(e) => setKeepCount(parseInt(e.target.value, 10) || 3)}
className="w-full rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] px-3 py-2 text-sm text-[var(--foreground-primary)] focus:border-[var(--brand-primary)] focus:ring-1 focus:ring-[var(--brand-primary)] focus:outline-none"
disabled={isPending}
/>
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
默认为 10 个,最少需保留 3 个。
</p>
</div>
</form>
</AppModal>
);
}
@@ -0,0 +1,119 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { getConfigVersion } from '@/features/config-versions/api/config-versions';
import type { ConfigVersionSummary } from '@/features/config-versions/types';
import {
CodeBlock,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function ConfigVersionSnapshotModal({
version,
onClose,
}: {
version: ConfigVersionSummary | null;
onClose: () => void;
}) {
const versionDetailQuery = useQuery({
queryKey: ['config-versions', 'detail', version?.id ?? 0],
queryFn: () => {
if (!version) {
throw new Error('missing config version');
}
return getConfigVersion(version.id);
},
enabled: Boolean(version?.id),
});
const versionDetail = versionDetailQuery.data ?? null;
return (
<AppModal
isOpen={Boolean(version)}
onClose={onClose}
title={version ? `版本 ${version.version}` : '查看快照'}
size="xl"
footer={
<div className="flex justify-end">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
</div>
}
>
{!version ? null : versionDetailQuery.isLoading && !versionDetail ? (
<LoadingState />
) : versionDetailQuery.isError ? (
<ErrorState
title="配置版本详情加载失败"
description={getErrorMessage(versionDetailQuery.error)}
/>
) : versionDetail ? (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{versionDetail.checksum}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
创建人
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{versionDetail.created_by || '系统'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(versionDetail.created_at)}
</p>
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
快照 JSON
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{versionDetail.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{versionDetail.main_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{versionDetail.rendered_config}
</CodeBlock>
</div>
</div>
) : null}
</AppModal>
);
}
@@ -0,0 +1,693 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
activateConfigVersion,
getActiveConfigVersion,
getConfigVersionDiff,
getConfigVersionPreview,
getConfigVersions,
publishConfigVersion,
cleanupConfigVersions,
} from '@/features/config-versions/api/config-versions';
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
import { CleanupConfigVersionsModal } from '@/features/config-versions/components/cleanup-config-versions-modal';
import type {
ConfigOptionDiffItem,
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionDetail,
ConfigVersionSummary,
SupportFile,
} from '@/features/config-versions/types';
import {
CodeBlock,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
const versionsQueryKey = ['config-versions'];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
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.main_config_changed ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
function DiffList({ title, items }: { title: string; items: string[] }) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{title}
</p>
<StatusBadge
label={`${items.length} 项`}
variant={items.length > 0 ? 'info' : 'warning'}
/>
</div>
{items.length > 0 ? (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<span
key={item}
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
>
{item}
</span>
))}
</div>
) : (
<p className="text-sm text-[var(--foreground-secondary)]">
当前无相关变更。
</p>
)}
</div>
);
}
function SupportFilesList({ files }: { files: SupportFile[] }) {
if (files.length === 0) {
return (
<p className="text-sm text-[var(--foreground-secondary)]">
当前发布不需要额外支持文件。
</p>
);
}
return (
<div className="space-y-3">
{files.map((file) => (
<details
key={file.path}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
>
<summary className="cursor-pointer text-sm font-medium text-[var(--foreground-primary)]">
{file.path}
</summary>
<CodeBlock className="mt-3 max-h-72 whitespace-pre-wrap">
{file.content}
</CodeBlock>
</details>
))}
</div>
);
}
function renderOptionValue(value: string) {
return value === '' ? '空' : value;
}
function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
if (items.length === 0) {
return (
<p className="text-sm text-[var(--foreground-secondary)]">
当前无 OpenResty 性能参数变化。
</p>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-[var(--border-default)]">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">参数</th>
<th className="px-3 py-3 font-medium">当前激活值</th>
<th className="px-3 py-3 font-medium">待发布值</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{items.map((item) => (
<tr key={item.key} className="align-top">
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
{item.key}
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
<code>{renderOptionValue(item.previous_value)}</code>
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
<code>{renderOptionValue(item.current_value)}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function PublishPreviewCard({
preview,
diff,
activeVersionMeta,
activeVersionDetail,
isActiveVersionDetailLoading,
activeVersionDetailError,
isPublishing,
onConfirm,
onCancel,
}: {
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
activeVersionMeta: ConfigVersionSummary | null;
activeVersionDetail: ConfigVersionDetail | null;
isActiveVersionDetailLoading: boolean;
activeVersionDetailError: string | null;
isPublishing: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
return (
<AppCard
title="发布前预览"
description="先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。"
action={
<StatusBadge
label={`启用规则 ${preview.route_count} 条`}
variant="info"
/>
}
>
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
当前激活版本
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{diff.active_version || '无'}
</p>
</div>
<div className="rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--status-success-foreground)] uppercase">
新增域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-success-foreground)]">
{diff.added_domains.length}
</p>
</div>
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--status-warning-foreground)] uppercase">
删除域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]">
{diff.removed_domains.length}
</p>
</div>
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--status-info-foreground)] uppercase">
修改域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-info-foreground)]">
{diff.modified_domains.length}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
主配置变化
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{diff.main_config_changed ? '已变化' : '无变化'}
</p>
</div>
</div>
{!canPublish ? (
<InlineMessage
tone="info"
message="当前规则与已激活版本一致,已阻止重复发布。"
/>
) : null}
<div className="grid gap-5 xl:grid-cols-3">
<DiffList title="新增域名" items={diff.added_domains} />
<DiffList title="删除域名" items={diff.removed_domains} />
<DiffList title="修改域名" items={diff.modified_domains} />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
OpenResty 参数变化
</p>
<StatusBadge
label={`${diff.changed_option_keys.length} 项`}
variant={diff.changed_option_keys.length > 0 ? 'info' : 'warning'}
/>
</div>
<OptionDiffTable items={diff.changed_option_details} />
</div>
{diff.main_config_changed && activeVersionMeta ? (
<div className="grid gap-5 xl:grid-cols-2">
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Current Active Main Config
</p>
<StatusBadge label={activeVersionMeta.version} variant="info" />
</div>
{isActiveVersionDetailLoading ? (
<LoadingState />
) : activeVersionDetailError ? (
<InlineMessage
tone="danger"
message={activeVersionDetailError}
/>
) : activeVersionDetail ? (
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersionDetail.main_config}
</CodeBlock>
) : (
<InlineMessage
tone="info"
message="当前激活版本详情暂不可用。"
/>
)}
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Pending Main Config
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
</CodeBlock>
</div>
</div>
) : null}
{/* Legacy duplicated main config preview block kept commented out while
we replace it with an explicit active-vs-pending comparison above.
{diff.main_config_changed && activeVersion ? (
<div className="grid gap-5 xl:grid-cols-2">
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
褰撳墠婵€娲讳富閰嶇疆
</p>
<StatusBadge label={activeVersion.version} variant="info" />
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{activeVersion.main_config}
</CodeBlock>
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
寰呭彂甯冧富閰嶇疆
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
Checksum锛歿preview.checksum}
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
</CodeBlock>
</div>
</div>
) : null}
*/}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
Checksum:{preview.checksum}
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
</CodeBlock>
</div>
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.rendered_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
支持文件
</p>
<SupportFilesList files={preview.support_files} />
</div>
<div className="flex flex-wrap gap-3">
<PrimaryButton
type="button"
onClick={onConfirm}
disabled={isPublishing || !canPublish}
>
{isPublishing ? '发布中...' : '确认发布'}
</PrimaryButton>
<SecondaryButton
type="button"
onClick={onCancel}
disabled={isPublishing}
>
取消预览
</SecondaryButton>
</div>
</div>
</AppCard>
);
}
export function ConfigVersionsPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<number | null>(
null,
);
const [publishPreview, setPublishPreview] = useState<{
preview: ConfigPreviewResult;
diff: ConfigDiffResult;
} | null>(null);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
const versionsQuery = useQuery({
queryKey: versionsQueryKey,
queryFn: getConfigVersions,
});
const versions = useMemo(
() => versionsQuery.data ?? [],
[versionsQuery.data],
);
const activeVersion = useMemo(
() => versions.find((item) => item.is_active) ?? null,
[versions],
);
const selectedVersion = useMemo(
() => versions.find((item) => item.id === selectedVersionId) ?? null,
[selectedVersionId, versions],
);
const shouldLoadActiveVersionDetail = Boolean(
activeVersion?.id && publishPreview?.diff.main_config_changed,
);
const activeVersionDetailQuery = useQuery({
queryKey: ['config-versions', 'active-detail', activeVersion?.id ?? 0],
queryFn: getActiveConfigVersion,
enabled: shouldLoadActiveVersionDetail,
});
const publishMutation = useMutation({
mutationFn: (force?: boolean) => publishConfigVersion(force),
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `发布成功,版本 ${version.version}`,
});
setPublishPreview(null);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const activateMutation = useMutation({
mutationFn: activateConfigVersion,
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `已激活版本 ${version.version}`,
});
setSelectedVersionId(version.id);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const cleanupMutation = useMutation({
mutationFn: cleanupConfigVersions,
onSuccess: async (data) => {
setFeedback({
tone: 'success',
message: `成功清理 ${data.deleted_count} 个历史快照`,
});
setIsCleanupModalOpen(false);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleOpenPublishPreview = async () => {
setFeedback(null);
setIsPreviewLoading(true);
try {
const [preview, diff] = await Promise.all([
getConfigVersionPreview(),
getConfigVersionDiff(),
]);
setPublishPreview({ preview, diff });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
} finally {
setIsPreviewLoading(false);
}
};
const handleActivate = (version: ConfigVersionSummary) => {
if (version.is_active) {
return;
}
if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) {
return;
}
setFeedback(null);
activateMutation.mutate(version.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="配置版本"
description="查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。"
action={
<div className="flex gap-2">
<SecondaryButton
type="button"
onClick={() => {
if (
window.confirm(
'确认强制重新发布吗?这会忽略配置变化检查并立即生成一个新版本。',
)
) {
publishMutation.mutate(true);
}
}}
disabled={publishMutation.isPending}
>
重新发布
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleOpenPublishPreview}
disabled={isPreviewLoading || publishMutation.isPending}
>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
{publishPreview ? (
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
activeVersionMeta={activeVersion}
activeVersionDetail={activeVersionDetailQuery.data ?? null}
isActiveVersionDetailLoading={activeVersionDetailQuery.isLoading}
activeVersionDetailError={
activeVersionDetailQuery.isError
? getErrorMessage(activeVersionDetailQuery.error)
: null
}
isPublishing={publishMutation.isPending}
onConfirm={() => publishMutation.mutate(false)}
onCancel={() => setPublishPreview(null)}
/>
) : null}
<AppCard
title="历史版本"
description="发布成功后会立即刷新列表,不再需要手动刷新页面。"
action={
<div className="flex gap-2">
<SecondaryButton
type="button"
onClick={() => setIsCleanupModalOpen(true)}
>
清理旧版本
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: versionsQueryKey,
})
}
>
刷新列表
</SecondaryButton>
</div>
}
>
{versionsQuery.isLoading ? (
<LoadingState />
) : versionsQuery.isError ? (
<ErrorState
title="版本列表加载失败"
description={getErrorMessage(versionsQuery.error)}
/>
) : versions.length === 0 ? (
<EmptyState
title="暂无历史版本"
description="当前还没有可查看的发布记录,请先从反代规则页触发一次发布。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">版本号</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">创建人</th>
<th className="px-3 py-3 font-medium">Checksum</th>
<th className="px-3 py-3 font-medium">创建时间</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{versions.map((version) => (
<tr key={version.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{version.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={version.is_active ? '当前激活' : '历史版本'}
variant={version.is_active ? 'success' : 'info'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{version.created_by || '系统'}
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={version.checksum}
>
{truncateChecksum(version.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(version.created_at)}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => setSelectedVersionId(version.id)}
className="px-3 py-2 text-xs"
>
查看快照
</SecondaryButton>
{!version.is_active ? (
<PrimaryButton
type="button"
onClick={() => handleActivate(version)}
disabled={activateMutation.isPending}
className="px-3 py-2 text-xs"
>
重新激活
</PrimaryButton>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<ConfigVersionSnapshotModal
version={selectedVersion}
onClose={() => setSelectedVersionId(null)}
/>
<CleanupConfigVersionsModal
isOpen={isCleanupModalOpen}
onClose={() => setIsCleanupModalOpen(false)}
onConfirm={(keepCount) =>
cleanupMutation.mutate({ keep_count: keepCount })
}
isPending={cleanupMutation.isPending}
/>
</>
);
}
@@ -0,0 +1,53 @@
export interface ConfigVersionSummary {
id: number;
version: string;
checksum: string;
is_active: boolean;
created_by: string;
created_at: string;
}
export interface ConfigVersionDetail extends ConfigVersionSummary {
snapshot_json: string;
main_config: string;
rendered_config: string;
support_files_json: string;
}
export interface SupportFile {
path: string;
content: string;
}
export interface ConfigPreviewResult {
snapshot_json: string;
main_config: string;
route_config: string;
rendered_config: string;
support_files: SupportFile[];
checksum: string;
route_count: number;
website_count: number;
}
export interface ConfigDiffResult {
active_version?: string;
added_sites: string[];
removed_sites: string[];
modified_sites: string[];
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
waf_config_changed: boolean;
changed_option_keys: string[];
changed_option_details: ConfigOptionDiffItem[];
current_website_count: number;
active_website_count: number;
}
export interface ConfigOptionDiffItem {
key: string;
previous_value: string;
current_value: string;
}
@@ -0,0 +1,239 @@
import {apiRequest} from '@/lib/api/client';
import type {
CompactCapacityTrendPoint,
CompactDashboardNodeHealth,
CompactDiskIOTrendPoint,
CompactDistributionItem,
CompactNetworkTrendPoint,
CompactTrafficTrendPoint,
DashboardCapacity,
DashboardNodeHealth,
DashboardOverview,
DashboardOverviewCompact,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/features/dashboard/types';
function arrayOrEmpty<T>(value: T[] | null | undefined) {
return Array.isArray(value) ? value : [];
}
function isCompactDistributionItem(
value: DistributionItem | CompactDistributionItem,
): value is CompactDistributionItem {
return Array.isArray(value);
}
function isCompactTrafficTrendPoint(
value:
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint,
): value is CompactTrafficTrendPoint {
return Array.isArray(value);
}
function isCompactCapacityTrendPoint(
value:
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint,
): value is CompactCapacityTrendPoint {
return Array.isArray(value);
}
function isCompactNetworkTrendPoint(
value:
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint,
): value is CompactNetworkTrendPoint {
return Array.isArray(value);
}
function isCompactDiskIOTrendPoint(
value:
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint,
): value is CompactDiskIOTrendPoint {
return Array.isArray(value);
}
function isCompactDashboardNode(
value: DashboardNodeHealth | CompactDashboardNodeHealth,
): value is CompactDashboardNodeHealth {
return Array.isArray(value);
}
function normalizeDistributionItems(
items: Array<DistributionItem | CompactDistributionItem> | null | undefined,
): DistributionItem[] {
return arrayOrEmpty(items).map((item) =>
isCompactDistributionItem(item)
? { key: String(item[0] ?? ''), value: Number(item[1] ?? 0) }
: item,
);
}
function normalizeTrafficTrendPoints(
items:
| Array<
| DashboardOverview['trends']['traffic_24h'][number]
| CompactTrafficTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactTrafficTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
request_count: Number(item[1] ?? 0),
error_count: Number(item[2] ?? 0),
unique_visitor_count: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeCapacityTrendPoints(
items:
| Array<
| DashboardOverview['trends']['capacity_24h'][number]
| CompactCapacityTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactCapacityTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
average_cpu_usage_percent: Number(item[1] ?? 0),
average_memory_usage_percent: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeNetworkTrendPoints(
items:
| Array<
| DashboardOverview['trends']['network_24h'][number]
| CompactNetworkTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactNetworkTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
network_rx_bytes: Number(item[1] ?? 0),
network_tx_bytes: Number(item[2] ?? 0),
openresty_rx_bytes: Number(item[3] ?? 0),
openresty_tx_bytes: Number(item[4] ?? 0),
reported_nodes: Number(item[5] ?? 0),
}
: item,
);
}
function normalizeDiskIOTrendPoints(
items:
| Array<
| DashboardOverview['trends']['disk_io_24h'][number]
| CompactDiskIOTrendPoint
>
| null
| undefined,
) {
return arrayOrEmpty(items).map((item) =>
isCompactDiskIOTrendPoint(item)
? {
bucket_started_at: String(item[0] ?? ''),
disk_read_bytes: Number(item[1] ?? 0),
disk_write_bytes: Number(item[2] ?? 0),
reported_nodes: Number(item[3] ?? 0),
}
: item,
);
}
function normalizeDashboardNodes(
items:
| Array<DashboardNodeHealth | CompactDashboardNodeHealth>
| null
| undefined,
): DashboardNodeHealth[] {
return arrayOrEmpty(items).map((item) =>
isCompactDashboardNode(item)
? {
id: Number(item[0] ?? 0),
node_id: String(item[1] ?? ''),
name: String(item[2] ?? ''),
geo_name: String(item[3] ?? ''),
geo_latitude:
item[4] === null || item[4] === undefined ? null : Number(item[4]),
geo_longitude:
item[5] === null || item[5] === undefined ? null : Number(item[5]),
status: (item[6] ?? 'pending') as DashboardNodeHealth['status'],
openresty_status: (item[7] ??
'unknown') as DashboardNodeHealth['openresty_status'],
current_version: String(item[8] ?? ''),
last_seen_at: String(item[9] ?? ''),
active_event_count: Number(item[10] ?? 0),
cpu_usage_percent: Number(item[11] ?? 0),
memory_usage_percent: Number(item[12] ?? 0),
storage_usage_percent: Number(item[13] ?? 0),
request_count: Number(item[14] ?? 0),
error_count: Number(item[15] ?? 0),
unique_visitor_count: Number(item[16] ?? 0),
}
: item,
);
}
function normalizeDashboardOverview(
overview: DashboardOverview | DashboardOverviewCompact | null | undefined,
): DashboardOverview | null {
if (!overview) {
return null;
}
const summary = (overview.summary ?? {}) as DashboardSummary;
const traffic = (overview.traffic ?? {}) as DashboardTraffic;
const capacity = (overview.capacity ?? {}) as DashboardCapacity;
return {
generated_at: String(overview.generated_at ?? ''),
summary,
traffic,
capacity,
nodes: normalizeDashboardNodes(overview.nodes),
distributions: {
source_countries: normalizeDistributionItems(
overview.distributions?.source_countries,
),
status_codes: normalizeDistributionItems(
overview.distributions?.status_codes,
),
top_domains: normalizeDistributionItems(
overview.distributions?.top_domains,
),
},
trends: {
traffic_24h: normalizeTrafficTrendPoints(overview.trends?.traffic_24h),
capacity_24h: normalizeCapacityTrendPoints(overview.trends?.capacity_24h),
network_24h: normalizeNetworkTrendPoints(overview.trends?.network_24h),
disk_io_24h: normalizeDiskIOTrendPoints(overview.trends?.disk_io_24h),
},
};
}
export async function getDashboardOverview() {
const overview = await apiRequest<
DashboardOverview | DashboardOverviewCompact
>('/dashboard/overview');
return normalizeDashboardOverview(overview);
}
@@ -0,0 +1,415 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { RankChart } from '@/components/data/rank-chart';
import { TrendChart } from '@/components/data/trend-chart';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getDashboardOverview } from '@/features/dashboard/api/overview';
import type { DashboardNodeHealth } from '@/features/dashboard/types';
import { WorldStage } from '@/features/dashboard/components/world-stage';
import {
getNodeStatusLabel,
getNodeStatusVariant,
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
isWSConnectedLastSeen,
} from '@/features/nodes/utils';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
formatBytes,
formatBytesPerSecond,
formatPercent,
} from '@/lib/utils/metrics';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/detail?id=${id}`;
}
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),
}));
}
function NodeHealthRow({ node }: { node: DashboardNodeHealth }) {
return (
<Link
href={buildNodeDetailHref(node.id)}
className="block rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{node.name}
</p>
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
{node.node_id}
</p>
</div>
<div className="flex flex-wrap gap-2">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
<StatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
variant={getOpenrestyStatusVariant(node.openresty_status)}
/>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-4">
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
CPU
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatPercent(node.cpu_usage_percent)}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
内存
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatPercent(node.memory_usage_percent)}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
最近窗口请求
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{node.request_count.toLocaleString('zh-CN')}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
活动异常
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{node.active_event_count}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm text-[var(--foreground-secondary)]">
<p>当前版本:{node.current_version || '未应用'}</p>
<p>
最近心跳:
{isWSConnectedLastSeen(node.last_seen_at)
? ' WS 已连接'
: node.last_seen_at
? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: ' 暂无'}
</p>
</div>
</Link>
);
}
export function DashboardOverview() {
const overviewQuery = useQuery({
queryKey: ['dashboard', 'overview'],
queryFn: getDashboardOverview,
refetchInterval: 10000,
});
if (overviewQuery.isLoading) {
return <LoadingState />;
}
if (overviewQuery.isError) {
return (
<ErrorState
title="总览看板加载失败"
description={getErrorMessage(overviewQuery.error)}
/>
);
}
const overview = overviewQuery.data;
if (!overview) {
return (
<EmptyState
title="暂无总览数据"
description="系统已经启动,但还没有可展示的总览聚合结果。"
/>
);
}
return (
<div className="space-y-6">
<WorldStage
summary={overview.summary}
traffic={overview.traffic}
capacity={overview.capacity}
nodes={overview.nodes}
sourceCountries={overview.distributions.source_countries}
/>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard
title="24 小时请求趋势"
description="观察整体请求量和错误量是否出现异常抬升。"
>
<TrendChart
labels={overview.trends.traffic_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: overview.trends.traffic_24h.map(
(point) => point.request_count,
),
},
{
label: '错误量',
color: '#ef4444',
values: overview.trends.traffic_24h.map(
(point) => point.error_count,
),
},
]}
/>
</AppCard>
<AppCard
title="24 小时容量趋势"
description="按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。"
>
<TrendChart
labels={overview.trends.capacity_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
yAxisValueFormatter={formatPercent}
series={[
{
label: '平均 CPU',
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
values: overview.trends.capacity_24h.map(
(point) => point.average_cpu_usage_percent,
),
valueFormatter: formatPercent,
},
{
label: '平均内存',
color: '#2563eb',
values: overview.trends.capacity_24h.map(
(point) => point.average_memory_usage_percent,
),
valueFormatter: formatPercent,
},
]}
/>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard title="24 小时网络与磁盘趋势">
<div className="space-y-6">
<TrendChart
labels={overview.trends.network_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)}
series={[
{
label: 'OpenResty 入站',
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: overview.trends.network_24h.map(
(point) => point.openresty_rx_bytes,
),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
{
label: 'OpenResty 出站',
color: '#38bdf8',
values: overview.trends.network_24h.map(
(point) => point.openresty_tx_bytes,
),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
]}
/>
<TrendChart
labels={overview.trends.disk_io_24h.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
height={180}
yAxisValueFormatter={formatBytes}
series={[
{
label: '磁盘读',
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: overview.trends.disk_io_24h.map(
(point) => point.disk_read_bytes,
),
valueFormatter: formatBytes,
},
{
label: '磁盘写',
color: '#fb7185',
values: overview.trends.disk_io_24h.map(
(point) => point.disk_write_bytes,
),
valueFormatter: formatBytes,
},
]}
/>
</div>
</AppCard>
<AppCard title="Top 节点榜单">
<div className="grid gap-6 xl:grid-cols-1">
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
流量最高节点
</p>
<RankChart
items={buildNodeRankItems(
overview.nodes,
(node) => node.request_count,
)}
color="#38bdf8"
emptyMessage="暂无流量榜单"
/>
</div>
<div>
<p className="mb-3 text-xs tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
容量压力节点
</p>
<RankChart
items={buildNodeRankItems(overview.nodes, (node) =>
Math.round(
Math.max(
node.cpu_usage_percent,
node.memory_usage_percent,
node.storage_usage_percent,
),
),
)}
color="#ef4444"
valueFormatter={(value) => `${value}%`}
emptyMessage="暂无容量压力数据"
/>
</div>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-3">
<AppCard title="来源分布" description="聚合最近 24 小时主要来源国家。">
<RankChart
items={overview.distributions.source_countries.map((item) => ({
label: item.key,
value: item.value,
}))}
color="#38bdf8"
emptyMessage="暂无来源分布数据"
/>
</AppCard>
<AppCard
title="状态码分布"
description="快速判断成功响应是否仍是主流,以及错误码是否有抬升。"
>
<RankChart
items={overview.distributions.status_codes.map((item) => ({
label: `HTTP ${item.key}`,
value: item.value,
}))}
color="#f59e0b"
emptyMessage="暂无状态码分布"
/>
</AppCard>
<AppCard title="Top Domain" description="观察主要流量集中在哪些域名。">
<RankChart
items={overview.distributions.top_domains.map((item) => ({
label: item.key,
value: item.value,
}))}
color="#34d399"
emptyMessage="暂无域名分布"
/>
</AppCard>
</div>
<AppCard
title="节点健康列表"
description="按异常数量和资源压力排序。"
action={
<Link
href="/node"
className="inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
进入节点页
</Link>
}
>
{overview.nodes.length === 0 ? (
<EmptyState
title="暂无节点"
description="节点接入后,这里会展示系统健康与容量摘要。"
/>
) : (
<div className="space-y-4">
{overview.nodes.slice(0, 8).map((node) => (
<NodeHealthRow key={node.node_id} node={node} />
))}
</div>
)}
</AppCard>
</div>
);
}
@@ -0,0 +1,571 @@
'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 { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type {
DashboardNodeHealth,
DistributionItem,
} from '@/features/dashboard/types';
import {
getNodeStatusLabel,
getOpenrestyStatusLabel,
} from '@/features/nodes/utils';
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 = 168;
const baseWorldMapZoom = 1;
function buildNodeDetailHref(id?: number | null) {
if (!id) {
return '/node';
}
return `/node/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';
}
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,
};
}
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({
isDark,
nodes,
sourceCountries,
}: {
isDark: boolean;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
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(() => {
const container = chartContainerRef.current;
if (!container) {
return;
}
const updateSize = () => {
const nextWidth = container.clientWidth;
const nextHeight = container.clientHeight;
setContainerSize((previous) =>
previous.width === nextWidth && previous.height === nextHeight
? previous
: { width: nextWidth, height: nextHeight },
);
};
updateSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateSize);
return () => {
window.removeEventListener('resize', updateSize);
};
}
const observer = new ResizeObserver(() => {
updateSize();
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, []);
const mapPalette = useMemo(
() =>
isDark
? {
areaColor: '#13233b',
borderColor: 'rgba(125,211,252,0.14)',
labelColor: '#e2e8f0',
sourceAreaColor: 'rgba(56, 189, 248, 0.18)',
sourceAreaBorder: 'rgba(125, 211, 252, 0.45)',
healthyColor: '#34d399',
warningColor: '#fbbf24',
dangerColor: '#fb7185',
healthyBorder: '#bbf7d0',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
}
: {
areaColor: '#eaf2ff',
borderColor: 'rgba(71,85,105,0.12)',
labelColor: '#0f172a',
sourceAreaColor: 'rgba(14, 165, 233, 0.22)',
sourceAreaBorder: 'rgba(14, 165, 233, 0.46)',
healthyColor: '#10b981',
warningColor: '#f59e0b',
dangerColor: '#f43f5e',
healthyBorder: '#d1fae5',
warningBorder: '#fde68a',
dangerBorder: '#fecdd3',
},
[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(56, 189, 248, ${areaOpacity})`
: `rgba(14, 165, 233, ${areaOpacity})`;
const borderColor = isDark
? `rgba(125, 211, 252, ${borderOpacity})`
: `rgba(14, 165, 233, ${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 widthScale = Math.min(Math.max(width / 960, 0.52), 1.08);
const heightScale = Math.min(Math.max(height / 520, 0.72), 1.1);
const compactViewportScale = width < 640 ? 0.9 : 1;
return Number(
(Math.min(widthScale, heightScale) * compactViewportScale).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(15,23,42,0.96)'
: 'rgba(255,255,255,0.96)',
borderColor: isDark
? 'rgba(148,163,184,0.2)'
: 'rgba(148,163,184,0.24)',
borderWidth: 1,
textStyle: {
color: isDark ? '#e2e8f0' : '#0f172a',
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>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
].join('');
}
if (!data || !('requestCount' in data)) {
return '';
}
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · 预设落点`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</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(8,15,31,0.7)'
: 'rgba(255,255,255,0.88)',
borderColor: isDark
? 'rgba(148,163,184,0.16)'
: 'rgba(148,163,184,0.2)',
borderWidth: 1,
borderRadius: 999,
padding: [4, 8],
},
},
},
],
}),
[
computedLayoutSize,
computedZoom,
countryRegions,
isDark,
mapNodes,
mapPalette,
responsiveMapScale,
],
);
if (!mapReady) {
return (
<div className="flex h-full items-center justify-center">
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
/>
</div>
);
}
return (
<div ref={chartContainerRef} className="h-full w-full">
<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: '100%', width: '100%' }}
/>
</div>
);
}
@@ -0,0 +1,344 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { useTheme } from '@/components/providers/theme-provider';
import type {
DashboardCapacity,
DashboardNodeHealth,
DashboardSummary,
DashboardTraffic,
DistributionItem,
} from '@/features/dashboard/types';
import { cn } from '@/lib/utils/cn';
type Tone = 'healthy' | 'warning' | 'danger' | 'source';
const WorldStageMap = dynamic(
() =>
import('@/features/dashboard/components/world-stage-map').then(
(module) => module.WorldStageMap,
),
{ ssr: false },
);
function formatPercent(value: number) {
if (!Number.isFinite(value)) {
return '0%';
}
return `${value.toFixed(value >= 100 ? 0 : 1)}%`;
}
function HeroMetric({
label,
value,
hint,
isDark,
}: {
label: string;
value: string;
hint: string;
isDark: boolean;
}) {
return (
<div
className={cn(
'rounded-[24px] border px-5 py-5 backdrop-blur',
isDark
? 'border-white/10 bg-white/6'
: 'border-sky-100/90 bg-white/80 shadow-[0_18px_40px_rgba(148,163,184,0.12)]',
)}
>
<p
className={cn(
'text-[11px] tracking-[0.26em] uppercase',
isDark ? 'text-slate-300' : 'text-slate-500',
)}
>
{label}
</p>
<p
className={cn(
'mt-3 text-[30px] leading-none font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
{value}
</p>
<p
className={cn(
'mt-2 text-sm',
isDark ? 'text-slate-300' : 'text-slate-600',
)}
>
{hint}
</p>
</div>
);
}
function LegendPill({
label,
tone,
isDark,
}: {
label: string;
tone: Tone;
isDark: boolean;
}) {
const toneClass =
tone === 'healthy'
? isDark
? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
: tone === 'warning'
? isDark
? 'border-amber-300/20 bg-amber-400/10 text-amber-100'
: 'border-amber-200 bg-amber-50 text-amber-700'
: tone === 'source'
? isDark
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
: 'border-sky-200 bg-sky-50 text-sky-700'
: isDark
? 'border-rose-300/20 bg-rose-400/10 text-rose-100'
: 'border-rose-200 bg-rose-50 text-rose-700';
return (
<div className={cn('rounded-full border px-3 py-1 text-[11px]', toneClass)}>
{label}
</div>
);
}
export function WorldStage({
summary,
traffic,
capacity,
nodes,
sourceCountries,
}: {
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
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: '180px 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;
return (
<section
className={cn(
'overflow-hidden rounded-[32px] border transition-colors',
isDark
? 'border-slate-800/70 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(56,189,248,0.10),transparent_18%),linear-gradient(135deg,#08111f,#0f172a_45%,#111827)] shadow-[0_32px_80px_rgba(2,6,23,0.35)]'
: 'border-sky-100/90 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_28%),radial-gradient(circle_at_82%_18%,rgba(59,130,246,0.12),transparent_20%),linear-gradient(135deg,#f8fbff,#edf5ff_45%,#ffffff)] shadow-[0_32px_80px_rgba(148,163,184,0.18)]',
)}
>
<div
className={cn(
'border-b px-6 py-6 md:px-7 md:py-7',
isDark ? 'border-white/8' : 'border-slate-200/70',
)}
>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-2">
<p
className={cn(
'text-[11px] tracking-[0.34em] uppercase',
isDark ? 'text-sky-200/80' : 'text-sky-700/80',
)}
>
Global Stage
</p>
<h2
className={cn(
'text-2xl font-semibold',
isDark ? 'text-white' : 'text-slate-950',
)}
>
全球态势板
</h2>
</div>
</div>
</div>
<div className="grid gap-6 px-6 py-7 md:px-7 md:py-8 xl:grid-cols-[1.32fr_0.88fr]">
<div className="space-y-5">
<div
className={cn(
'relative min-h-[540px] overflow-hidden rounded-[28px] border py-6 lg:min-h-[600px]',
isDark
? 'border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42))]'
: 'border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(239,246,255,0.92))]',
)}
>
<div
className={cn(
'absolute top-6 left-6 z-10 rounded-full px-3 py-1 text-[11px] tracking-[0.22em] uppercase backdrop-blur',
isDark
? 'bg-sky-400/20 text-sky-100'
: 'bg-sky-100/90 text-sky-700',
)}
>
{sourceCountries.length > 0
? '访客来源热度'
: geoConfiguredNodes > 0
? '节点地理坐标'
: '节点覆盖信号'}
</div>
<div className="absolute top-4 right-4 z-10 flex flex-wrap gap-2">
<LegendPill
label="国家底色: 来源热度"
tone="source"
isDark={isDark}
/>
<LegendPill
label="绿色: 运行正常"
tone="healthy"
isDark={isDark}
/>
<LegendPill
label="黄色: 资源承压"
tone="warning"
isDark={isDark}
/>
<LegendPill
label="红色: 异常待处理"
tone="danger"
isDark={isDark}
/>
</div>
<div
ref={mapViewportRef}
className="absolute inset-x-3 top-16 bottom-6 flex items-center justify-center md:inset-x-4 md:top-18 md:bottom-8"
>
<div className="h-full w-full min-w-0 overflow-hidden">
{shouldRenderMap ? (
<WorldStageMap
isDark={isDark}
nodes={nodes}
sourceCountries={sourceCountries}
/>
) : (
<div className="flex h-full items-center justify-center">
<EmptyState
title="全球地图准备中"
description="地图会在进入可视区域后再加载,避免首页滚动和交互被首屏图表拖慢。"
/>
</div>
)}
</div>
</div>
{shouldRenderMap && nodes.length === 0 ? (
<div className="pointer-events-none absolute inset-x-6 bottom-10 z-10">
<div
className={cn(
'rounded-2xl border border-dashed px-4 py-4 text-sm backdrop-blur',
isDark
? 'border-white/15 bg-white/5 text-slate-300'
: 'border-slate-300/70 bg-white/78 text-slate-600',
)}
>
当前暂无节点接入。地图已完成初始化,后续会在这里展示节点位置与健康状态。
</div>
</div>
) : null}
</div>
</div>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-2">
<HeroMetric
label="在线覆盖"
value={formatPercent(onlineRate)}
hint={`${summary.online_nodes}/${summary.total_nodes} 个节点在线`}
isDark={isDark}
/>
<HeroMetric
label="运行健康"
value={formatPercent(healthyRate)}
hint={`${summary.unhealthy_nodes} 个节点存在 OpenResty 异常`}
isDark={isDark}
/>
<HeroMetric
label="最近窗口请求"
value={traffic.request_count.toLocaleString('zh-CN')}
hint={`QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点已上报`}
isDark={isDark}
/>
<HeroMetric
label="平均 CPU"
value={formatPercent(capacity.average_cpu_usage_percent)}
hint={`${capacity.high_cpu_nodes} 个节点 CPU 偏高`}
isDark={isDark}
/>
<HeroMetric
label="平均内存"
value={formatPercent(capacity.average_memory_usage_percent)}
hint={`${capacity.high_memory_nodes} 个高内存节点`}
isDark={isDark}
/>
<HeroMetric
label="高存储节点"
value={capacity.high_storage_nodes.toLocaleString('zh-CN')}
hint={`${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`}
isDark={isDark}
/>
</div>
</div>
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
export interface DashboardSummary {
total_nodes: number;
online_nodes: number;
offline_nodes: number;
pending_nodes: number;
unhealthy_nodes: number;
}
export interface DashboardTraffic {
request_count: number;
unique_visitors: number;
error_count: number;
estimated_qps: number;
reported_nodes: number;
}
export interface DashboardCapacity {
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
high_cpu_nodes: number;
high_memory_nodes: number;
high_storage_nodes: number;
}
export interface DistributionItem {
key: string;
value: number;
}
export type CompactDistributionItem = [string, number];
export interface TrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface CapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NetworkTrendPoint {
bucket_started_at: string;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
reported_nodes: number;
}
export interface DiskIOTrendPoint {
bucket_started_at: string;
disk_read_bytes: number;
disk_write_bytes: number;
reported_nodes: number;
}
export interface TrafficDistributions {
status_codes: DistributionItem[];
top_domains: DistributionItem[];
source_countries: DistributionItem[];
}
export interface DashboardTrends {
traffic_24h: TrafficTrendPoint[];
capacity_24h: CapacityTrendPoint[];
network_24h: NetworkTrendPoint[];
disk_io_24h: DiskIOTrendPoint[];
}
export interface DashboardNodeHealth {
id: number;
node_id: string;
name: string;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
status: 'online' | 'offline' | 'pending';
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
current_version: string;
last_seen_at: string;
active_event_count: number;
cpu_usage_percent: number;
memory_usage_percent: number;
storage_usage_percent: number;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface DashboardOverview {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: TrafficDistributions;
trends: DashboardTrends;
nodes: DashboardNodeHealth[];
}
export type CompactTrafficTrendPoint = [string, number, number, number];
export type CompactCapacityTrendPoint = [string, number, number, number];
export type CompactNetworkTrendPoint = [
string,
number,
number,
number,
number,
number,
];
export type CompactDiskIOTrendPoint = [string, number, number, number];
export type CompactDashboardNodeHealth = [
number,
string,
string,
string,
number | null,
number | null,
DashboardNodeHealth['status'],
DashboardNodeHealth['openresty_status'],
string,
string,
number,
number,
number,
number,
number,
number,
number,
];
export interface DashboardOverviewCompact {
generated_at: string;
summary: DashboardSummary;
traffic: DashboardTraffic;
capacity: DashboardCapacity;
distributions: {
status_codes: CompactDistributionItem[];
top_domains: CompactDistributionItem[];
source_countries: CompactDistributionItem[];
};
trends: {
traffic_24h: CompactTrafficTrendPoint[];
capacity_24h: CompactCapacityTrendPoint[];
network_24h: CompactNetworkTrendPoint[];
disk_io_24h: CompactDiskIOTrendPoint[];
};
nodes: CompactDashboardNodeHealth[];
}
@@ -0,0 +1,29 @@
import {apiRequest} from '@/lib/api/client';
import type {DnsAccountItem, DnsAccountMutationPayload,} from '@/features/dns-accounts/types';
export function getDnsAccounts() {
return apiRequest<DnsAccountItem[]>('/dns-accounts/');
}
export function createDnsAccount(payload: DnsAccountMutationPayload) {
return apiRequest<DnsAccountItem>('/dns-accounts/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateDnsAccount(
id: number,
payload: DnsAccountMutationPayload,
) {
return apiRequest<DnsAccountItem>(`/dns-accounts/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteDnsAccount(id: number) {
return apiRequest<void>(`/dns-accounts/${id}/delete`, {
method: 'POST',
});
}
@@ -0,0 +1,220 @@
'use client';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import Link from 'next/link';
import {useForm} from 'react-hook-form';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {AppModal} from '@/components/ui/app-modal';
import {createDnsAccount, deleteDnsAccount, getDnsAccounts,} from '@/features/dns-accounts/api/dns-accounts';
import type {DnsAccountItem} from '@/features/dns-accounts/types';
import {getErrorMessage} from '@/features/websites/utils';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
} from '@/features/shared/components/resource-primitives';
import {formatDateTime} from '@/lib/utils/date';
export function DnsAccountsPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{
tone: 'info' | 'success' | 'danger';
message: string;
} | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const dnsAccountsQuery = useQuery({
queryKey: ['dns-accounts'],
queryFn: getDnsAccounts,
});
const deleteMutation = useMutation({
mutationFn: deleteDnsAccount,
onSuccess: async () => {
setFeedback({ tone: 'success', message: 'DNS 账号已删除。' });
await queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleDelete = (account: DnsAccountItem) => {
if (!window.confirm(`确认删除 DNS 账号 ${account.name} 吗?`)) {
return;
}
setFeedback(null);
deleteMutation.mutate(account.id);
};
const accounts = useMemo(
() => dnsAccountsQuery.data ?? [],
[dnsAccountsQuery.data],
);
return (
<>
<div className="space-y-6">
<PageHeader
title="DNS 账号"
description="统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。"
action={
<div className="flex flex-wrap gap-3">
<Link
href="/website/certificate"
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回
</Link>
<PrimaryButton
type="button"
onClick={() => setIsCreateOpen(true)}
>
添加账号
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard title="DNS 账号列表">
{dnsAccountsQuery.isLoading ? (
<LoadingState />
) : dnsAccountsQuery.isError ? (
<ErrorState
title="加载失败"
description={getErrorMessage(dnsAccountsQuery.error)}
/>
) : accounts.length === 0 ? (
<EmptyState
title="暂无 DNS 账号"
description="点击右上角“添加账号”开始录入。"
/>
) : (
<div className="space-y-3">
{accounts.map((account) => (
<div
key={account.id}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{account.name}{' '}
<span className="ml-2 text-xs font-normal text-[var(--foreground-secondary)]">
({account.type})
</span>
</p>
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
<p>创建于:{formatDateTime(account.created_at)}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<DangerButton
type="button"
onClick={() => handleDelete(account)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</div>
</div>
))}
</div>
)}
</AppCard>
</div>
{isCreateOpen && (
<DnsAccountCreateModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onCreated={() => {
setFeedback({ tone: 'success', message: 'DNS 账号已添加。' });
setIsCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ['dns-accounts'] });
}}
/>
)}
</>
);
}
function DnsAccountCreateModal({
isOpen,
onClose,
onCreated,
}: {
isOpen: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [error, setError] = useState('');
const { register, handleSubmit, formState } = useForm({
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
});
const createMutation = useMutation({
mutationFn: createDnsAccount,
onSuccess: onCreated,
onError: (err) => setError(getErrorMessage(err)),
});
const onSubmit = handleSubmit((values) => {
setError('');
// for cloudflare we wrap the token in JSON if it isn't already (the backend expects JSON)
let auth = values.authorization;
if (!auth.startsWith('{')) {
auth = JSON.stringify({ api_token: values.authorization });
}
createMutation.mutate({ ...values, authorization: auth });
});
return (
<AppModal isOpen={isOpen} onClose={onClose} title="添加 DNS 账号">
<form onSubmit={onSubmit} className="space-y-5">
{error && <InlineMessage tone="danger" message={error} />}
<ResourceField
label="账号名称"
error={formState.errors.name?.message as string}
>
<ResourceInput
placeholder="Cloudlfare 邮箱账号"
{...register('name', { required: '请输入名称' })}
/>
</ResourceField>
<ResourceField label="DNS 服务商">
<ResourceSelect {...register('type')}>
<option value="cloudflare">Cloudflare</option>
</ResourceSelect>
</ResourceField>
<ResourceField label="API Token" hint="请勿使用 Global API Key">
<ResourceInput
{...register('authorization', { required: '请输入 Token' })}
/>
</ResourceField>
<PrimaryButton type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? '提交中...' : '提交'}
</PrimaryButton>
</form>
</AppModal>
);
}
@@ -0,0 +1,13 @@
export interface DnsAccountItem {
id: number;
name: string;
type: string;
created_at: string;
updated_at: string;
}
export interface DnsAccountMutationPayload {
name: string;
type: string;
authorization: string;
}
@@ -0,0 +1,38 @@
import {apiRequest} from '@/lib/api/client';
import type {
ManagedDomainCertificateOption,
ManagedDomainItem,
ManagedDomainMutationPayload,
} from '@/features/managed-domains/types';
export function getManagedDomains() {
return apiRequest<ManagedDomainItem[]>('/managed-domains/');
}
export function createManagedDomain(payload: ManagedDomainMutationPayload) {
return apiRequest<ManagedDomainItem>('/managed-domains/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateManagedDomain(
id: number,
payload: ManagedDomainMutationPayload,
) {
return apiRequest<ManagedDomainItem>(`/managed-domains/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteManagedDomain(id: number) {
return apiRequest<void>(`/managed-domains/${id}/delete`, {
method: 'POST',
});
}
export function getManagedDomainCertificates() {
return apiRequest<ManagedDomainCertificateOption[]>('/tls-certificates/');
}
@@ -0,0 +1,22 @@
export interface ManagedDomainItem {
id: number;
domain: string;
cert_id: number | null;
enabled: boolean;
remark: string;
created_at: string;
updated_at: string;
}
export interface ManagedDomainMutationPayload {
domain: string;
cert_id: number | null;
enabled: boolean;
remark: string;
}
export interface ManagedDomainCertificateOption {
id: number;
name: string;
not_after: string | null;
}
@@ -0,0 +1,102 @@
import {apiRequest} from '@/lib/api/client';
import type {
NodeAgentReleaseInfo,
NodeAgentUpdatePayload,
NodeBootstrapToken,
NodeItem,
NodeMutationPayload,
NodeObservability,
} from '@/features/nodes/types';
import type {ReleaseChannel} from '@/features/update/types';
export function getNodes() {
return apiRequest<NodeItem[]>('/nodes/');
}
export function createNode(payload: NodeMutationPayload) {
return apiRequest<NodeItem>('/nodes/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateNode(id: number, payload: NodeMutationPayload) {
return apiRequest<NodeItem>(`/nodes/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteNode(id: number) {
return apiRequest<void>(`/nodes/${id}/delete`, {
method: 'POST',
});
}
export function getNodeBootstrapToken() {
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token');
}
export function rotateNodeBootstrapToken() {
return apiRequest<NodeBootstrapToken>('/nodes/bootstrap-token/rotate', {
method: 'POST',
});
}
export function getNodeAgentRelease(
id: number,
channel: ReleaseChannel = 'stable',
) {
return apiRequest<NodeAgentReleaseInfo>(
`/nodes/${id}/agent-release?channel=${channel}`,
);
}
export function requestNodeAgentUpdate(
id: number,
payload?: NodeAgentUpdatePayload,
) {
return apiRequest<NodeItem>(`/nodes/${id}/agent-update`, {
method: 'POST',
body: JSON.stringify(payload ?? {}),
});
}
export function requestNodeForceSync(id: number) {
return apiRequest<NodeItem>(`/nodes/${id}/force-sync`, {
method: 'POST',
});
}
export function requestNodeOpenrestyRestart(id: number) {
return apiRequest<NodeItem>(`/nodes/${id}/openresty-restart`, {
method: 'POST',
});
}
export function getNodeObservability(
id: number,
options?: { hours?: number; limit?: number },
) {
const params = new URLSearchParams();
if (options?.hours) {
params.set('hours', String(options.hours));
}
if (options?.limit) {
params.set('limit', String(options.limit));
}
const query = params.toString();
return apiRequest<NodeObservability>(
`/nodes/${id}/observability${query ? `?${query}` : ''}`,
);
}
export function cleanupNodeHealthEvents(id: number) {
return apiRequest<{ node_id: string; deleted_count: number }>(
`/nodes/${id}/observability/cleanup`,
{
method: 'POST',
},
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useEffect, useMemo} from 'react';
import {useForm, useWatch} from 'react-hook-form';
import {z} from 'zod';
import {AppModal} from '@/components/ui/app-modal';
import worldGeoJson from '@/features/dashboard/data/world-geo.json';
import type {NodeItem, NodeMutationPayload} from '@/features/nodes/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
type GeoJsonFeature = {
geometry?: GeoJsonGeometry;
properties?: {
name?: string;
};
};
type RegionOption = {
label: string;
latitude: number;
longitude: number;
};
const nodeEditorSchema = z
.object({
type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z
.string()
.trim()
.min(1, '请输入节点名')
.max(128, '节点名不能超过 128 个字符'),
ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'),
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
relay_bind_port: z.string().trim(),
relay_vhost_http_port: z.string().trim(),
relay_client_access_addr: z.string().trim(),
relay_agent_access_addr: z.string().trim(),
relay_client_proxy_url: z.string().trim(),
relay_web_server_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_region: z.string(),
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
geo_latitude: z.string().trim(),
geo_longitude: z.string().trim(),
})
.superRefine((values, ctx) => {
if (values.ip_manual_override && values.ip.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['ip'],
message: '锁定节点 IP 时必须填写节点 IP',
});
}
if (values.type === 'tunnel_relay') {
const port = Number(values.relay_bind_port);
if (Number.isNaN(port) || port < 1 || port > 65535) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_bind_port'],
message: '请填写正确的端口号 (1-65535)',
});
}
const vhostPort = Number(values.relay_vhost_http_port);
if (Number.isNaN(vhostPort) || vhostPort < 1 || vhostPort > 65535) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: '请填写正确的端口号 (1-65535)',
});
}
}
if (!values.geo_manual_override) {
return;
}
const hasLatitude = values.geo_latitude !== '';
const hasLongitude = values.geo_longitude !== '';
if (hasLatitude !== hasLongitude) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度和经度需要同时填写',
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '纬度和经度需要同时填写',
});
return;
}
if (hasLatitude) {
const latitude = Number(values.geo_latitude);
if (Number.isNaN(latitude) || latitude < -90 || latitude > 90) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_latitude'],
message: '纬度必须在 -90 到 90 之间',
});
}
}
if (hasLongitude) {
const longitude = Number(values.geo_longitude);
if (Number.isNaN(longitude) || longitude < -180 || longitude > 180) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['geo_longitude'],
message: '经度必须在 -180 到 180 之间',
});
}
}
});
type NodeEditorValues = z.infer<typeof nodeEditorSchema>;
const defaultValues: NodeEditorValues = {
type: 'edge_node',
name: '',
ip: '',
ip_manual_override: false,
auto_update_enabled: false,
relay_bind_port: '7000',
relay_vhost_http_port: '8080',
relay_client_access_addr: '',
relay_agent_access_addr: '',
relay_client_proxy_url: '',
relay_web_server_enabled: true,
geo_manual_override: false,
geo_region: '',
geo_name: '',
geo_latitude: '',
geo_longitude: '',
};
function collectPoints(value: unknown, points: Array<[number, number]>) {
if (!Array.isArray(value)) {
return;
}
if (
value.length >= 2 &&
typeof value[0] === 'number' &&
typeof value[1] === 'number'
) {
points.push([value[0], value[1]]);
return;
}
for (const item of value) {
collectPoints(item, points);
}
}
function getRegionCenter(feature: GeoJsonFeature) {
const points: Array<[number, number]> = [];
collectPoints(feature.geometry?.coordinates, points);
if (points.length === 0) {
return null;
}
let minLongitude = Number.POSITIVE_INFINITY;
let maxLongitude = Number.NEGATIVE_INFINITY;
let minLatitude = Number.POSITIVE_INFINITY;
let maxLatitude = Number.NEGATIVE_INFINITY;
for (const [longitude, latitude] of points) {
minLongitude = Math.min(minLongitude, longitude);
maxLongitude = Math.max(maxLongitude, longitude);
minLatitude = Math.min(minLatitude, latitude);
maxLatitude = Math.max(maxLatitude, latitude);
}
return {
longitude: Number(((minLongitude + maxLongitude) / 2).toFixed(4)),
latitude: Number(((minLatitude + maxLatitude) / 2).toFixed(4)),
};
}
function buildRegionOptions() {
const features = ((worldGeoJson as { features?: GeoJsonFeature[] })
.features ?? []) as GeoJsonFeature[];
const options = new Map<string, RegionOption>();
for (const feature of features) {
const label = feature.properties?.name?.trim();
if (!label || options.has(label)) {
continue;
}
const center = getRegionCenter(feature);
if (!center) {
continue;
}
options.set(label, {
label,
latitude: center.latitude,
longitude: center.longitude,
});
}
return Array.from(options.values()).sort((a, b) =>
a.label.localeCompare(b.label),
);
}
function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
if (!node) {
return defaultValues;
}
return {
type: node.node_type ?? 'edge_node',
name: node.name ?? '',
ip: node.ip ?? '',
ip_manual_override: node.ip_manual_override ?? false,
auto_update_enabled: node.auto_update_enabled ?? false,
relay_bind_port: String(node.relay_bind_port ?? 7000),
relay_vhost_http_port: String(node.relay_vhost_http_port ?? 8080),
relay_client_access_addr: node.relay_client_access_addr ?? '',
relay_agent_access_addr: node.relay_agent_access_addr ?? '',
relay_client_proxy_url: node.relay_client_proxy_url ?? '',
relay_web_server_enabled: node.relay_web_server_enabled ?? false,
geo_manual_override: node.geo_manual_override ?? false,
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
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),
};
}
function toPayload(values: NodeEditorValues): NodeMutationPayload {
const basePayload = {
node_type: values.type,
name: values.name.trim(),
ip: values.ip.trim(),
ip_manual_override: values.ip_manual_override,
auto_update_enabled: values.auto_update_enabled,
relay_bind_port:
values.type === 'tunnel_relay'
? Number(values.relay_bind_port)
: undefined,
relay_vhost_http_port:
values.type === 'tunnel_relay'
? Number(values.relay_vhost_http_port)
: undefined,
relay_client_access_addr:
values.type === 'tunnel_relay'
? values.relay_client_access_addr.trim()
: undefined,
relay_agent_access_addr:
values.type === 'tunnel_relay'
? values.relay_agent_access_addr.trim()
: undefined,
relay_client_proxy_url:
values.type === 'tunnel_relay'
? values.relay_client_proxy_url.trim()
: undefined,
relay_web_server_enabled:
values.type === 'tunnel_relay'
? values.relay_web_server_enabled
: undefined,
};
if (!values.geo_manual_override) {
return {
...basePayload,
geo_manual_override: false,
geo_name: '',
geo_latitude: null,
geo_longitude: null,
};
}
return {
...basePayload,
geo_manual_override: true,
geo_name: values.geo_name.trim(),
geo_latitude:
values.geo_latitude.trim() === '' ? null : Number(values.geo_latitude),
geo_longitude:
values.geo_longitude.trim() === '' ? null : Number(values.geo_longitude),
};
}
export function NodeEditorModal({
isOpen,
node,
isSubmitting,
title,
description,
submitLabel,
onClose,
onSubmit,
}: {
isOpen: boolean;
node?: Partial<NodeItem> | null;
isSubmitting: boolean;
title: string;
description: string;
submitLabel: string;
onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => void;
}) {
const form = useForm<NodeEditorValues>({
resolver: zodResolver(nodeEditorSchema),
defaultValues,
});
const watchedType = useWatch({
control: form.control,
name: 'type',
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const watchedIPManualOverride = useWatch({
control: form.control,
name: 'ip_manual_override',
});
const watchedGeoManualOverride = useWatch({
control: form.control,
name: 'geo_manual_override',
});
const regionOptions = useMemo(() => buildRegionOptions(), []);
useEffect(() => {
if (!isOpen) {
return;
}
form.reset(buildFormValues(node));
}, [form, isOpen, node]);
const ipField = form.register('ip');
const handleSubmit = form.handleSubmit((values) => {
onSubmit(toPayload(values));
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={title}
description={description}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={onClose}
disabled={isSubmitting}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-editor-form"
disabled={isSubmitting}
>
{isSubmitting ? '保存中...' : submitLabel}
</PrimaryButton>
</div>
}
>
<form id="node-editor-form" className="space-y-5" onSubmit={handleSubmit}>
<ResourceField
label="节点类型"
hint="边缘节点用于常规的网站代理和缓存;隧道中继节点用于内网穿透的流量中转服务器。"
>
<ResourceSelect {...form.register('type')} disabled={!!node}>
<option value="edge_node">边缘节点 (Edge Node)</option>
<option value="tunnel_relay">隧道中继节点 (Tunnel Relay)</option>
<option value="tunnel_client">隧道客户端 (Tunnel Client)</option>
</ResourceSelect>
</ResourceField>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
{watchedType === 'tunnel_relay' && (
<div className="border-border bg-muted/50 space-y-5 rounded-lg border p-4">
<h4 className="text-sm font-medium">中继配置 (Relay Config)</h4>
<ResourceField
label="中继绑定端口 (Bind Port)"
hint="中继服务端在此端口监听并接受 Flared 客户端连接。"
error={form.formState.errors.relay_bind_port?.message}
>
<ResourceInput
placeholder="7000"
{...form.register('relay_bind_port')}
/>
</ResourceField>
<ResourceField
label="中继 Vhost HTTP 端口 (Vhost HTTP Port)"
hint="中继服务端在此端口监听 HTTP 虚拟主机流量,用于 Openresty 转发到中继。"
error={form.formState.errors.relay_vhost_http_port?.message}
>
<ResourceInput
placeholder="8080"
{...form.register('relay_vhost_http_port')}
/>
</ResourceField>
<ResourceField
label="边缘节点接入地址 (Edge Node Access Addr)"
hint="用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
error={form.formState.errors.relay_agent_access_addr?.message}
>
<ResourceInput
placeholder="例如: 10.0.0.1:7000"
{...form.register('relay_agent_access_addr')}
/>
</ResourceField>
<ResourceField
label="客户端接入地址 (Client Access Addr)"
hint="如果不填默认使用节点的 IP + 绑定端口。"
error={form.formState.errors.relay_client_access_addr?.message}
>
<ResourceInput
placeholder="例如: relay.example.com:7000"
{...form.register('relay_client_access_addr')}
/>
</ResourceField>
<ResourceField
label="代理地址 (Proxy URL)"
hint="可选,下发给客户端。当客户端连接中继需要经过 HTTP 代理时使用。"
error={form.formState.errors.relay_client_proxy_url?.message}
>
<ResourceInput
placeholder="例如: http://10.0.0.1:3128"
{...form.register('relay_client_proxy_url')}
/>
</ResourceField>
<ToggleField
label="开启 FRPS WebUI"
description="访问端口 17500。用户 admin 密码为当前中继节点的接入 Token。"
checked={form.watch('relay_web_server_enabled')}
onChange={(checked) =>
form.setValue('relay_web_server_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
)}
<ResourceField
label="节点 IP"
hint={
watchedIPManualOverride
? '已锁定为指定值,不会被更新。'
: '留空或关闭锁定时,等待 Agent 自动更新。'
}
error={form.formState.errors.ip?.message}
>
<ResourceInput
placeholder="203.0.113.10"
{...ipField}
onChange={(event) => {
ipField.onChange(event);
const nextIP = event.target.value.trim();
const previousIP = (node?.ip ?? '').trim();
if (nextIP !== '' && nextIP !== previousIP) {
form.setValue('ip_manual_override', true, {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</ResourceField>
<ToggleField
label="锁定节点 IP"
description="开启后以管理端填写的 IP 为准,Agent 后续上报不会覆盖该字段。"
checked={watchedIPManualOverride}
onChange={(checked) =>
form.setValue('ip_manual_override', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
{watchedType === 'edge_node' && (
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
)}
<ToggleField
label="手动指定地图地区"
description="关闭时,节点会根据当前 IP 自动解析归属地;开启后使用你手动选择的地区与坐标。"
checked={watchedGeoManualOverride}
onChange={(checked) => {
form.setValue('geo_manual_override', checked, {
shouldDirty: true,
shouldValidate: true,
});
if (!checked) {
form.setValue('geo_region', '', { shouldDirty: true });
form.setValue('geo_name', '', { shouldDirty: true });
form.setValue('geo_latitude', '', { shouldDirty: true });
form.setValue('geo_longitude', '', { shouldDirty: true });
}
}}
/>
{watchedGeoManualOverride ? (
<ResourceField
label="地区选择"
hint="选择后会自动填充位置名与地图坐标,你也可以继续微调。"
>
<ResourceSelect
value={form.watch('geo_region')}
onChange={(event) => {
const regionName = event.target.value;
form.setValue('geo_region', regionName, {
shouldDirty: true,
});
const selectedRegion = regionOptions.find(
(item) => item.label === regionName,
);
if (!selectedRegion) {
return;
}
form.setValue('geo_name', selectedRegion.label, {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('geo_latitude', String(selectedRegion.latitude), {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
'geo_longitude',
String(selectedRegion.longitude),
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
>
<option value="">请选择地区</option>
{regionOptions.map((option) => (
<option key={option.label} value={option.label}>
{option.label}
</option>
))}
</ResourceSelect>
</ResourceField>
) : null}
<ResourceField
label="地图位置名"
hint={
watchedGeoManualOverride
? '可在自动填充后继续修改,例如使用更贴近业务的展示名称。'
: '自动解析模式下,该字段会由系统根据节点 IP 回填。'
}
error={form.formState.errors.geo_name?.message}
>
<ResourceInput
placeholder="Shanghai"
disabled={!watchedGeoManualOverride}
{...form.register('geo_name')}
/>
</ResourceField>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField
label="纬度"
hint="范围 -90 到 90,例如上海约为 31.2304"
error={form.formState.errors.geo_latitude?.message}
>
<ResourceInput
placeholder="31.2304"
disabled={!watchedGeoManualOverride}
{...form.register('geo_latitude')}
/>
</ResourceField>
<ResourceField
label="经度"
hint="范围 -180 到 180,例如上海约为 121.4737"
error={form.formState.errors.geo_longitude?.message}
>
<ResourceInput
placeholder="121.4737"
disabled={!watchedGeoManualOverride}
{...form.register('geo_longitude')}
/>
</ResourceField>
</div>
</form>
</AppModal>
);
}
@@ -0,0 +1,177 @@
'use client';
import React from 'react';
import type {NodeObservability} from '@/features/nodes/types';
export type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
export type HealthEventFilter = 'all' | 'active' | 'resolved';
export type NodeDetailTab = 'dashboard' | 'info';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
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) {
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 `${days} 天 ${hours} 小时`;
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`;
}
return `${minutes} 分钟`;
}
export function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
export function parseTrafficMap(value?: string | null) {
if (!value) {
return {} as Record<string, number>;
}
try {
const parsed = JSON.parse(value) as Record<string, number>;
return Object.entries(parsed).reduce<Record<string, number>>(
(result, [key, count]) => {
if (typeof count === 'number' && Number.isFinite(count)) {
result[key] = count;
}
return result;
},
{},
);
} catch {
return {} as Record<string, number>;
}
}
export function aggregateTrafficBreakdown(
reports: NodeObservability['traffic_reports'],
field: 'status_codes_json' | 'top_domains_json',
) {
const summary = new Map<string, number>();
for (const report of reports) {
const parsed = parseTrafficMap(report[field]);
for (const [key, value] of Object.entries(parsed)) {
summary.set(key, (summary.get(key) ?? 0) + value);
}
}
return Array.from(summary.entries())
.sort((left, right) => {
if (right[1] === left[1]) {
return left[0].localeCompare(right[0]);
}
return right[1] - left[1];
})
.slice(0, 6)
.map(([label, value]) => ({ label, value }));
}
export function getHealthEventVariant(
event: NodeObservability['health_events'][number],
): 'success' | 'warning' | 'danger' | 'info' {
if (event.status === 'resolved') {
return 'success';
}
if (event.severity === 'critical') {
return 'danger';
}
if (event.severity === 'warning') {
return 'warning';
}
return 'info';
}
export function getHealthEventLabel(
event: NodeObservability['health_events'][number],
) {
return event.event_type.replaceAll('_', ' ');
}
export function MetricBar({
label,
value,
progress,
hint,
}: {
label: string;
value: string;
progress?: number | null;
hint?: string;
}) {
return (
<div className="space-y-2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
{hint ? (
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
{hint}
</p>
) : null}
</div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{value}
</p>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--status-info-foreground)] transition-[width]"
style={{ width: `${progress ?? 0}%` }}
/>
</div>
</div>
);
}
export function SummaryStat({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">{hint}</p>
</div>
);
}
@@ -0,0 +1,475 @@
'use client';
import Link from 'next/link';
import {useSearchParams} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge';
import {getConfigVersions} from '@/features/config-versions/api/config-versions';
import {createNode, deleteNode, getNodes, updateNode,} from '@/features/nodes/api/nodes';
import {NodeEditorModal} from '@/features/nodes/components/node-editor-modal';
import type {NodeItem, NodeMutationPayload} from '@/features/nodes/types';
import {DangerButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
import {formatDateTime, formatRelativeTime} from '@/lib/utils/date';
import {
getApplyLabel,
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
getOpenrestyStatusLabel,
getOpenrestyStatusVariant,
getRelayStatusLabel,
getRelayStatusVariant,
isMeaningfulTime,
isNodeAbnormal,
isWSConnectedLastSeen,
} from '@/features/nodes/utils';
const nodesQueryKey = ['nodes'];
const supportedNodeFilters = [
'all',
'edge',
'relay',
'tunnel',
'abnormal',
] as const;
type NodeFilter = (typeof supportedNodeFilters)[number];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function NodesPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
refetchInterval: 5000,
});
const configVersionsQuery = useQuery({
queryKey: ['config-versions'],
queryFn: getConfigVersions,
refetchInterval: 5000,
});
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
return editingNode
? updateNode(editingNode.id, payload)
: createNode(payload);
},
onSuccess: async () => {
setFeedback({
tone: 'success',
message: editingNode ? '节点已更新。' : '节点已创建。',
});
setEditingNode(null);
setIsEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteNode,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' });
setEditingNode(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const activeVersion = useMemo(
() =>
(configVersionsQuery.data ?? []).find((item) => item.is_active)
?.version ?? '',
[configVersionsQuery.data],
);
const nodeFilter = useMemo<NodeFilter>(() => {
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
if (supportedNodeFilters.includes(current as NodeFilter)) {
return current as NodeFilter;
}
const legacyRisk = searchParams.get('risk')?.trim().toLowerCase() ?? '';
if (
legacyRisk === 'offline' ||
legacyRisk === 'unhealthy' ||
legacyRisk === 'lagging'
) {
return 'abnormal';
}
return 'all';
}, [searchParams]);
const filteredNodes = useMemo(() => {
switch (nodeFilter) {
case 'all':
return nodes;
case 'relay':
return nodes.filter((node) => node.node_type === 'tunnel_relay');
case 'tunnel':
return nodes.filter((node) => node.node_type === 'tunnel_client');
case 'abnormal':
return nodes.filter((node) => isNodeAbnormal(node, activeVersion));
case 'edge':
default:
return nodes.filter((node) => node.node_type === 'edge_node');
}
}, [activeVersion, nodeFilter, nodes]);
const filterDescription = useMemo(() => {
switch (nodeFilter) {
case 'all':
return '当前展示全部节点。';
case 'relay':
return '当前仅展示 Relay 节点。';
case 'tunnel':
return '当前仅展示 Tunnel 节点。';
case 'abnormal':
return activeVersion
? `当前仅展示异常节点(离线、运行异常或未追平激活版本 ${activeVersion})。`
: '当前仅展示异常节点(离线或运行异常)。';
case 'edge':
default:
return '当前仅展示 Edge 节点。';
}
}, [activeVersion, nodeFilter]);
const handleReset = () => {
setFeedback(null);
setEditingNode(null);
setIsEditorOpen(false);
};
const handleCreate = () => {
setFeedback(null);
setEditingNode(null);
setIsEditorOpen(true);
};
const handleEdit = (node: NodeItem) => {
setFeedback(null);
setEditingNode(node);
setIsEditorOpen(true);
};
const handleDelete = (nodeId: number, nodeName: string) => {
if (
!window.confirm(
`确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate(nodeId);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="节点管理"
description="显示节点相关信息。"
action={
<>
<SecondaryButton type="button" onClick={handleCreate}>
新增节点
</SecondaryButton>
<Link
href="/apply-log"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
应用记录
</Link>
</>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard
title="节点列表"
description={filterDescription}
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
}
>
立即刷新
</SecondaryButton>
}
>
{nodesQuery.isLoading ? (
<LoadingState />
) : nodesQuery.isError ? (
<ErrorState
title="节点列表加载失败"
description={getErrorMessage(nodesQuery.error)}
/>
) : filteredNodes.length === 0 ? (
<EmptyState
title={nodes.length === 0 ? '暂无节点' : '当前筛选无结果'}
description={
nodes.length === 0
? '请先创建一个节点,然后进入详情页查看专属部署命令。'
: '可以返回总览继续排查,或切换到其他筛选查看完整列表。'
}
/>
) : (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Link
href="/node"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
nodeFilter === 'all'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
全部节点
</Link>
<Link
href="/node?filter=edge"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
nodeFilter === 'edge'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
Edge
</Link>
<Link
href="/node?filter=relay"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
nodeFilter === 'relay'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
Relay
</Link>
<Link
href="/node?filter=tunnel"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
nodeFilter === 'tunnel'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
Tunnel
</Link>
<Link
href="/node?filter=abnormal"
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
nodeFilter === 'abnormal'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
异常
</Link>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">节点</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">Version</th>
<th className="px-3 py-3 font-medium">运行健康</th>
<th className="px-3 py-3 font-medium">当前版本</th>
<th className="px-3 py-3 font-medium">最近应用</th>
<th className="px-3 py-3 font-medium">最近心跳</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{filteredNodes.map((node) => (
<tr key={node.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium text-[var(--foreground-primary)]">
{node.name}
</p>
{node.node_type === 'tunnel_relay' ? (
<span className="rounded-full border border-blue-500/20 bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Relay
</span>
) : node.node_type === 'tunnel_client' ? (
<span className="rounded-full border border-purple-500/20 bg-purple-50 px-2 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
Client
</span>
) : (
<span className="rounded-full border border-gray-500/20 bg-gray-50 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
Edge
</span>
)}
</div>
<p className="text-xs text-[var(--foreground-secondary)]">
IP:{node.ip || 'null'}
{node.ip_manual_override ? '(已锁定)' : ''}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
位置:{node.geo_name || '未配置地图点位'}
</p>
</div>
</td>
<td className="px-3 py-4">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
</td>
<td className="px-3 py-4 text-sm text-[var(--foreground-secondary)]">
{node.version || 'unknown'}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
{node.node_type === 'tunnel_relay' ? (
<StatusBadge
label={getRelayStatusLabel(node.relay_status)}
variant={getRelayStatusVariant(
node.relay_status,
)}
/>
) : node.node_type === 'tunnel_client' ? (
<StatusBadge
label={
node.status === 'online' ? '运行中' : '未知'
}
variant={
node.status === 'online'
? 'success'
: 'warning'
}
/>
) : (
<StatusBadge
label={getOpenrestyStatusLabel(
node.openresty_status,
)}
variant={getOpenrestyStatusVariant(
node.openresty_status,
)}
/>
)}
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.current_version ||
(node.node_type === 'tunnel_relay'
? '实时配置'
: '未应用')}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
{node.node_type === 'tunnel_relay' ? (
<span className="text-sm text-[var(--foreground-secondary)]">
—
</span>
) : (
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
)}
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(
node.last_seen_at,
)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<Link
href={`/node/detail?id=${node.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-2 text-xs font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
详情
</Link>
<SecondaryButton
type="button"
onClick={() => handleEdit(node)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(node.id, node.name)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</AppCard>
</div>
<NodeEditorModal
isOpen={isEditorOpen}
node={editingNode}
isSubmitting={saveMutation.isPending}
title={editingNode ? '编辑节点' : '新增节点'}
onClose={handleReset}
description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
submitLabel={editingNode ? '保存修改' : '新增节点'}
onSubmit={(payload) => {
setFeedback(null);
saveMutation.mutate(payload);
}}
/>
</>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,246 @@
import type { ReleaseChannel } from '@/features/update/types';
export interface NodeItem {
id: number;
node_id: string;
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
name: string;
ip: string;
ip_manual_override: boolean;
relay_bind_port: number;
relay_vhost_http_port: number;
relay_client_access_addr: string;
relay_agent_access_addr: string;
relay_client_proxy_url: string;
relay_auth_token: string;
relay_status: string;
relay_web_server_enabled: boolean;
relay_frps_connections: number;
relay_frps_proxy_count: number;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
access_token: string;
auto_update_enabled: boolean;
update_requested: boolean;
update_channel: ReleaseChannel;
update_tag: string;
restart_openresty_requested: boolean;
version: string;
ext_version: string;
openresty_status: 'healthy' | 'unhealthy' | 'unknown';
openresty_message: string;
status: 'online' | 'offline' | 'pending';
current_version: string;
last_seen_at: string;
last_error: string;
latest_apply_result: 'success' | 'warning' | 'failed' | '';
latest_apply_message: string;
latest_apply_checksum: string;
latest_main_config_checksum: string;
latest_route_config_checksum: string;
latest_support_file_count: number;
latest_apply_at?: string | null;
created_at: string;
updated_at: string;
}
export interface NodeBootstrapToken {
discovery_token: string;
}
export interface NodeMutationPayload {
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
name: string;
ip: string;
ip_manual_override: boolean;
relay_bind_port?: number;
relay_vhost_http_port?: number;
relay_client_access_addr?: string;
relay_agent_access_addr?: string;
relay_client_proxy_url?: string;
relay_web_server_enabled?: boolean;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;
geo_longitude?: number | null;
geo_manual_override: boolean;
}
export interface NodeAgentReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
current_version: string;
has_update: boolean;
channel: ReleaseChannel;
prerelease: boolean;
update_requested: boolean;
requested_channel: ReleaseChannel;
requested_tag: string;
}
export interface NodeAgentUpdatePayload {
channel?: ReleaseChannel;
tag_name?: string;
}
export interface NodeSystemProfile {
hostname: string;
os_name: string;
os_version: string;
kernel_version: string;
architecture: string;
cpu_model: string;
cpu_cores: number;
total_memory_bytes: number;
total_disk_bytes: number;
uptime_seconds: number;
reported_at: string;
}
export interface NodeMetricSnapshot {
captured_at: string;
cpu_usage_percent: number;
memory_used_bytes: number;
memory_total_bytes: number;
storage_used_bytes: number;
storage_total_bytes: number;
disk_read_bytes: number;
disk_write_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
openresty_connections: number;
}
export interface NodeTrafficReport {
window_started_at: string;
window_ended_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
status_codes_json: string;
top_domains_json: string;
source_countries_json: string;
}
export interface NodeTrafficTrendPoint {
bucket_started_at: string;
request_count: number;
error_count: number;
unique_visitor_count: number;
}
export interface NodeCapacityTrendPoint {
bucket_started_at: string;
average_cpu_usage_percent: number;
average_memory_usage_percent: number;
reported_nodes: number;
}
export interface NodeNetworkTrendPoint {
bucket_started_at: string;
network_rx_bytes: number;
network_tx_bytes: number;
openresty_rx_bytes: number;
openresty_tx_bytes: number;
reported_nodes: number;
}
export interface NodeDiskIOTrendPoint {
bucket_started_at: string;
disk_read_bytes: number;
disk_write_bytes: number;
reported_nodes: number;
}
export interface NodeDistributionItem {
key: string;
value: number;
}
export interface NodeTrafficDistributions {
status_codes: NodeDistributionItem[];
top_domains: NodeDistributionItem[];
source_countries: NodeDistributionItem[];
}
export interface NodeTrafficSummary {
window_started_at: string;
window_ended_at: string;
request_count: number;
unique_visitor_count: number;
error_count: number;
estimated_qps: number;
error_rate_percent: number;
}
export interface NodeHealthSummary {
active_alerts: number;
critical_alerts: number;
warning_alerts: number;
info_alerts: number;
resolved_alerts: number;
has_capacity_risk: boolean;
has_traffic_risk: boolean;
has_runtime_risk: boolean;
}
export interface NodeObservabilityAnalytics {
traffic: NodeTrafficSummary;
distributions: NodeTrafficDistributions;
health: NodeHealthSummary;
}
export interface NodeObservabilityTrends {
traffic_24h: NodeTrafficTrendPoint[];
capacity_24h: NodeCapacityTrendPoint[];
network_24h: NodeNetworkTrendPoint[];
disk_io_24h: NodeDiskIOTrendPoint[];
}
export interface NodeHealthEvent {
event_type: string;
severity: string;
status: string;
message: string;
metadata_json?: string;
first_triggered_at: string;
last_triggered_at: string;
reported_at: string;
resolved_at?: string | null;
}
export interface RelayProxyStat {
name: string;
type: string;
status: 'online' | 'offline';
client_version: string;
last_start_time: string;
last_close_time: string;
client_addr: string;
}
export interface RelayDashboardSnapshot {
total_proxies: number;
online_proxies: number;
offline_proxies: number;
proxies: RelayProxyStat[];
total_connections: number;
client_counts: number;
}
export interface NodeObservability {
node_id: string;
profile: NodeSystemProfile | null;
metric_snapshots: NodeMetricSnapshot[];
traffic_reports: NodeTrafficReport[];
health_events: NodeHealthEvent[];
analytics: NodeObservabilityAnalytics;
trends: NodeObservabilityTrends;
relay_dashboard?: RelayDashboardSnapshot | null;
}
@@ -0,0 +1,301 @@
import type { NodeItem } from '@/features/nodes/types';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export const FLARED_WS_CONNECTED_LAST_SEEN =
'__OPENFLARE_FLARED_WS_CONNECTED__';
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) {
return (
Boolean(value) &&
!isWSConnectedLastSeen(value) &&
!String(value).startsWith('0001-01-01')
);
}
export function getNodeStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
export function getNodeStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
export function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'warning') {
return 'warning';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'warning') {
return '警告';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
if (node.update_channel === 'preview') {
return { label: '等待预览更新', variant: 'warning' as const };
}
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动', variant: 'success' as const };
}
return { label: '手动', variant: 'info' as const };
}
export function getOpenrestyStatusVariant(
status: NodeItem['openresty_status'],
) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
export function isNodeAbnormal(node: NodeItem, activeVersion?: string) {
if (node.status === 'offline') {
return true;
}
if (activeVersion && node.current_version !== activeVersion) {
return true;
}
if (node.node_type === 'tunnel_relay') {
return node.relay_status === 'unhealthy';
}
if (node.node_type === 'edge_node') {
return node.openresty_status === 'unhealthy';
}
return false;
}
function parseVersionParts(version: string) {
const normalized = version.trim().replace(/^v/i, '');
if (!normalized || normalized.toLowerCase() === 'unknown') {
return null;
}
return normalized.split('.').map((segment) => {
const matched = segment.trim().match(/^\d+/);
return matched ? Number.parseInt(matched[0], 10) : 0;
});
}
function isOlderVersion(current: string, target: string) {
const currentParts = parseVersionParts(current);
const targetParts = parseVersionParts(target);
if (!currentParts || !targetParts) {
return false;
}
const maxLength = Math.max(currentParts.length, targetParts.length);
for (let index = 0; index < maxLength; index += 1) {
const currentPart = currentParts[index] ?? 0;
const targetPart = targetParts[index] ?? 0;
if (currentPart < targetPart) {
return true;
}
if (currentPart > targetPart) {
return false;
}
}
return false;
}
export function shouldShowManualUpdate(
agentVersion: string,
serverVersion: string,
) {
const normalizedServerVersion = serverVersion.trim();
const normalizedAgentVersion = agentVersion.trim();
if (
!normalizedServerVersion ||
normalizedServerVersion.toLowerCase() === 'dev' ||
!normalizedAgentVersion ||
normalizedAgentVersion.toLowerCase() === 'unknown'
) {
return false;
}
return isOlderVersion(normalizedAgentVersion, normalizedServerVersion);
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
export function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
export function buildNodeDockerInstallCommand(
serverUrl: string,
agentToken: string,
) {
const image = 'ghcr.io/rain-kl/openflare-agent:latest';
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 \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
` ${image}`,
].join('\n');
}
export function getRelayStatusVariant(status: string | null | undefined) {
if (status === 'healthy') {
return 'success';
}
if (status === 'unhealthy') {
return 'danger';
}
return 'warning';
}
export function getRelayStatusLabel(status: string | null | undefined) {
if (status === 'healthy') {
return '健康';
}
if (status === 'unhealthy') {
return '异常';
}
return '未知';
}
const relayInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh';
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,
) {
const image = 'ghcr.io/rain-kl/openflare-relay:latest';
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,
) {
const flaredInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh';
return [
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --tunnel-token ${tunnelToken}`,
].join('\n');
}
export function buildTunnelDockerInstallCommand(
serverUrl: string,
tunnelToken: string,
) {
const image = 'ghcr.io/rain-kl/openflared:latest';
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');
}
@@ -0,0 +1,31 @@
import {apiRequest} from '@/lib/api/client';
import type {OriginDetail, OriginItem, OriginMutationPayload,} from '@/features/origins/types';
export function getOrigins() {
return apiRequest<OriginItem[]>('/origins/');
}
export function getOrigin(id: number) {
return apiRequest<OriginDetail>(`/origins/${id}`);
}
export function createOrigin(payload: OriginMutationPayload) {
return apiRequest<OriginItem>('/origins/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateOrigin(id: number, payload: OriginMutationPayload) {
return apiRequest<OriginItem>(`/origins/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteOrigin(id: number) {
return apiRequest<void>(`/origins/${id}/delete`, {
method: 'POST',
});
}
@@ -0,0 +1,221 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge';
import {deleteOrigin, getOrigin} from '@/features/origins/api/origins';
import {OriginEditorModal} from '@/features/origins/components/origin-editor-modal';
import {DangerButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
import {formatDateTime} from '@/lib/utils/date';
export function OriginDetailPage({ originId }: { originId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{
tone: 'success' | 'danger';
message: string;
} | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const originQuery = useQuery({
queryKey: ['origins', originId],
queryFn: () => getOrigin(Number(originId)),
enabled: originId !== '',
});
const deleteMutation = useMutation({
mutationFn: deleteOrigin,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['origins'] });
router.push('/origin');
},
onError: (error) => {
setFeedback({
tone: 'danger',
message:
error instanceof Error ? error.message : '请求失败,请稍后重试。',
});
},
});
if (originQuery.isLoading) {
return <LoadingState />;
}
if (originQuery.isError) {
return (
<ErrorState
title="源站详情加载失败"
description={
originQuery.error instanceof Error
? originQuery.error.message
: '请求失败,请稍后重试。'
}
/>
);
}
const origin = originQuery.data;
if (!origin) {
return (
<EmptyState
title="源站不存在"
description="该源站可能已被删除,或当前 ID 无法匹配到源站记录。"
/>
);
}
const handleDelete = () => {
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
return;
}
deleteMutation.mutate(origin.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title={origin.name}
description="源站详情"
action={
<>
<Link
href="/origin"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回
</Link>
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(true)}
>
编辑源站
</SecondaryButton>
<DangerButton
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
删除源站
</DangerButton>
</>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="源站地址">
<p className="text-sm text-[var(--foreground-primary)]">
{origin.address}
</p>
</AppCard>
<AppCard title="绑定规则">
<div className="space-y-3">
<StatusBadge
label={`${origin.route_count} 条规则`}
variant={origin.route_count > 0 ? 'success' : 'warning'}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
编辑地址后,绑定规则的主源站地址会一起更新。
</p>
</div>
</AppCard>
<AppCard title="创建时间">
<p className="text-sm text-[var(--foreground-secondary)]">
{formatDateTime(origin.created_at)}
</p>
</AppCard>
<AppCard title="更新时间">
<p className="text-sm text-[var(--foreground-secondary)]">
{formatDateTime(origin.updated_at)}
</p>
</AppCard>
</div>
<AppCard title="备注">
<p className="text-sm text-[var(--foreground-secondary)]">
{origin.remark || '暂无备注'}
</p>
</AppCard>
<AppCard
title="关联规则"
description="展示当前源站作为主源站绑定的规则。"
>
{origin.routes.length === 0 ? (
<EmptyState
title="暂无关联规则"
description="当前源站还没有被任何规则引用。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">域名</th>
<th className="px-3 py-3 font-medium">源站地址</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">更新时间</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{origin.routes.map((route) => (
<tr key={route.id}>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{route.domain}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{route.origin_url}
</td>
<td className="px-3 py-4">
<StatusBadge
label={route.enabled ? '启用' : '停用'}
variant={route.enabled ? 'success' : 'warning'}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(route.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
{isEditorOpen ? (
<OriginEditorModal
isOpen={isEditorOpen}
origin={origin}
onClose={() => setIsEditorOpen(false)}
onSaved={() => {
setFeedback({ tone: 'success', message: '源站已更新。' });
void queryClient.invalidateQueries({
queryKey: ['origins', origin.id],
});
void queryClient.invalidateQueries({ queryKey: ['origins'] });
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,168 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useEffect} from 'react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {AppModal} from '@/components/ui/app-modal';
import {createOrigin, updateOrigin} from '@/features/origins/api/origins';
import type {OriginItem, OriginMutationPayload,} from '@/features/origins/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const originSchema = z.object({
name: z.string().max(255, '源站名不能超过 255 个字符'),
address: z
.string()
.trim()
.min(1, '请输入源站地址')
.refine(
(value) => !/[/?#]/.test(value) && !value.includes('://'),
'源站地址格式不合法',
),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
type OriginFormValues = z.infer<typeof originSchema>;
function toPayload(values: OriginFormValues): OriginMutationPayload {
return {
name: values.name.trim(),
address: values.address.trim(),
remark: values.remark.trim(),
};
}
function toFormValues(origin?: OriginItem | null): OriginFormValues {
if (!origin) {
return {
name: '',
address: '',
remark: '',
};
}
return {
name: origin.name,
address: origin.address,
remark: origin.remark || '',
};
}
export function OriginEditorModal({
isOpen,
onClose,
origin,
onSaved,
}: {
isOpen: boolean;
onClose: () => void;
origin?: OriginItem | null;
onSaved?: (origin: OriginItem, mode: 'create' | 'update') => void;
}) {
const queryClient = useQueryClient();
const form = useForm<OriginFormValues>({
resolver: zodResolver(originSchema),
defaultValues: toFormValues(origin),
});
useEffect(() => {
form.reset(toFormValues(origin));
}, [form, origin, isOpen]);
const mutation = useMutation({
mutationFn: async (values: OriginFormValues) => {
const payload = toPayload(values);
return origin ? updateOrigin(origin.id, payload) : createOrigin(payload);
},
onSuccess: async (savedOrigin) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['origins'] }),
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
]);
onSaved?.(savedOrigin, origin ? 'update' : 'create');
onClose();
},
});
const handleSubmit = form.handleSubmit((values) => {
mutation.mutate(values);
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={origin ? '编辑源站' : '新增源站'}
description="源站会作为规则里的可复用地址目录,协议和端口仍由规则决定。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="origin-editor-form"
disabled={mutation.isPending}
>
{mutation.isPending
? '保存中...'
: origin
? '保存修改'
: '新增源站'}
</PrimaryButton>
</div>
}
>
<form
id="origin-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="源站地址"
hint="支持 IP、域名或主机名,例如 10.0.0.10、origin.internal。"
error={form.formState.errors.address?.message}
>
<ResourceInput
placeholder="origin.internal"
{...form.register('address')}
/>
</ResourceField>
<ResourceField
label="源站名"
hint="可选,留空时默认使用源站地址。"
error={form.formState.errors.name?.message}
>
<ResourceInput placeholder="主站源站" {...form.register('name')} />
</ResourceField>
</div>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea
placeholder="例如:主站内网入口"
{...form.register('remark')}
/>
</ResourceField>
{mutation.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{mutation.error instanceof Error
? mutation.error.message
: '请求失败,请稍后重试。'}
</p>
) : null}
</form>
</AppModal>
);
}
@@ -0,0 +1,192 @@
'use client';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge';
import {deleteOrigin, getOrigins} from '@/features/origins/api/origins';
import {OriginEditorModal} from '@/features/origins/components/origin-editor-modal';
import type {OriginItem} from '@/features/origins/types';
import {DangerButton, PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
import {formatDateTime} from '@/lib/utils/date';
type FeedbackState = {
tone: 'success' | 'danger';
message: string;
};
export function OriginsPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const originsQuery = useQuery({
queryKey: ['origins'],
queryFn: getOrigins,
});
const deleteMutation = useMutation({
mutationFn: deleteOrigin,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '源站已删除。' });
await queryClient.invalidateQueries({ queryKey: ['origins'] });
},
onError: (error) => {
setFeedback({
tone: 'danger',
message:
error instanceof Error ? error.message : '请求失败,请稍后重试。',
});
},
});
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
const handleDelete = (origin: OriginItem) => {
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
return;
}
setFeedback(null);
deleteMutation.mutate(origin.id);
};
return (
<>
<div className="space-y-6">
<PageHeader
title="源站"
description="集中维护规则复用的源站地址,减少批量改地址时的重复操作。"
action={
<PrimaryButton
type="button"
onClick={() => {
setEditingOrigin(null);
setFeedback(null);
setIsEditorOpen(true);
}}
>
新增源站
</PrimaryButton>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard
title="源站列表"
description="编辑源站地址后,所有引用该源站的规则会同步更新。"
>
{originsQuery.isLoading ? (
<LoadingState />
) : originsQuery.isError ? (
<ErrorState
title="源站列表加载失败"
description={
originsQuery.error instanceof Error
? originsQuery.error.message
: '请求失败,请稍后重试。'
}
/>
) : origins.length === 0 ? (
<EmptyState
title="暂无源站"
description="点击右上角“新增源站”开始录入。后续规则可直接复用这些地址。"
/>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{origins.map((origin) => (
<article
key={origin.id}
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{origin.name}
</h2>
<StatusBadge
label={`${origin.route_count} 条规则`}
variant={
origin.route_count > 0 ? 'success' : 'warning'
}
/>
</div>
<p className="text-sm text-[var(--foreground-primary)]">
{origin.address}
</p>
<p className="text-sm text-[var(--foreground-secondary)]">
{origin.remark || '暂无备注'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
最后更新:{formatDateTime(origin.updated_at)}
</div>
</div>
<div className="flex flex-row gap-2">
<Link
href={`/origin/detail?id=${origin.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
详情
</Link>
<SecondaryButton
type="button"
onClick={() => {
setEditingOrigin(origin);
setFeedback(null);
setIsEditorOpen(true);
}}
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(origin)}
disabled={deleteMutation.isPending}
>
删除
</DangerButton>
</div>
</div>
</article>
))}
</div>
)}
</AppCard>
</div>
{isEditorOpen ? (
<OriginEditorModal
isOpen={isEditorOpen}
origin={editingOrigin}
onClose={() => setIsEditorOpen(false)}
onSaved={(origin, mode) => {
setFeedback({
tone: 'success',
message: mode === 'create' ? '源站已创建。' : '源站已更新。',
});
setEditingOrigin(origin);
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,27 @@
export interface OriginItem {
id: number;
name: string;
address: string;
remark: string;
route_count: number;
created_at: string;
updated_at: string;
}
export interface OriginRouteSummary {
id: number;
domain: string;
origin_url: string;
enabled: boolean;
updated_at: string;
}
export interface OriginDetail extends OriginItem {
routes: OriginRouteSummary[];
}
export interface OriginMutationPayload {
name: string;
address: string;
remark: string;
}
@@ -0,0 +1,123 @@
import {ApiError, apiRequest, getApiUrl} from '@/lib/api/client';
import type {ApiEnvelope} from '@/types/api';
import type {PagesDeployment, PagesProject, PagesProjectPayload,} from '@/features/pages/types';
export function getPagesProjects() {
return apiRequest<PagesProject[]>('/pages/');
}
export function getPagesProject(id: number) {
return apiRequest<PagesProject>(`/pages/${id}`);
}
export function createPagesProject(payload: PagesProjectPayload) {
return apiRequest<PagesProject>('/pages/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updatePagesProject(id: number, payload: PagesProjectPayload) {
return apiRequest<PagesProject>(`/pages/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deletePagesProject(id: number) {
return apiRequest<void>(`/pages/${id}/delete`, {
method: 'POST',
});
}
export function getPagesDeployments(projectId: number) {
return apiRequest<PagesDeployment[]>(`/pages/${projectId}/deployments`);
}
export function uploadPagesDeployment(
projectId: number,
file: File,
rootDir = '',
entryFile = 'index.html',
onProgress?: (percent: number) => void,
) {
if (!onProgress) {
const formData = new FormData();
formData.append('package', file);
formData.append('root_dir', rootDir);
formData.append('entry_file', entryFile);
return apiRequest<PagesDeployment>(
`/pages/${projectId}/deployments/upload`,
{
method: 'POST',
body: formData,
},
);
}
return new Promise<PagesDeployment>((resolve, reject) => {
const formData = new FormData();
formData.append('package', file);
formData.append('root_dir', rootDir);
formData.append('entry_file', entryFile);
const xhr = new XMLHttpRequest();
xhr.open('POST', getApiUrl(`/pages/${projectId}/deployments/upload`));
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
onProgress(percent);
}
});
xhr.onload = () => {
let payload: ApiEnvelope<PagesDeployment> | null = null;
try {
payload = JSON.parse(xhr.responseText) as ApiEnvelope<PagesDeployment>;
} catch {
payload = null;
}
if (xhr.status >= 200 && xhr.status < 300) {
if (payload && payload.success) {
resolve(payload.data);
} else {
reject(new ApiError(payload?.message || '请求失败', xhr.status));
}
} else {
reject(
new ApiError(
payload?.message || `请求失败(${xhr.status})`,
xhr.status,
),
);
}
};
xhr.onerror = () => {
reject(new ApiError('网络请求失败', 0));
};
xhr.send(formData);
});
}
export function activatePagesDeployment(
projectId: number,
deploymentId: number,
) {
return apiRequest<PagesProject>(
`/pages/${projectId}/deployments/${deploymentId}/activate`,
{ method: 'POST' },
);
}
export function deletePagesDeployment(projectId: number, deploymentId: number) {
return apiRequest<void>(
`/pages/${projectId}/deployments/${deploymentId}/delete`,
{ method: 'POST' },
);
}
@@ -0,0 +1,278 @@
'use client';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useRef, useState} from 'react';
import {File as FileIcon, UploadCloud, X} from 'lucide-react';
import {AppModal} from '@/components/ui/app-modal';
import {activatePagesDeployment, uploadPagesDeployment,} from '@/features/pages/api/pages';
import {PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
import {cn} from '@/lib/utils/cn';
import {deploymentsQueryKey, projectQueryKey, projectsQueryKey,} from '../utils';
interface PagesDeploymentUploadModalProps {
isOpen: boolean;
onClose: () => void;
projectId: number;
}
function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function PagesDeploymentUploadModal({
isOpen,
onClose,
projectId,
}: PagesDeploymentUploadModalProps) {
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [isDragActive, setIsDragActive] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetForm = () => {
setFile(null);
setIsDragActive(false);
setUploadProgress(null);
setErrorMessage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleClose = () => {
resetForm();
onClose();
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setIsDragActive(true);
} else if (e.type === 'dragleave') {
setIsDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const droppedFile = e.dataTransfer.files[0];
if (droppedFile.name.toLowerCase().endsWith('.zip')) {
setFile(droppedFile);
setErrorMessage(null);
} else {
setErrorMessage('仅支持 zip 格式的文件');
}
}
};
const uploadMutation = useMutation({
mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => {
if (!file) {
throw new Error('请选择 zip 文件');
}
setUploadProgress(0);
setErrorMessage(null);
const deployment = await uploadPagesDeployment(
projectId,
file,
'',
'index.html',
(percent) => {
setUploadProgress(percent);
},
);
if (shouldActivate) {
await activatePagesDeployment(projectId, deployment.id);
}
return deployment;
},
onSuccess: () => {
resetForm();
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId),
});
queryClient.invalidateQueries({
queryKey: projectQueryKey(projectId),
});
queryClient.invalidateQueries({
queryKey: projectsQueryKey,
});
onClose();
},
onError: (error) => {
setUploadProgress(null);
setErrorMessage(error instanceof Error ? error.message : '上传失败');
},
});
const handleUploadOnly = () => {
uploadMutation.mutate({ shouldActivate: false });
};
const handleUploadAndDeploy = () => {
uploadMutation.mutate({ shouldActivate: true });
};
return (
<AppModal
isOpen={isOpen}
onClose={handleClose}
title="上传部署包"
description="上传已构建的 zip 静态资源包。项目将使用配置的根目录与入口文件路径。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleClose}
disabled={uploadMutation.isPending}
>
取消
</SecondaryButton>
<SecondaryButton
type="button"
disabled={!file || uploadMutation.isPending}
onClick={handleUploadOnly}
>
{uploadMutation.isPending &&
!uploadMutation.variables?.shouldActivate
? `上传中 (${uploadProgress ?? 0}%)...`
: '上传'}
</SecondaryButton>
<PrimaryButton
type="button"
disabled={!file || uploadMutation.isPending}
onClick={handleUploadAndDeploy}
>
{uploadMutation.isPending &&
uploadMutation.variables?.shouldActivate
? `上传并部署中 (${uploadProgress ?? 0}%)...`
: '上传并部署'}
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
{/* Hidden File Input */}
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip"
onChange={(event) => {
const selectedFile = event.target.files?.[0] ?? null;
if (selectedFile) {
if (selectedFile.name.toLowerCase().endsWith('.zip')) {
setFile(selectedFile);
setErrorMessage(null);
} else {
setErrorMessage('仅支持 zip 格式的文件');
}
}
}}
className="hidden"
/>
<div className="block space-y-2">
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<span>部署包</span>
</span>
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
'group relative flex min-h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[var(--border-default)] bg-[var(--surface-elevated)] p-6 text-center transition-all duration-200 hover:border-[var(--brand-primary)] hover:bg-[var(--surface-hover)]',
isDragActive &&
'scale-[0.99] border-[var(--brand-primary)] bg-[var(--accent-soft)]/20 shadow-inner',
file && 'border-solid border-[var(--brand-primary)]',
)}
>
{file ? (
<div
className="flex flex-col items-center space-y-3"
onClick={(e) => e.stopPropagation()}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--brand-primary)] transition duration-200 group-hover:scale-110">
<FileIcon className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="max-w-[300px] truncate text-sm font-medium text-[var(--foreground-primary)]">
{file.name}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
大小: {formatBytes(file.size)}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-xs font-medium text-[var(--foreground-primary)] transition hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)]"
>
<X className="h-3 w-3" />
清除文件
</button>
</div>
</div>
) : (
<div className="pointer-events-none flex flex-col items-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--surface-muted)] text-[var(--foreground-muted)] transition duration-200 group-hover:scale-110 group-hover:bg-[var(--accent-soft)] group-hover:text-[var(--brand-primary)]">
<UploadCloud className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
点击或拖拽 zip 部署包到此处
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件
</p>
</div>
</div>
)}
</div>
{errorMessage && (
<span className="mt-1 block text-xs text-[var(--status-danger-foreground)]">
{errorMessage}
</span>
)}
</div>
{uploadProgress !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
</AppModal>
);
}
@@ -0,0 +1,243 @@
'use client';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {
activatePagesDeployment,
deletePagesDeployment,
deletePagesProject,
getPagesDeployments,
getPagesProject,
} from '@/features/pages/api/pages';
import {DangerButton, PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives';
import {PagesProjectEditorModal} from './pages-project-editor-modal';
import {PagesDeploymentUploadModal} from './pages-deployment-upload-modal';
import {formatBytes} from '@/lib/utils/metrics';
import {deploymentsQueryKey, projectQueryKey, projectsQueryKey,} from '../utils';
export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const [isEditModalOpen, setEditModalOpen] = useState(false);
const [isUploadModalOpen, setUploadModalOpen] = useState(false);
const parsedProjectId = Number(projectId);
const projectQuery = useQuery({
queryKey: projectQueryKey(projectId),
queryFn: () => getPagesProject(parsedProjectId),
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
});
const deploymentsQuery = useQuery({
queryKey: deploymentsQueryKey(parsedProjectId),
queryFn: () => getPagesDeployments(parsedProjectId),
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
});
const activateMutation = useMutation({
mutationFn: (deploymentId: number) =>
activatePagesDeployment(parsedProjectId, deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
});
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
const deleteDeploymentMutation = useMutation({
mutationFn: (deploymentId: number) =>
deletePagesDeployment(parsedProjectId, deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
});
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
const deleteProjectMutation = useMutation({
mutationFn: () => deletePagesProject(parsedProjectId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
router.push('/pages');
},
});
if (projectId === '' || !Number.isFinite(parsedProjectId)) {
return (
<EmptyState
title="Pages 项目不存在"
description="缺少有效的 Pages 项目 ID,请从项目列表重新进入。"
/>
);
}
if (projectQuery.isLoading) {
return <LoadingState />;
}
if (projectQuery.isError) {
return (
<ErrorState
title="Pages 项目加载失败"
description={projectQuery.error.message}
/>
);
}
const project = projectQuery.data;
if (!project) {
return (
<EmptyState
title="Pages 项目不存在"
description="该项目可能已被删除,或当前 ID 无法匹配到项目记录。"
/>
);
}
const handleDeleteProject = () => {
if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
return;
}
deleteProjectMutation.mutate();
};
return (
<div className="space-y-6">
<PageHeader
title={project.name}
description={`${project.slug} · Pages 静态站点项目详情`}
action={
<>
<Link
href="/pages"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回列表
</Link>
<SecondaryButton
type="button"
onClick={() => setEditModalOpen(true)}
>
编辑项目
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => setUploadModalOpen(true)}
>
上传部署包
</PrimaryButton>
<DangerButton
type="button"
disabled={deleteProjectMutation.isPending}
onClick={handleDeleteProject}
>
删除项目
</DangerButton>
</>
}
/>
<AppCard
title="部署历史"
description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
>
{deploymentsQuery.isLoading ? (
<p className="text-sm text-[var(--foreground-secondary)]">
加载中...
</p>
) : deploymentsQuery.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{deploymentsQuery.error.message}
</p>
) : (deploymentsQuery.data ?? []).length === 0 ? (
<EmptyState
title="暂无部署"
description="上传 zip 部署包后,可以在这里激活某个部署版本。"
>
</EmptyState>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
{(deploymentsQuery.data ?? []).map((deployment) => (
<div
key={deployment.id}
className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between"
>
<div>
<p className="text-sm font-medium text-[var(--foreground-primary)]">
#{deployment.deployment_number}{' '}
{deployment.status === 'active' ? '· 已激活' : ''}
</p>
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
{deployment.checksum.slice(0, 16)} · {deployment.file_count}{' '}
files · {formatBytes(deployment.total_size)}
</p>
</div>
<div className="flex gap-2">
<SecondaryButton
type="button"
disabled={
deployment.status === 'active' ||
activateMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认激活部署 #${deployment.deployment_number} 吗?`,
)
) {
activateMutation.mutate(deployment.id);
}
}}
>
激活
</SecondaryButton>
<DangerButton
type="button"
disabled={
deployment.status === 'active' ||
deleteDeploymentMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认删除部署 #${deployment.deployment_number} 吗?`,
)
) {
deleteDeploymentMutation.mutate(deployment.id);
}
}}
>
删除
</DangerButton>
</div>
</div>
))}
</div>
)}
</AppCard>
<PagesProjectEditorModal
isOpen={isEditModalOpen}
onClose={() => setEditModalOpen(false)}
project={project}
/>
<PagesDeploymentUploadModal
isOpen={isUploadModalOpen}
onClose={() => setUploadModalOpen(false)}
projectId={parsedProjectId}
/>
</div>
);
}
@@ -0,0 +1,75 @@
'use client';
import {useQuery} from '@tanstack/react-query';
import {useState} from 'react';
import {AppCard} from '@/components/ui/app-card';
import {getPagesProjects} from '@/features/pages/api/pages';
import {PrimaryButton,} from '@/features/shared/components/resource-primitives';
import {PagesProjectEditorModal} from './pages-project-editor-modal';
import {PagesProjectListItem} from './pages-project-list-item';
import {projectsQueryKey} from '../utils';
export { PagesProjectDetailPage } from './pages-detail-page';
export function PagesPage() {
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
const projectsQuery = useQuery({
queryKey: projectsQueryKey,
queryFn: getPagesProjects,
});
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-[var(--foreground-secondary)]">
OpenFlare Pages
</p>
<h1 className="text-2xl font-semibold text-[var(--foreground-primary)]">
边缘静态站点托管
</h1>
<p className="max-w-3xl text-sm leading-6 text-[var(--foreground-secondary)]">
创建 Pages 项目,上传已构建 of zip 静态资源包,然后在规则中选择
Pages 项目作为上游。发布后 Agent
会拉取部署包并在边缘节点本地服务静态文件。
</p>
</div>
<PrimaryButton
type="button"
className="w-full lg:w-auto"
onClick={() => setCreateModalOpen(true)}
>
新建 Pages 项目
</PrimaryButton>
</div>
<div className="space-y-4">
{projectsQuery.isLoading ? (
<AppCard>正在加载 Pages 项目...</AppCard>
) : projectsQuery.error ? (
<AppCard>
<p className="text-sm text-[var(--status-danger-foreground)]">
{projectsQuery.error.message}
</p>
</AppCard>
) : (projectsQuery.data ?? []).length === 0 ? (
<AppCard
title="还没有 Pages 项目"
description="先创建一个项目,再上传静态资源包。"
/>
) : (
(projectsQuery.data ?? []).map((project) => (
<PagesProjectListItem key={project.id} project={project} />
))
)}
</div>
<PagesProjectEditorModal
isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,289 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useEffect} from 'react';
import {useForm} from 'react-hook-form';
import {AppModal} from '@/components/ui/app-modal';
import {createPagesProject, updatePagesProject,} from '@/features/pages/api/pages';
import type {PagesProject} from '@/features/pages/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import {projectQueryKey, projectsQueryKey} from '../utils';
import {type PagesProjectFormValues, pagesProjectSchema} from '../schemas';
interface PagesProjectEditorModalProps {
isOpen: boolean;
onClose: () => void;
project?: PagesProject | null;
onSaved?: (project: PagesProject, mode: 'create' | 'update') => void;
}
function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
if (!project) {
return {
name: '',
slug: '',
description: '',
spa_fallback_enabled: false,
spa_fallback_path: '/index.html',
api_proxy_enabled: false,
api_proxy_path: '',
api_proxy_pass: '',
api_proxy_rewrite: '',
root_dir: '',
entry_file: 'index.html',
};
}
return {
name: project.name,
slug: project.slug,
description: project.description || '',
spa_fallback_enabled: project.spa_fallback_enabled,
spa_fallback_path: project.spa_fallback_path,
api_proxy_enabled: project.api_proxy_enabled || false,
api_proxy_path: project.api_proxy_path || '',
api_proxy_pass: project.api_proxy_pass || '',
api_proxy_rewrite: project.api_proxy_rewrite || '',
root_dir: project.root_dir || '',
entry_file: project.entry_file || 'index.html',
};
}
export function PagesProjectEditorModal({
isOpen,
onClose,
project,
onSaved,
}: PagesProjectEditorModalProps) {
const queryClient = useQueryClient();
const form = useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema),
defaultValues: toFormValues(project),
});
useEffect(() => {
if (isOpen) {
form.reset(toFormValues(project));
}
}, [form, project, isOpen]);
const mutation = useMutation({
mutationFn: (values: PagesProjectFormValues) => {
const payload = {
name: values.name.trim(),
slug: values.slug?.trim() || '',
description: values.description?.trim() || '',
enabled: project ? project.enabled : true,
spa_fallback_enabled: values.spa_fallback_enabled,
spa_fallback_path: values.spa_fallback_enabled
? values.spa_fallback_path.trim()
: project
? project.spa_fallback_path
: '/index.html',
api_proxy_enabled: values.api_proxy_enabled,
api_proxy_path: values.api_proxy_enabled
? values.api_proxy_path.trim()
: '',
api_proxy_pass: values.api_proxy_enabled
? values.api_proxy_pass.trim()
: '',
api_proxy_rewrite: values.api_proxy_enabled
? values.api_proxy_rewrite.trim()
: '',
root_dir: values.root_dir?.trim() || '',
entry_file: values.entry_file?.trim() || 'index.html',
};
return project
? updatePagesProject(project.id, payload)
: createPagesProject(payload);
},
onSuccess: (savedProject) => {
onClose();
if (project) {
queryClient.invalidateQueries({
queryKey: projectQueryKey(project.id),
});
}
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
onSaved?.(savedProject, project ? 'update' : 'create');
},
});
const handleSubmit = form.handleSubmit((values) => {
mutation.mutate(values);
});
const spaFallbackEnabled = form.watch('spa_fallback_enabled');
const apiProxyEnabled = form.watch('api_proxy_enabled');
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={project ? '编辑 Pages 项目' : '新建 Pages 项目'}
description={
project
? '修改静态站点项目的基础配置与高级反代规则。'
: '配置静态站点项目的基础信息与高级反代规则。创建后再上传已构建的 zip 部署包。'
}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="pages-project-editor-form"
disabled={mutation.isPending || !form.formState.isValid}
>
{mutation.isPending
? '保存中...'
: project
? '保存修改'
: '创建项目'}
</PrimaryButton>
</div>
}
>
<form
id="pages-project-editor-form"
className="grid gap-4 md:grid-cols-2"
onSubmit={handleSubmit}
>
<ResourceField
label="项目名称"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="Marketing Site"
{...form.register('name')}
required
/>
</ResourceField>
<ResourceField
label="项目标识"
hint="留空时会按名称自动生成。"
error={form.formState.errors.slug?.message}
>
<ResourceInput
placeholder="marketing-site"
{...form.register('slug')}
/>
</ResourceField>
<ResourceField
label="描述"
className="md:col-span-2"
error={form.formState.errors.description?.message}
>
<ResourceInput
placeholder="这个项目托管的静态站点用途"
{...form.register('description')}
/>
</ResourceField>
<ResourceField
label="根目录 (可选)"
hint="静态资源的根文件夹路径 (例如 build 或 dist),若为空则默认为 zip 包根目录。"
error={form.formState.errors.root_dir?.message}
>
<ResourceInput
placeholder="例如: build"
{...form.register('root_dir')}
/>
</ResourceField>
<ResourceField
label="入口文件"
hint="基于根目录下的入口文件路径。"
error={form.formState.errors.entry_file?.message}
>
<ResourceInput
placeholder="index.html"
{...form.register('entry_file')}
required
/>
</ResourceField>
<ToggleField
label="启用 SPA fallback"
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
checked={spaFallbackEnabled}
onChange={(checked) =>
form.setValue('spa_fallback_enabled', checked, {
shouldValidate: true,
})
}
/>
<ResourceField
label="SPA 回退路径"
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
error={form.formState.errors.spa_fallback_path?.message}
>
<ResourceInput
placeholder="/index.html"
disabled={!spaFallbackEnabled}
{...form.register('spa_fallback_path')}
/>
</ResourceField>
<ToggleField
label="启用 API 反向代理"
description="允许为该静态站点配置反代后端(例如反代指定 API 路径至您的后端服务)。"
checked={apiProxyEnabled}
onChange={(checked) =>
form.setValue('api_proxy_enabled', checked, {
shouldValidate: true,
})
}
/>
{apiProxyEnabled && (
<>
<ResourceField
label="反代匹配路径"
hint="以 / 开头,例如 /api 或 /api/v1。匹配该前缀的请求将被转发。"
error={form.formState.errors.api_proxy_path?.message}
>
<ResourceInput
placeholder="/api"
{...form.register('api_proxy_path')}
required
/>
</ResourceField>
<ResourceField
label="后端服务地址"
hint="包含协议和主机的完整 URL,例如 http://127.0.0.1:8080。"
error={form.formState.errors.api_proxy_pass?.message}
>
<ResourceInput
placeholder="http://127.0.0.1:8080"
{...form.register('api_proxy_pass')}
required
/>
</ResourceField>
<ResourceField
label="路径重写目标"
hint="可选。如果配置为 /,请求 /api/users 将被重写转发至后端 /users 路径。"
error={form.formState.errors.api_proxy_rewrite?.message}
>
<ResourceInput
placeholder="/"
{...form.register('api_proxy_rewrite')}
/>
</ResourceField>
</>
)}
{mutation.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
{mutation.error instanceof Error
? mutation.error.message
: '操作失败,请稍后重试。'}
</p>
) : null}
</form>
</AppModal>
);
}
@@ -0,0 +1,71 @@
'use client';
import Link from 'next/link';
import {StatusBadge} from '@/components/ui/status-badge';
import type {PagesProject} from '@/features/pages/types';
import {formatDateTime} from '@/lib/utils/date';
export function PagesProjectListItem({ project }: { project: PagesProject }) {
return (
<Link
href={`/pages/detail?id=${project.id}`}
className="group block rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] p-5 shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:shadow-[var(--shadow-soft)]"
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{project.name}
</h2>
<StatusBadge
label={project.enabled ? '已启用' : '已停用'}
variant={project.enabled ? 'success' : 'warning'}
/>
<StatusBadge
label={project.spa_fallback_enabled ? 'SPA fallback' : '严格 404'}
variant={project.spa_fallback_enabled ? 'info' : 'warning'}
/>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{project.slug}
</p>
{project.description ? (
<p className="line-clamp-2 text-sm leading-6 text-[var(--foreground-secondary)]">
{project.description}
</p>
) : null}
{project.spa_fallback_enabled ? (
<p className="text-xs text-[var(--foreground-secondary)]">
回退路径:{project.spa_fallback_path || '/index.html'}
</p>
) : null}
</div>
<div className="grid shrink-0 grid-cols-1 gap-3 text-sm md:min-w-40">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
<p className="text-xs text-[var(--foreground-secondary)]">
当前激活
</p>
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
{project.active_deployment
? `#${project.active_deployment.deployment_number}`
: '暂无'}
</p>
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-default)] pt-4">
<p className="text-xs text-[var(--foreground-secondary)]">
激活时间:
{project.active_deployment?.activated_at
? formatDateTime(project.active_deployment.activated_at)
: '未激活'}
</p>
<span className="text-sm font-medium text-[var(--brand-primary)] transition group-hover:translate-x-1">
查看详情 →
</span>
</div>
</Link>
);
}
@@ -0,0 +1,88 @@
import {z} from 'zod';
export const pagesProjectSchema = z
.object({
name: z
.string()
.trim()
.min(1, '请输入项目名称')
.max(255, '项目名称不能超过 255 个字符'),
slug: z
.string()
.trim()
.max(255, '项目标识不能超过 255 个字符')
.optional()
.or(z.literal('')),
description: z
.string()
.trim()
.max(1000, '描述不能超过 1000 个字符')
.optional()
.or(z.literal('')),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z
.string()
.trim()
.max(512, '根目录不能超过 512 个字符')
.optional()
.or(z.literal('')),
entry_file: z
.string()
.trim()
.min(1, '请输入入口文件')
.max(512, '入口文件不能超过 512 个字符'),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled) {
if (!data.spa_fallback_path) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'],
message: '启用 SPA fallback 时必须输入回退路径',
});
} else if (!data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'],
message: '回退路径必须以 / 开头,例如 /index.html',
});
}
}
if (data.api_proxy_enabled) {
if (!data.api_proxy_path) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: '启用 API 反向代理时必须输入匹配路径',
});
} else if (!data.api_proxy_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: '匹配路径必须以 / 开头,例如 /api',
});
}
if (!data.api_proxy_pass) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: '启用 API 反向代理时必须输入后端服务地址',
});
} else if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: '后端服务地址必须以 http:// 或 https:// 开头',
});
}
}
});
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
@@ -0,0 +1,50 @@
export interface PagesDeployment {
id: number;
project_id: number;
deployment_number: number;
checksum: string;
status: 'uploaded' | 'active';
file_count: number;
total_size: number;
root_dir?: string;
entry_file: string;
created_by: string;
created_at: string;
activated_at?: string | null;
}
export interface PagesProject {
id: number;
name: string;
slug: string;
description: string;
enabled: boolean;
spa_fallback_enabled: boolean;
spa_fallback_path: string;
api_proxy_enabled: boolean;
api_proxy_path: string;
api_proxy_pass: string;
api_proxy_rewrite: string;
root_dir?: string;
entry_file: string;
active_deployment_id?: number | null;
active_deployment?: PagesDeployment | null;
deployment_count: number;
created_at: string;
updated_at: string;
}
export interface PagesProjectPayload {
name: string;
slug: string;
description: string;
enabled: boolean;
spa_fallback_enabled: boolean;
spa_fallback_path: string;
api_proxy_enabled: boolean;
api_proxy_path: string;
api_proxy_pass: string;
api_proxy_rewrite: string;
root_dir?: string;
entry_file: string;
}
@@ -0,0 +1,9 @@
export const projectsQueryKey = ['pages-projects'];
export function projectQueryKey(projectId: string | number) {
return ['pages-project', String(projectId)];
}
export function deploymentsQueryKey(projectId: string | number) {
return ['pages-deployments', Number(projectId)];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import {apiRequest} from '@/lib/api/client';
import type {
ManagedDomainMatchResult,
ProxyRouteItem,
ProxyRouteMutationPayload,
TlsCertificateItem,
} from '@/features/proxy-routes/types';
export function getProxyRoutes() {
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
}
export function getProxyRoute(id: number) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}`);
}
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateProxyRoute(
id: number,
payload: ProxyRouteMutationPayload,
) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteProxyRoute(id: number) {
return apiRequest<void>(`/proxy-routes/${id}/delete`, {
method: 'POST',
});
}
export function getTlsCertificates() {
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
}
export function matchManagedDomainCertificate(domain: string) {
const searchParams = new URLSearchParams({ domain });
return apiRequest<ManagedDomainMatchResult>(
`/managed-domains/match?${searchParams.toString()}`,
);
}
@@ -0,0 +1,265 @@
'use client';
import {useId, useMemo} from 'react';
import {Minus, Plus} from 'lucide-react';
import type {TlsCertificateItem} from '@/features/tls-certificates/types';
import {ResourceInput, ResourceSelect, SecondaryButton,} from '@/features/shared/components/resource-primitives';
export type DomainListRow = {
domain: string;
certificateId: string;
};
const actionButtonBaseClassName = 'h-11 w-11 shrink-0 rounded-2xl px-0';
const removeButtonClassName =
'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)] disabled:border-[var(--border-default)] disabled:bg-[var(--surface-muted)] disabled:text-[var(--foreground-muted)]';
const addButtonClassName =
'border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-[var(--foreground-secondary)] hover:border-[var(--brand-primary)] hover:bg-[var(--brand-primary-soft)] hover:text-[var(--brand-primary)]';
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
function buildSuggestionSources(domains: string[]) {
const values = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
if (!normalized) {
continue;
}
values.add(normalized);
const segments = normalized.split('.');
for (let index = 1; index < segments.length - 1; index += 1) {
values.add(segments.slice(index).join('.'));
}
}
return Array.from(values);
}
function buildDomainSuggestions(
input: string,
sources: string[],
rows: DomainListRow[],
) {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const existingDomains = new Set(
rows
.map((row) => row.domain.trim().toLowerCase())
.filter((row) => row && row !== normalizedInput),
);
const suggestions: string[] = [];
for (const source of sources) {
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
suggestions.push(source);
}
const separatorIndex = normalizedInput.lastIndexOf('.');
if (separatorIndex <= 0) {
continue;
}
const prefix = normalizedInput.slice(0, separatorIndex);
const suffixInput = normalizedInput.slice(separatorIndex + 1);
if (!suffixInput || source.startsWith(suffixInput)) {
suggestions.push(`${prefix}.${source}`);
}
}
return suggestions.filter((suggestion, index) => {
return (
suggestion !== normalizedInput &&
!existingDomains.has(suggestion) &&
suggestions.indexOf(suggestion) === index
);
});
}
export function buildDomainRowsFromRoute(
domains: string[],
domainCertIDs: number[],
certIDs: number[],
): DomainListRow[] {
if (domains.length === 0) {
return ensureRows([]);
}
if (domainCertIDs.length === domains.length) {
return domains.map((domain, index) => ({
domain,
certificateId: domainCertIDs[index] ? String(domainCertIDs[index]) : '',
}));
}
if (certIDs.length === 0) {
return domains.map((domain) => ({ domain, certificateId: '' }));
}
if (certIDs.length === 1) {
return domains.map((domain) => ({
domain,
certificateId: String(certIDs[0]),
}));
}
return domains.map((domain, index) => ({
domain,
certificateId: certIDs[index] ? String(certIDs[index]) : '',
}));
}
export function DomainListInput({
rows,
onChange,
onBlur,
suggestionSources = [],
certificates = [],
domainPlaceholder = 'app.example.com',
}: {
rows: DomainListRow[];
onChange: (rows: DomainListRow[]) => void;
onBlur?: () => void;
suggestionSources?: string[];
certificates?: TlsCertificateItem[];
domainPlaceholder?: string;
}) {
const listId = useId();
const safeRows = ensureRows(rows);
const normalizedSources = useMemo(
() => buildSuggestionSources(suggestionSources),
[suggestionSources],
);
const updateRows = (nextRows: DomainListRow[]) => {
onChange(ensureRows(nextRows));
};
return (
<div className="space-y-4">
{safeRows.map((row, index) => {
const suggestions = buildDomainSuggestions(
row.domain,
normalizedSources,
safeRows,
).slice(0, 4);
return (
<div key={`${index}-${safeRows.length}`} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[44px_minmax(0,1fr)_280px] md:items-start">
<SecondaryButton
type="button"
aria-label={`删除域名输入框 ${index + 1}`}
className={`${actionButtonBaseClassName} ${removeButtonClassName}`}
disabled={safeRows.length === 1}
onClick={() => {
if (safeRows.length === 1) {
updateRows([{ domain: '', certificateId: '' }]);
return;
}
updateRows(
safeRows.filter((_, rowIndex) => rowIndex !== index),
);
}}
>
<Minus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
<div className="min-w-0 space-y-2">
<ResourceInput
value={row.domain}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={
index === 0 ? domainPlaceholder : 'www.example.com'
}
onBlur={onBlur}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: event.target.value,
};
updateRows(nextRows);
}}
className="h-12"
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="inline-flex items-center rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-1 text-xs text-[var(--foreground-secondary)] transition hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]"
onClick={() => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: suggestion,
};
updateRows(nextRows);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</div>
<ResourceSelect
aria-label={`证书 ${index + 1}`}
value={row.certificateId}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
certificateId: event.target.value,
};
updateRows(nextRows);
}}
className="h-12"
>
<option value="">
{certificates.length === 0 ? '暂无可选证书' : '选择证书'}
</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{certificate.name}
</option>
))}
</ResourceSelect>
</div>
</div>
);
})}
<SecondaryButton
type="button"
aria-label="新增域名输入框"
className={`${actionButtonBaseClassName} ${addButtonClassName}`}
onClick={() => {
updateRows([...safeRows, { domain: '', certificateId: '' }]);
}}
>
<Plus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
</div>
);
}
@@ -0,0 +1,523 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQuery} from '@tanstack/react-query';
import {useEffect, useMemo} from 'react';
import {Controller, useForm} from 'react-hook-form';
import {z} from 'zod';
import {Drawer} from '@/components/ui/drawer';
import {getManagedDomains} from '@/features/managed-domains/api/managed-domains';
import {getPagesProjects} from '@/features/pages/api/pages';
import {createProxyRoute} from '@/features/proxy-routes/api/proxy-routes';
import {DomainListInput, type DomainListRow,} from '@/features/proxy-routes/components/domain-list-input';
import {
buildOriginUrl,
getErrorMessage,
parseOriginUrl,
parseOriginUrls,
validateDomains,
} from '@/features/proxy-routes/helpers';
import {getNodes} from '@/features/nodes/api/nodes';
import type {ProxyRouteItem} from '@/features/proxy-routes/types';
import {getTlsCertificates} from '@/features/tls-certificates/api/tls-certificates';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
ToggleField,
} from '@/features/shared/components/resource-primitives';
const domainRowSchema = z.object({
domain: z.string(),
certificateId: z.string(),
});
const createWebsiteSchema = z
.object({
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
domain_rows: z.array(domainRowSchema).min(1),
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
origin_urls_text: z.string().trim(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
pages_project_id: z.string().optional(),
enabled: z.boolean(),
redirect_http: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
const domains = value.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainError = validateDomains(domains);
if (domainError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domain_rows'],
message: domainError,
});
}
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
} else if (value.upstream_type === 'tunnel') {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
if (!value.tunnel_target_addr) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
} else if (!value.pages_project_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['pages_project_id'],
message: '请选择 Pages 项目',
});
}
const selectedCertificateCount = new Set(
value.domain_rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
).size;
if (value.redirect_http && selectedCertificateCount === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['redirect_http'],
message: '启用 HTTP 跳转前,请先为域名选择证书',
});
}
});
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
const defaultValues: CreateWebsiteFormValues = {
site_name: '',
domain_rows: [{ domain: '', certificateId: '' }],
upstream_type: 'direct',
origin_urls_text: '',
tunnel_id: '',
tunnel_target_addr: '',
tunnel_target_protocol: 'http',
pages_project_id: '',
enabled: true,
redirect_http: false,
remark: '',
};
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
return Array.from(
new Set(
rows
.filter((item) => item.domain.trim() !== '')
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
),
);
}
function buildDomainCertificateIDs(rows: DomainListRow[]) {
return rows
.filter((item) => item.domain.trim() !== '')
.map((item) => {
const certificateID = Number(item.certificateId);
return Number.isFinite(certificateID) && certificateID > 0
? certificateID
: 0;
});
}
export function ProxyRouteCreateDrawer({
open,
onOpenChange,
onCreated,
domainSuggestionSources = [],
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (route: ProxyRouteItem) => void;
domainSuggestionSources?: string[];
}) {
const form = useForm<CreateWebsiteFormValues>({
resolver: zodResolver(createWebsiteSchema),
defaultValues,
});
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
enabled: open,
});
const certificatesQuery = useQuery({
queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates,
enabled: open,
});
const tunnelsQuery = useQuery({
queryKey: ['nodes'],
queryFn: getNodes,
enabled: open,
});
const pagesProjectsQuery = useQuery({
queryKey: ['pages-projects'],
queryFn: getPagesProjects,
enabled: open,
});
const tunnelClients = (tunnelsQuery.data ?? []).filter(
(node) => node.node_type === 'tunnel_client',
);
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
(project) => project.enabled && project.active_deployment_id,
);
const combinedDomainSuggestions = useMemo(
() => [
...domainSuggestionSources,
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[domainSuggestionSources, managedDomainsQuery.data],
);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
form.watch('domain_rows'),
);
const createMutation = useMutation({
mutationFn: async (values: CreateWebsiteFormValues) => {
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const selectedCertIDs = normalizeSelectedCertificateIDs(
values.domain_rows,
);
let originUrl = '';
const originHost = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = buildOriginUrl(
primaryOrigin.scheme,
primaryOrigin.address,
primaryOrigin.port,
primaryOrigin.uri,
);
upstreams = urls.slice(1);
} else if (values.upstream_type === 'tunnel') {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
} else {
originUrl = 'http://127.0.0.1';
}
return createProxyRoute({
site_name: values.site_name.trim() || domains[0],
domain: domains[0],
domains,
origin_id: null,
origin_url: originUrl,
origin_scheme:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).scheme
: 'http',
origin_address:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).address
: values.tunnel_target_addr || '127.0.0.1',
origin_port:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).port
: '80',
origin_uri:
values.upstream_type === 'direct'
? parseOriginUrl(originUrl).uri
: '',
origin_host: originHost,
upstreams,
enabled: values.enabled,
enable_https: selectedCertIDs.length > 0,
cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null,
cert_ids: selectedCertIDs,
domain_cert_ids: domainCertIDs,
redirect_http:
selectedCertIDs.length > 0 ? values.redirect_http : false,
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_policy: 'url',
cache_rules: [],
custom_headers: [],
pow_enabled: false,
pow_config: '{}',
basic_auth_enabled: false,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_node_id:
values.upstream_type === 'tunnel' && values.tunnel_id
? Number(values.tunnel_id)
: null,
tunnel_target_addr:
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol:
values.upstream_type === 'tunnel'
? values.tunnel_target_protocol
: '',
pages_project_id:
values.upstream_type === 'pages' && values.pages_project_id
? Number(values.pages_project_id)
: null,
});
},
onSuccess: (route) => {
form.reset(defaultValues);
onOpenChange(false);
onCreated(route);
},
});
useEffect(() => {
if (!open) {
form.reset(defaultValues);
}
}, [form, open]);
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
direction="right"
title="新建规则"
footer={
<div className="flex items-center justify-end gap-3">
<PrimaryButton
type="submit"
form="create-website-form"
disabled={createMutation.isPending}
>
{createMutation.isPending ? '创建中...' : '创建'}
</PrimaryButton>
</div>
}
>
<form
id="create-website-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => createMutation.mutate(values))}
>
<ResourceField
label="站点标识"
hint="可选,留空时会自动使用第一个域名。"
error={form.formState.errors.site_name?.message}
>
<ResourceInput
{...form.register('site_name')}
placeholder="marketing-site"
/>
</ResourceField>
<ResourceField
label="域名列表"
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
error={
form.formState.errors.domain_rows?.message as string | undefined
}
container="div"
>
<Controller
control={form.control}
name="domain_rows"
render={({ field }) => (
<DomainListInput
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={combinedDomainSuggestions}
certificates={certificatesQuery.data ?? []}
/>
)}
/>
</ResourceField>
<ToggleField
label="HTTP 自动跳转到 HTTPS"
description={
selectedCertificateIDs.length > 0
? '勾选后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'
}
checked={form.watch('redirect_http')}
disabled={selectedCertificateIDs.length === 0}
onChange={(checked) =>
form.setValue('redirect_http', checked, { shouldDirty: true })
}
/>
<div className="space-y-3">
<label className="block text-sm font-medium text-[var(--foreground-primary)]">
回源方式
</label>
<div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
<input
type="radio"
value="direct"
{...form.register('upstream_type')}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
直连上游
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
<input
type="radio"
value="tunnel"
{...form.register('upstream_type')}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
内网穿透 (Tunnel)
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-[var(--foreground-primary)]">
<input
type="radio"
value="pages"
{...form.register('upstream_type')}
className="h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Pages 静态站点
</label>
</div>
</div>
{form.watch('upstream_type') === 'direct' ? (
<ResourceField
label="上游地址"
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')}
/>
</ResourceField>
) : form.watch('upstream_type') === 'tunnel' ? (
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
<ResourceField
label="选择内网穿透隧道"
hint="将请求转发到该隧道连接的客户端节点。"
error={form.formState.errors.tunnel_id?.message}
>
<select
{...form.register('tunnel_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{tunnelClients.map((tunnel) => (
<option key={tunnel.id} value={tunnel.id}>
{tunnel.name} (
{tunnel.status === 'online' ? '在线' : '离线'})
</option>
))}
</select>
</ResourceField>
<ResourceField
label="内网服务协议"
hint="转发到内网服务时使用的协议。"
error={form.formState.errors.tunnel_target_protocol?.message}
>
<select
{...form.register('tunnel_target_protocol')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</ResourceField>
<ResourceField
label="内网服务地址"
hint="例如: 127.0.0.1:8080 或 192.168.1.10:80"
error={form.formState.errors.tunnel_target_addr?.message}
>
<ResourceInput
placeholder="127.0.0.1:8080"
{...form.register('tunnel_target_addr')}
/>
</ResourceField>
</div>
) : (
<div className="space-y-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
<ResourceField
label="选择 Pages 项目"
hint="仅显示已启用且已有激活部署的 Pages 项目。"
error={form.formState.errors.pages_project_id?.message}
>
<select
{...form.register('pages_project_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] transition outline-none focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{pagesProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.slug})
</option>
))}
</select>
</ResourceField>
</div>
)}
<ToggleField
label="创建后立即启用"
description="关闭后站点会以草稿保存,后续仍可继续编辑。"
checked={form.watch('enabled')}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea {...form.register('remark')} />
</ResourceField>
{createMutation.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{getErrorMessage(createMutation.error)}
</p>
) : null}
</form>
</Drawer>
);
}
@@ -0,0 +1,404 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge';
import {getConfigVersionDiff, publishConfigVersion,} from '@/features/config-versions/api/config-versions';
import {ProxyRouteCreateDrawer} from '@/features/proxy-routes/components/proxy-route-create-drawer';
import {getErrorMessage, getUpstreamSummary, getWebsiteStatusBadges,} from '@/features/proxy-routes/helpers';
import {deleteProxyRoute, getProxyRoutes,} from '@/features/proxy-routes/api/proxy-routes';
import type {ProxyRouteItem} from '@/features/proxy-routes/types';
import {
DangerButton,
PrimaryButton,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
function hasConfigChanges(diff: {
active_version?: string;
added_sites: string[];
removed_sites: string[];
modified_sites: string[];
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
waf_config_changed?: boolean;
changed_option_keys: string[];
}) {
return (
diff.added_sites.length > 0 ||
diff.removed_sites.length > 0 ||
diff.modified_sites.length > 0 ||
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
diff.main_config_changed ||
Boolean(diff.waf_config_changed) ||
diff.changed_option_keys.length > 0 ||
!diff.active_version
);
}
export function ProxyRoutesPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [keyword, setKeyword] = useState('');
const [isCreateOpen, setIsCreateOpen] = useState(false);
const routesQuery = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
});
const diffQuery = useQuery({
queryKey: ['config-versions', 'diff'],
queryFn: getConfigVersionDiff,
});
const deleteMutation = useMutation({
mutationFn: deleteProxyRoute,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '网站已删除。' });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const publishMutation = useMutation({
mutationFn: publishConfigVersion,
onSuccess: async (version) => {
setFeedback({
tone: 'success',
message: `配置已发布,版本号 ${version.version}。`,
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
const filteredRoutes = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return routes;
}
return routes.filter((route) => {
const haystack = [
route.site_name,
route.primary_domain,
...route.domains,
...route.upstream_list,
route.remark,
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedKeyword);
});
}, [keyword, routes]);
const diff = diffQuery.data;
const totalDomains = routes.reduce(
(sum, route) => sum + route.domain_count,
0,
);
const enabledCount = routes.filter((route) => route.enabled).length;
const handleDelete = (route: ProxyRouteItem) => {
if (
!window.confirm(
`确认删除网站 ${route.site_name} 吗?\n这会删除该站点下的全部域名与配置。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate(route.id);
};
const handlePublish = () => {
if (!diff || !hasConfigChanges(diff)) {
setFeedback({ tone: 'info', message: '当前草稿没有可发布的变更。' });
return;
}
const summary = [
`新增网站 ${diff.added_sites.length} 个`,
`删除网站 ${diff.removed_sites.length} 个`,
`修改网站 ${diff.modified_sites.length} 个`,
`域名变更 ${diff.added_domains.length + diff.removed_domains.length + diff.modified_domains.length} 项`,
].join(',');
if (!window.confirm(`确认发布当前配置吗?\n${summary}`)) {
return;
}
setFeedback(null);
publishMutation.mutate(false);
};
if (routesQuery.isLoading) {
return <LoadingState />;
}
if (routesQuery.isError) {
return (
<ErrorState
title="规则列表加载失败"
description={getErrorMessage(routesQuery.error)}
/>
);
}
return (
<>
<div className="space-y-6">
<PageHeader
title="规则配置"
action={
<div className="flex flex-wrap gap-3">
<SecondaryButton
type="button"
onClick={handlePublish}
disabled={publishMutation.isPending || diffQuery.isLoading}
>
{publishMutation.isPending ? '发布中...' : '发布配置'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setFeedback(null);
setIsCreateOpen(true);
}}
>
新建规则
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="网站数">
<div className="space-y-2">
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
{routes.length}
</p>
<p className="text-sm text-[var(--foreground-secondary)]">
已启用 {enabledCount} 个
</p>
</div>
</AppCard>
<AppCard title="域名数">
<div className="space-y-2">
<p className="text-3xl font-semibold text-[var(--foreground-primary)]">
{totalDomains}
</p>
<p className="text-sm text-[var(--foreground-secondary)]">
每个网站第一行域名为主域名
</p>
</div>
</AppCard>
<AppCard title="草稿状态">
<div className="space-y-3">
<StatusBadge
label={
diff && hasConfigChanges(diff)
? '有待发布变更'
: '已与线上一致'
}
variant={diff && hasConfigChanges(diff) ? 'warning' : 'success'}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{diff
? `网站变更 ${diff.added_sites.length + diff.removed_sites.length + diff.modified_sites.length} 项`
: '正在读取 diff...'}
</p>
</div>
</AppCard>
<AppCard title="发布摘要">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>新增网站:{diff?.added_sites.length ?? 0}</p>
<p>删除网站:{diff?.removed_sites.length ?? 0}</p>
<p>修改网站:{diff?.modified_sites.length ?? 0}</p>
</div>
</AppCard>
</div>
<AppCard title="规则列表">
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="max-w-xl">
<ResourceInput
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索站点"
/>
</div>
{diff && hasConfigChanges(diff) ? (
<div className="flex flex-wrap gap-2">
{diff.modified_sites.length > 0 ? (
<StatusBadge
label={`修改网站 ${diff.modified_sites.length}`}
variant="warning"
/>
) : null}
{diff.added_sites.length > 0 ? (
<StatusBadge
label={`新增网站 ${diff.added_sites.length}`}
variant="success"
/>
) : null}
{diff.removed_sites.length > 0 ? (
<StatusBadge
label={`删除网站 ${diff.removed_sites.length}`}
variant="danger"
/>
) : null}
</div>
) : null}
</div>
{filteredRoutes.length === 0 ? (
<EmptyState
title={routes.length === 0 ? '暂无网站' : '没有匹配结果'}
description={
routes.length === 0
? '先创建一个网站,再进入配置子页面继续补齐 HTTPS、缓存和限流。'
: '试试调整搜索词,或者清空筛选条件。'
}
/>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{filteredRoutes.map((route) => (
<article
key={route.id}
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<div className="flex flex-col gap-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-3">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
{route.site_name}
</h2>
{getWebsiteStatusBadges(route).map((badge) => (
<StatusBadge
key={`${route.id}-${badge.label}`}
label={badge.label}
variant={badge.variant}
/>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={`/proxy-route/detail?id=${route.id}&section=domains`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
配置
</Link>
<DangerButton
type="button"
onClick={() => handleDelete(route)}
disabled={deleteMutation.isPending}
>
删除
</DangerButton>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
域名列表
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{route.domains.join(' / ')}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3">
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
上游摘要
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{getUpstreamSummary(route)}
</p>
</div>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{route.remark || null}
</p>
</div>
</article>
))}
</div>
)}
</div>
</AppCard>
</div>
<ProxyRouteCreateDrawer
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
domainSuggestionSources={routes.flatMap((route) => route.domains)}
onCreated={async (route) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
queryClient.invalidateQueries({
queryKey: ['config-versions', 'diff'],
}),
]);
router.push(`/proxy-route/detail?id=${route.id}&section=domains`);
}}
/>
</>
);
}
@@ -0,0 +1,337 @@
import type {ProxyRouteCustomHeader, ProxyRouteItem, ProxyRouteMutationPayload,} from '@/features/proxy-routes/types';
export const websiteConfigSections = [
{
key: 'domains',
label: '域名设置',
description: '维护站点标识、域名列表和证书绑定。',
},
{
key: 'limits',
label: '流量限制',
description: '设置连接数和限速。',
},
{
key: 'proxy',
label: '反向代理',
description: '配置主回源和上游地址。',
},
{
key: 'cache',
label: '缓存',
description: '配置站点缓存策略。',
},
{
key: 'waf',
label: 'WAF',
description: '绑定 WAF 规则组,并查看当前站点生效策略。',
},
{
key: 'auth',
label: '认证配置',
description: '配置基础鉴权访问,需要输入账号密码才能访问网站。',
},
] as const;
export type WebsiteConfigSectionKey =
(typeof websiteConfigSections)[number]['key'];
const domainPattern =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const originHostPattern =
/^(?:(?:[a-z0-9-]+\.)*[a-z0-9-]+|\[[0-9a-f:.]+\]|[0-9.]+)(?::\d{1,5})?$/i;
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function getWebsiteConfigSection(
value: string | null | undefined,
): WebsiteConfigSectionKey {
return websiteConfigSections.some((section) => section.key === value)
? (value as WebsiteConfigSectionKey)
: 'domains';
}
export function linesFromTextarea(value: string) {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function validateDomains(domains: string[]) {
if (domains.length === 0) {
return '请至少填写一个域名';
}
const seen = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase();
if (!domainPattern.test(normalized)) {
return `域名格式不合法:${domain}`;
}
if (seen.has(normalized)) {
return `域名重复:${domain}`;
}
seen.add(normalized);
}
return null;
}
export function parseOriginUrls(value: string) {
const urls = linesFromTextarea(value);
if (urls.length === 0) {
return { urls: [], error: '请至少填写一个上游地址' };
}
let sharedScheme = '';
for (const originUrl of urls) {
let parsed: URL;
try {
parsed = new URL(originUrl);
} catch {
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
urls: [],
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
};
}
if (!parsed.hostname) {
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
}
if (urls.length > 1) {
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
return {
urls: [],
error: '多上游模式暂不支持带路径或查询参数的地址',
};
}
if (!sharedScheme) {
sharedScheme = parsed.protocol;
} else if (sharedScheme !== parsed.protocol) {
return {
urls: [],
error: '同一站点的多个上游必须使用相同协议',
};
}
}
}
return { urls, error: null };
}
export function parseOriginUrl(originUrl: string) {
const parsed = new URL(originUrl);
const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
const path = parsed.pathname === '/' ? '' : parsed.pathname;
return {
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
address: parsed.hostname,
port,
uri: parsed.search ? `${path}${parsed.search}` || parsed.search : path,
};
}
export function buildOriginUrl(
scheme: 'http' | 'https',
address: string,
port: string,
uri: string,
) {
const normalizedAddress = address.trim();
const normalizedPort = port.trim();
const normalizedURI = uri.trim();
if (!normalizedAddress || !normalizedPort) {
return '';
}
const host =
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
? `[${normalizedAddress}]`
: normalizedAddress;
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
}
export function validateOriginHost(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
if (
normalized.includes('://') ||
/[\/\\\s]/.test(normalized) ||
!originHostPattern.test(normalized)
) {
return '回源 Host 格式不合法';
}
return null;
}
export function parseCustomHeadersText(value: string) {
const lines = linesFromTextarea(value);
const headers: ProxyRouteCustomHeader[] = [];
for (const line of lines) {
const separatorIndex = line.indexOf(':');
if (separatorIndex <= 0) {
return {
headers: [],
error: `自定义请求头格式不合法:${line}`,
};
}
const key = line.slice(0, separatorIndex).trim();
const headerValue = line.slice(separatorIndex + 1).trim();
if (!headerKeyPattern.test(key)) {
return {
headers: [],
error: `自定义请求头名称不合法:${key}`,
};
}
headers.push({ key, value: headerValue });
}
return { headers, error: null };
}
export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
}
export function validateLimitRate(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m 或纯数字';
}
return null;
}
export function normalizeLimitRate(value: string) {
const normalized = value.trim().toLowerCase();
return normalized === '0' ? '' : normalized;
}
export function validateCacheRules(
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
) {
if (policy === 'url') {
return null;
}
if (rules.length === 0) {
return '当前缓存策略至少需要一条规则';
}
if (policy === 'suffix') {
for (const rule of rules) {
const normalized = rule.replace(/^\./, '');
if (!normalized || /[\/\\\s]/.test(normalized)) {
return `缓存后缀格式不合法:${rule}`;
}
}
return null;
}
for (const rule of rules) {
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
return `缓存路径规则格式不合法:${rule}`;
}
}
return null;
}
export function buildPayloadFromRoute(
route: ProxyRouteItem,
overrides: Partial<ProxyRouteMutationPayload>,
): ProxyRouteMutationPayload {
const primaryOrigin =
route.upstream_type === 'pages'
? parseOriginUrl('http://127.0.0.1')
: parseOriginUrl(route.origin_url);
return {
site_name: route.site_name,
domain: route.primary_domain,
domains: route.domains,
origin_id: null,
origin_url: route.origin_url,
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address,
origin_port: primaryOrigin.port,
origin_uri: primaryOrigin.uri,
origin_host: route.origin_host || '',
upstreams: route.upstream_list.slice(1),
enabled: route.enabled,
enable_https: route.enable_https,
cert_id: route.cert_id,
cert_ids: route.cert_ids,
domain_cert_ids: route.domain_cert_ids,
redirect_http: route.redirect_http,
limit_conn_per_server: route.limit_conn_per_server,
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
cache_enabled: route.cache_enabled,
cache_policy: route.cache_policy || 'url',
cache_rules: route.cache_rule_list,
custom_headers: route.custom_header_list,
remark: route.remark || '',
pow_enabled: route.pow_enabled,
pow_config: JSON.stringify(route.pow_config),
basic_auth_enabled: route.basic_auth_enabled,
basic_auth_username: route.basic_auth_username,
basic_auth_password: route.basic_auth_password,
upstream_type: route.upstream_type,
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: route.tunnel_target_protocol || '',
pages_project_id: route.pages_project_id ?? null,
...overrides,
};
}
export function getUpstreamSummary(route: ProxyRouteItem) {
if (route.upstream_type === 'pages') {
return route.pages_project_id
? `Pages 项目 #${route.pages_project_id}`
: 'Pages 项目未绑定';
}
if (route.upstream_type === 'tunnel') {
const protocol = route.tunnel_target_protocol || 'http';
const target = route.tunnel_target_addr || '未配置目标';
return `Tunnel → ${protocol}://${target}`;
}
if (route.upstream_list.length <= 1) {
return route.origin_url;
}
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
}
export function getWebsiteStatusBadges(route: ProxyRouteItem) {
return [
route.enabled
? { label: '已启用', variant: 'success' as const }
: { label: '已停用', variant: 'warning' as const },
route.enable_https
? { label: 'HTTPS', variant: 'info' as const }
: { label: 'HTTP', variant: 'warning' as const },
];
}
@@ -0,0 +1,124 @@
export interface ProxyRouteCustomHeader {
key: string;
value: string;
}
export interface ProxyRoutePoWListConfig {
ips: string[];
ip_cidrs: string[];
paths: string[];
path_regexes: string[];
user_agents: string[];
}
export interface ProxyRoutePoWConfig {
difficulty: number;
algorithm: 'fast' | 'slow';
session_ttl: number;
challenge_ttl: number;
whitelist: ProxyRoutePoWListConfig;
blacklist: ProxyRoutePoWListConfig;
}
export interface ProxyRouteItem {
id: number;
site_name: string;
domain: string;
domains: string[];
primary_domain: string;
domain_count: number;
origin_id: number | null;
origin_url: string;
origin_host: string;
upstreams: string;
upstream_list: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids: number[];
domain_cert_ids: number[];
redirect_http: boolean;
limit_conn_per_server: number;
limit_conn_per_ip: number;
limit_rate: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string;
cache_rule_list: string[];
custom_headers: string;
custom_header_list: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
basic_auth_enabled: boolean;
basic_auth_username: string;
basic_auth_password: string;
remark: string;
upstream_type: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
created_at: string;
updated_at: string;
}
export interface ProxyRouteMutationPayload {
site_name?: string;
domain: string;
domains?: string[];
origin_id: number | null;
origin_url: string;
origin_scheme: 'http' | 'https';
origin_address: string;
origin_port: string;
origin_uri: string;
origin_host: string;
upstreams: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids?: number[];
domain_cert_ids?: number[];
redirect_http: boolean;
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
limit_rate?: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string[];
custom_headers: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: string;
basic_auth_enabled: boolean;
basic_auth_username?: string;
basic_auth_password?: string;
remark: string;
upstream_type?: 'direct' | 'tunnel' | 'pages';
tunnel_node_id?: number | null;
tunnel_id?: number | null;
tunnel_target_addr?: string;
tunnel_target_protocol?: string;
pages_project_id?: number | null;
}
export interface TlsCertificateItem {
id: number;
name: string;
not_after?: string | null;
}
export interface ManagedDomainMatchCandidate {
managed_domain_id: number;
domain: string;
match_type: 'exact' | 'wildcard';
certificate_id: number;
certificate_name: string;
}
export interface ManagedDomainMatchResult {
domain: string;
matched: boolean;
candidate?: ManagedDomainMatchCandidate;
candidates: ManagedDomainMatchCandidate[];
}
@@ -0,0 +1,73 @@
import type { ManagedDomainItem } from '@/features/managed-domains/types';
export function isWildcardManagedDomain(domain: string) {
return domain.startsWith('*.');
}
export function buildRouteDomain(
managedDomain: string | undefined,
subdomainLabel: string,
) {
if (!managedDomain) {
return '';
}
if (!isWildcardManagedDomain(managedDomain)) {
return managedDomain.toLowerCase();
}
const normalizedLabel = subdomainLabel.trim().toLowerCase();
if (!normalizedLabel) {
return '';
}
return `${normalizedLabel}.${managedDomain.slice(2).toLowerCase()}`;
}
export function findManagedDomainForRoute(
routeDomain: string,
managedDomains: ManagedDomainItem[],
) {
const normalizedRouteDomain = routeDomain.trim().toLowerCase();
const exactMatch = managedDomains.find(
(item) => item.domain.toLowerCase() === normalizedRouteDomain,
);
if (exactMatch) {
return {
managedDomainId: exactMatch.domain,
subdomainLabel: '',
};
}
const wildcardMatch = managedDomains.find((item) => {
if (!isWildcardManagedDomain(item.domain)) {
return false;
}
const suffix = item.domain.slice(2).toLowerCase();
const suffixWithDot = `.${suffix}`;
if (!normalizedRouteDomain.endsWith(suffixWithDot)) {
return false;
}
const label = normalizedRouteDomain.slice(
0,
normalizedRouteDomain.length - suffixWithDot.length,
);
return Boolean(label) && !label.includes('.');
});
if (!wildcardMatch) {
return null;
}
return {
managedDomainId: wildcardMatch.domain,
subdomainLabel: normalizedRouteDomain.slice(
0,
normalizedRouteDomain.length - wildcardMatch.domain.slice(1).length,
),
};
}
@@ -0,0 +1,138 @@
import {apiRequest} from '@/lib/api/client';
import {setStoredOpenFlareToken} from '@/lib/api/auth-token';
import type {
AuthSource,
AuthSourcePayload,
BootstrapTokenPayload,
DatabaseCleanupPayload,
DatabaseCleanupResult,
ExternalAccountBinding,
GeoIPLookupResult,
OptionBatchPayload,
OptionItem,
SettingsProfile,
UpdateSelfPayload,
} from '@/features/settings/types';
export function getOptions() {
return apiRequest<OptionItem[]>('/option/');
}
export function updateOption(key: string, value: string) {
return apiRequest<void>('/option/update', {
method: 'POST',
body: JSON.stringify({ key, value }),
});
}
export function updateOptions(options: OptionBatchPayload['options']) {
return apiRequest<void>('/option/update-batch', {
method: 'POST',
body: JSON.stringify({ options }),
});
}
export function lookupGeoIP(provider: string, ip: string) {
return apiRequest<GeoIPLookupResult>('/option/geoip/lookup', {
method: 'POST',
body: JSON.stringify({ provider, ip }),
});
}
export function cleanupDatabaseObservability(payload: DatabaseCleanupPayload) {
return apiRequest<DatabaseCleanupResult>('/option/database/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function getAuthSources() {
return apiRequest<AuthSource[]>('/auth-sources/');
}
export function createAuthSource(payload: AuthSourcePayload) {
return apiRequest<AuthSource>('/auth-sources/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateAuthSource(id: number, payload: AuthSourcePayload) {
return apiRequest<AuthSource>(`/auth-sources/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function toggleAuthSource(id: number, isActive: boolean) {
return apiRequest<void>(`/auth-sources/${id}/toggle`, {
method: 'POST',
body: JSON.stringify({ is_active: isActive }),
});
}
export function deleteAuthSource(id: number) {
return apiRequest<void>(`/auth-sources/${id}/delete`, {
method: 'POST',
});
}
export function getExternalAccountBindings() {
return apiRequest<ExternalAccountBinding[]>('/oauth/external-accounts/');
}
export function deleteExternalAccountBinding(id: number) {
return apiRequest<void>(`/oauth/external-accounts/${id}/delete`, {
method: 'POST',
});
}
export function getBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
}
export function rotateBootstrapToken() {
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token/rotate', {
method: 'POST',
});
}
export function getSettingsProfile() {
return apiRequest<SettingsProfile>('/user/self');
}
export function updateSelf(payload: UpdateSelfPayload) {
return apiRequest<void>('/user/self/update', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function generateAccessToken() {
return apiRequest<string>('/user/token').then((token) => {
setStoredOpenFlareToken(token);
return token;
});
}
export function bindWeChat(code: string) {
return apiRequest<void>(
`/oauth/wechat/bind?code=${encodeURIComponent(code)}`,
);
}
export function bindEmail(email: string, code: string) {
const searchParams = new URLSearchParams({ email, code });
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
}
export function getAboutContent() {
return apiRequest<string>('/about');
}
export function syncUptimeKuma() {
return apiRequest<void>('/uptimekuma/sync', {
method: 'POST',
});
}
@@ -0,0 +1,447 @@
'use client';
import { useEffect, useState } from 'react';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import {
createAuthSource,
deleteAuthSource,
toggleAuthSource,
updateAuthSource,
} from '@/features/settings/api/settings';
import type {
AuthSource,
AuthSourcePayload,
AuthSourceType,
} from '@/features/settings/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const emptyForm: AuthSourcePayload = {
name: '',
type: 'github',
display_name: '',
is_active: false,
client_id: '',
client_secret: '',
openid_discovery_url: '',
scopes: 'user:email',
icon_url: '',
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function sourceToForm(source: AuthSource): AuthSourcePayload {
return {
id: source.id,
name: source.name,
type: source.type,
display_name: source.display_name,
is_active: source.is_active,
client_id: source.client_id,
client_secret: '',
openid_discovery_url: source.openid_discovery_url,
scopes:
source.scopes ||
(source.type === 'oidc' ? 'openid profile email' : 'user:email'),
icon_url: source.icon_url,
};
}
function buildCallbackURL(origin: string, sourceName: string) {
const normalizedName = sourceName.trim() || '认证源名称';
return `${origin || '当前访问地址'}/oauth/${encodeURIComponent(normalizedName)}`;
}
export function AuthSourceModal({
isOpen,
sources,
isLoading,
error,
onClose,
onChanged,
}: {
isOpen: boolean;
sources: AuthSource[];
isLoading: boolean;
error: unknown;
onClose: () => void;
onChanged: () => Promise<void>;
}) {
const [mode, setMode] = useState<'list' | 'edit'>('list');
const [editingSource, setEditingSource] = useState<AuthSource | null>(null);
const [form, setForm] = useState<AuthSourcePayload>(emptyForm);
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState<{
tone: 'success' | 'danger' | 'info';
text: string;
} | null>(null);
const [browserOrigin, setBrowserOrigin] = useState('');
useEffect(() => {
if (!isOpen) {
setMode('list');
setEditingSource(null);
setForm(emptyForm);
setBusyKey(null);
setMessage(null);
}
}, [isOpen]);
useEffect(() => {
if (typeof window !== 'undefined') {
setBrowserOrigin(window.location.origin);
}
}, []);
const startCreate = () => {
setEditingSource(null);
setForm(emptyForm);
setMessage(null);
setMode('edit');
};
const startEdit = (source: AuthSource) => {
setEditingSource(source);
setForm(sourceToForm(source));
setMessage(null);
setMode('edit');
};
const updateType = (type: AuthSourceType) => {
setForm((previous) => ({
...previous,
type,
scopes:
previous.scopes === 'user:email' ||
previous.scopes === 'openid profile email' ||
!previous.scopes
? type === 'oidc'
? 'openid profile email'
: 'user:email'
: previous.scopes,
}));
};
const runAction = async (key: string, action: () => Promise<void>) => {
setBusyKey(key);
setMessage(null);
try {
await action();
} catch (actionError) {
setMessage({ tone: 'danger', text: getErrorMessage(actionError) });
} finally {
setBusyKey(null);
}
};
const saveForm = () => {
void runAction('save', async () => {
const payload: AuthSourcePayload = {
...form,
name: form.name.trim(),
display_name: form.display_name.trim(),
client_id: form.client_id.trim(),
client_secret: form.client_secret.trim(),
openid_discovery_url: form.openid_discovery_url.trim(),
scopes: form.scopes.trim(),
icon_url: form.icon_url.trim(),
};
if (editingSource) {
await updateAuthSource(editingSource.id, payload);
} else {
await createAuthSource(payload);
}
await onChanged();
setMessage({ tone: 'success', text: '认证源已保存。' });
setMode('list');
setEditingSource(null);
setForm(emptyForm);
});
};
const removeSource = (source: AuthSource) => {
if (
!window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
)
) {
return;
}
void runAction(`delete-${source.id}`, async () => {
await deleteAuthSource(source.id);
await onChanged();
setMessage({ tone: 'success', text: '认证源已删除。' });
});
};
const toggleSource = (source: AuthSource) => {
void runAction(`toggle-${source.id}`, async () => {
await toggleAuthSource(source.id, !source.is_active);
await onChanged();
setMessage({ tone: 'success', text: '认证源状态已更新。' });
});
};
return (
<AppModal
isOpen={isOpen}
title="认证源"
description="配置 GitHub 或标准 OIDC 登录入口。启用后会显示在登录页。"
size="xl"
onClose={onClose}
footer={
mode === 'edit' ? (
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={() => setMode('list')}>
返回列表
</SecondaryButton>
<PrimaryButton
type="button"
onClick={saveForm}
disabled={busyKey === 'save'}
>
{busyKey === 'save' ? '保存中...' : '保存认证源'}
</PrimaryButton>
</div>
) : (
<div className="flex justify-end">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
</div>
)
}
>
<div className="space-y-5">
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
{mode === 'list' ? (
<div className="space-y-4">
<div className="flex justify-end">
<PrimaryButton type="button" onClick={startCreate}>
新增认证源
</PrimaryButton>
</div>
{isLoading ? <LoadingState /> : null}
{error ? (
<ErrorState
title="认证源加载失败"
description={getErrorMessage(error)}
/>
) : null}
{!isLoading && !error && sources.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
暂无认证源。
</div>
) : null}
{!isLoading && !error && sources.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
<tr>
<th className="px-4 py-3 font-medium">名称</th>
<th className="px-4 py-3 font-medium">类型</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">Client ID</th>
<th className="px-4 py-3 text-right font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{sources.map((source) => (
<tr key={source.id}>
<td className="px-4 py-3">
<div className="font-medium text-[var(--foreground-primary)]">
{source.display_name || source.name}
</div>
<div className="text-xs text-[var(--foreground-secondary)]">
{source.name}
</div>
</td>
<td className="px-4 py-3 uppercase">{source.type}</td>
<td className="px-4 py-3">
{source.is_active ? '已启用' : '已禁用'}
</td>
<td className="max-w-[220px] truncate px-4 py-3">
{source.client_id || '-'}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton
type="button"
onClick={() => toggleSource(source)}
disabled={busyKey === `toggle-${source.id}`}
>
{source.is_active ? '禁用' : '启用'}
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => startEdit(source)}
>
修改
</SecondaryButton>
<DangerButton
type="button"
onClick={() => removeSource(source)}
disabled={busyKey === `delete-${source.id}`}
>
删除
</DangerButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
) : (
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="认证源名称">
<ResourceInput
value={form.name}
onChange={(event) =>
setForm((previous) => ({
...previous,
name: event.target.value,
}))
}
placeholder="GitHub"
/>
</ResourceField>
<ResourceField label="展示名称">
<ResourceInput
value={form.display_name}
onChange={(event) =>
setForm((previous) => ({
...previous,
display_name: event.target.value,
}))
}
placeholder="GitHub"
/>
</ResourceField>
<ResourceField label="类型">
<ResourceSelect
value={form.type}
onChange={(event) =>
updateType(event.target.value as AuthSourceType)
}
>
<option value="github">GitHub</option>
<option value="oidc">OIDC</option>
</ResourceSelect>
</ResourceField>
<ResourceField label="状态">
<ResourceSelect
value={form.is_active ? 'true' : 'false'}
onChange={(event) =>
setForm((previous) => ({
...previous,
is_active: event.target.value === 'true',
}))
}
>
<option value="false">禁用</option>
<option value="true">启用</option>
</ResourceSelect>
</ResourceField>
<ResourceField label="Client ID">
<ResourceInput
value={form.client_id}
onChange={(event) =>
setForm((previous) => ({
...previous,
client_id: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField
label="Client Secret"
hint={editingSource ? '留空表示不更新现有密钥。' : undefined}
>
<ResourceInput
type="password"
value={form.client_secret}
onChange={(event) =>
setForm((previous) => ({
...previous,
client_secret: event.target.value,
}))
}
/>
</ResourceField>
<div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)] md:col-span-2">
{form.name.trim() ? (
<>
第三方平台的 Redirect URI / Callback URL 请填写:
<span className="font-medium">
{' '}
{buildCallbackURL(browserOrigin, form.name)}
</span>
。末尾路径使用上方填写的认证源名称,保存后如修改认证源名称,也需要同步更新第三方平台中的回调地址。
</>
) : (
'请先输入认证源名称获取回调地址'
)}
</div>
{form.type === 'oidc' ? (
<ResourceField
label="OIDC Discovery URL"
className="md:col-span-2"
>
<ResourceInput
value={form.openid_discovery_url}
onChange={(event) =>
setForm((previous) => ({
...previous,
openid_discovery_url: event.target.value,
}))
}
placeholder="https://auth.example.com/.well-known/openid-configuration"
/>
</ResourceField>
) : null}
<ResourceField label="Scopes">
<ResourceInput
value={form.scopes}
onChange={(event) =>
setForm((previous) => ({
...previous,
scopes: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="图标 URL">
<ResourceInput
value={form.icon_url}
onChange={(event) =>
setForm((previous) => ({
...previous,
icon_url: event.target.value,
}))
}
/>
</ResourceField>
</div>
)}
</div>
</AppModal>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '加载站点列表失败。';
}
export function UptimeKumaSiteSelectModal({
isOpen,
selectedSites,
onClose,
onSave,
}: {
isOpen: boolean;
selectedSites: string[];
onClose: () => void;
onSave: (sites: string[]) => void;
}) {
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
const {
data: routes = [],
isLoading,
error,
} = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
enabled: isOpen,
});
useEffect(() => {
if (isOpen) {
setTempSelected(
new Set(selectedSites.map((s) => s.trim()).filter(Boolean)),
);
setSearchTerm('');
}
}, [isOpen, selectedSites]);
const toggleSite = (siteName: string) => {
setTempSelected((prev) => {
const next = new Set(prev);
if (next.has(siteName)) {
next.delete(siteName);
} else {
next.add(siteName);
}
return next;
});
};
const handleSelectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.add(route.site_name);
});
return next;
});
};
const handleDeselectAll = () => {
setTempSelected((prev) => {
const next = new Set(prev);
filteredRoutes.forEach((route) => {
next.delete(route.site_name);
});
return next;
});
};
const handleSave = () => {
onSave(Array.from(tempSelected));
onClose();
};
const filteredRoutes = routes.filter(
(route) =>
route.site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
route.primary_domain.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<AppModal
isOpen={isOpen}
title="选择监控站点"
description="请选择要同步到 Uptime Kuma 监控的站点,支持按站点名称和域名搜索。"
size="lg"
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton type="button" onClick={handleSave}>
保存选择
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex-1">
<ResourceField label="搜索站点">
<ResourceInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="按名称或域名搜索..."
/>
</ResourceField>
</div>
<div className="flex gap-2 sm:mt-6">
<SecondaryButton type="button" onClick={handleSelectAll}>
全选过滤项
</SecondaryButton>
<SecondaryButton type="button" onClick={handleDeselectAll}>
清空过滤项
</SecondaryButton>
</div>
</div>
{isLoading ? <LoadingState /> : null}
{error ? (
<ErrorState
title="站点加载失败"
description={getErrorMessage(error)}
/>
) : null}
{!isLoading && !error && routes.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
暂无可用的代理站点。
</div>
) : null}
{!isLoading && !error && routes.length > 0 ? (
<div className="max-h-[350px] overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)]">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
<tr>
<th className="w-12 px-4 py-3">选择</th>
<th className="px-4 py-3 font-medium">站点名称</th>
<th className="px-4 py-3 font-medium">主域名</th>
<th className="px-4 py-3 font-medium">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{filteredRoutes.map((route) => {
const isChecked = tempSelected.has(route.site_name);
return (
<tr
key={route.id}
onClick={() => toggleSite(route.site_name)}
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
>
<td
className="px-4 py-3"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleSite(route.site_name)}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
</td>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{route.site_name}
</td>
<td className="px-4 py-3 text-[var(--foreground-secondary)]">
{route.primary_domain}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
route.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{route.enabled ? '启用' : '禁用'}
</span>
</td>
</tr>
);
})}
{filteredRoutes.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-4 py-8 text-center text-[var(--foreground-secondary)]"
>
无匹配的站点
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
<div className="text-right text-xs text-[var(--foreground-muted)]">
已选择 {tempSelected.size} 个监控站点
</div>
</div>
</AppModal>
);
}
@@ -0,0 +1,85 @@
import type { AuthUser } from '@/types/auth';
export interface OptionItem {
key: string;
value: string;
}
export interface OptionBatchPayload {
options: OptionItem[];
}
export type AuthSourceType = 'github' | 'oidc';
export interface AuthSource {
id: number;
name: string;
type: AuthSourceType;
display_name: string;
is_active: boolean;
client_id: string;
client_secret?: string;
client_secret_configured?: boolean;
openid_discovery_url: string;
scopes: string;
icon_url: string;
}
export interface ExternalAccountBinding {
id: number;
auth_source_id: number;
auth_source_name: string;
auth_source_type: AuthSourceType;
auth_source_label: string;
external_username: string;
email: string;
created_at: string;
}
export type AuthSourcePayload = Omit<
AuthSource,
'id' | 'client_secret_configured'
> & {
id?: number;
client_secret: string;
};
export interface BootstrapTokenPayload {
discovery_token: string;
}
export interface GeoIPLookupResult {
provider: string;
ip: string;
iso_code: string;
name: string;
latitude?: number | null;
longitude?: number | null;
}
export type DatabaseCleanupTarget =
| 'node_access_logs'
| 'node_metric_snapshots'
| 'node_request_reports';
export interface DatabaseCleanupPayload {
target: DatabaseCleanupTarget;
retention_days?: number;
}
export interface DatabaseCleanupResult {
target: DatabaseCleanupTarget;
target_label: string;
deleted_count: number;
delete_all: boolean;
retention_days?: number;
cutoff?: string;
}
export interface UpdateSelfPayload {
username: string;
display_name: string;
password: string;
}
export type SettingsProfile = AuthUser;
@@ -0,0 +1,43 @@
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {FeaturePlaceholder} from '@/components/feedback/feature-placeholder';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
interface ModulePageSkeletonProps {
title: string;
description: string;
}
export function ModulePageSkeleton({
title,
description,
}: ModulePageSkeletonProps) {
return (
<div className="space-y-6">
<PageHeader title={title} description={description} />
<FeaturePlaceholder
title={`${title} 页面骨架`}
description="当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。"
milestones={[
'路由入口已创建,可纳入统一导航。',
'页面标题区、说明区与内容容器已统一。',
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
]}
/>
<div className="grid gap-4 xl:grid-cols-3">
<LoadingState />
<EmptyState
title="空态骨架"
description="后续接入查询后,可将资源为空时的提示统一沉淀为此组件。"
/>
<ErrorState
title="错误态骨架"
description="后续请求失败、鉴权失效与重试提示可在此基础上扩展。"
/>
</div>
</div>
);
}
@@ -0,0 +1,219 @@
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
import {cn} from '@/lib/utils/cn';
interface ResourceFieldProps {
label: ReactNode;
hint?: ReactNode;
error?: string;
className?: string;
tooltip?: string;
container?: 'label' | 'div';
children: ReactNode;
}
function FieldTooltip({ content }: { content: string }) {
return (
<span className="group/tooltip relative inline-flex">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] text-[11px] font-semibold text-[var(--foreground-secondary)] transition group-hover/tooltip:border-[var(--border-strong)] group-hover/tooltip:text-[var(--foreground-primary)]">
?
</span>
<span className="pointer-events-none absolute top-full left-1/2 z-20 mt-2 w-64 -translate-x-1/2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-2 text-xs leading-5 text-[var(--foreground-secondary)] opacity-0 shadow-[var(--shadow-soft)] transition-opacity delay-700 duration-200 group-hover/tooltip:opacity-100">
{content}
</span>
</span>
);
}
export function ResourceField({
label,
hint,
error,
className,
tooltip,
container = 'label',
children,
}: ResourceFieldProps) {
const Container = container;
return (
<Container className={cn('block space-y-2', className)}>
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<span>{label}</span>
{tooltip ? <FieldTooltip content={tooltip} /> : null}
</span>
{children}
{error ? (
<span className="block text-xs text-[var(--status-danger-foreground)]">
{error}
</span>
) : hint ? (
<span className="block text-xs text-[var(--foreground-secondary)]">
{hint}
</span>
) : null}
</Container>
);
}
export function ResourceInput(props: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={cn(
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
props.className,
)}
/>
);
}
export function ResourceTextarea(
props: TextareaHTMLAttributes<HTMLTextAreaElement>,
) {
return (
<textarea
{...props}
className={cn(
'min-h-28 w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none placeholder:text-[var(--foreground-muted)] focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
props.className,
)}
/>
);
}
export function ResourceSelect(props: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
{...props}
className={cn(
'w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)] focus:ring-2 focus:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-60',
props.className,
)}
/>
);
}
function baseButtonClassName(className?: string) {
return cn(
'inline-flex items-center justify-center rounded-2xl px-4 py-3 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60',
className,
);
}
export function PrimaryButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={baseButtonClassName(
cn(
'bg-[var(--brand-primary)] text-[var(--foreground-inverse)] hover:opacity-90',
className,
),
)}
/>
);
}
export function SecondaryButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={baseButtonClassName(
cn(
'border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] hover:bg-[var(--control-background-hover)]',
className,
),
)}
/>
);
}
export function DangerButton({
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
className={baseButtonClassName(
cn(
'border border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)] hover:opacity-90',
className,
),
)}
/>
);
}
interface ToggleFieldProps {
label: string;
description?: string;
checked: boolean;
disabled?: boolean;
tooltip?: string;
onChange: (checked: boolean) => void;
}
export function ToggleField({
label,
description,
checked,
disabled,
tooltip,
onChange,
}: ToggleFieldProps) {
return (
<label className="flex cursor-pointer items-start gap-3 self-start rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="mt-1 h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="flex flex-col">
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<span>{label}</span>
{tooltip ? <FieldTooltip content={tooltip} /> : null}
</span>
{description ? (
<span className="mt-1 block text-xs leading-5 text-[var(--foreground-secondary)]">
{description}
</span>
) : null}
</span>
</label>
);
}
export function CodeBlock({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<pre
className={cn(
'overflow-x-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-xs leading-6 text-[var(--foreground-primary)]',
className,
)}
>
{children}
</pre>
);
}
@@ -0,0 +1,98 @@
import { apiRequest } from '@/lib/api/client';
import type {
TlsCertificateContentItem,
TlsCertificateDetailItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
TlsCertificateApplyPayload,
} from '@/features/tls-certificates/types';
export function getTlsCertificates() {
return apiRequest<TlsCertificateItem[]>('/tls-certificates/');
}
export function createTlsCertificate(payload: TlsCertificateMutationPayload) {
return apiRequest<TlsCertificateItem>('/tls-certificates/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function getTlsCertificate(id: number) {
return apiRequest<TlsCertificateDetailItem>(`/tls-certificates/${id}`);
}
export function getTlsCertificateContent(id: number) {
return apiRequest<TlsCertificateContentItem>(
`/tls-certificates/${id}/content`,
);
}
export function updateTlsCertificate(
id: number,
payload: TlsCertificateMutationPayload,
) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function importTlsCertificateFiles(
payload: TlsCertificateFileImportPayload,
) {
const formData = new FormData();
formData.append('name', payload.name);
formData.append('remark', payload.remark);
formData.append('cert_file', payload.certFile);
formData.append('key_file', payload.keyFile);
return apiRequest<TlsCertificateItem>('/tls-certificates/import-file', {
method: 'POST',
body: formData,
});
}
export function deleteTlsCertificate(id: number) {
return apiRequest<void>(`/tls-certificates/${id}/delete`, {
method: 'POST',
});
}
export function applyTlsCertificate(payload: TlsCertificateApplyPayload) {
return apiRequest<TlsCertificateItem>('/tls-certificates/apply', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function renewTlsCertificate(id: number) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/renew`, {
method: 'POST',
});
}
export function updateAcmeCertificate(
id: number,
payload: TlsCertificateApplyPayload,
) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}/update-acme`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function convertTlsCertificateToAcme(
id: number,
payload: TlsCertificateApplyPayload,
) {
return apiRequest<TlsCertificateItem>(
`/tls-certificates/${id}/convert-acme`,
{
method: 'POST',
body: JSON.stringify(payload),
},
);
}
@@ -0,0 +1,380 @@
'use client';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
deleteTlsCertificate,
getTlsCertificates,
renewTlsCertificate,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal';
import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal';
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
import { CertificateApplyModal } from '@/features/websites/components/certificate-apply-modal';
import {
getCertificateStatus,
getErrorMessage,
} from '@/features/websites/utils';
import {
DangerButton,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const certificatesQueryKey = ['tls-certificates', 'list'] as const;
type CertificateApplyMode = 'edit-acme' | 'convert-upload';
export function TlsCertificatesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isImportOpen, setIsImportOpen] = useState(false);
const [isApplyOpen, setIsApplyOpen] = useState(false);
const [selectedCertificateId, setSelectedCertificateId] = useState<
number | null
>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [applyCertificate, setApplyCertificate] =
useState<TlsCertificateItem | null>(null);
const [applyMode, setApplyMode] = useState<CertificateApplyMode>('edit-acme');
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: getTlsCertificates,
});
const deleteCertificateMutation = useMutation({
mutationFn: deleteTlsCertificate,
onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已删除。' });
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const renewCertificateMutation = useMutation({
mutationFn: renewTlsCertificate,
onSuccess: async (cert) => {
setFeedback({
tone: 'success',
message: `证书 ${cert.name} 续期任务已提交。`,
});
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const handleDeleteCertificate = (certificate: TlsCertificateItem) => {
if (!window.confirm(`确认删除证书 ${certificate.name} 吗?`)) {
return;
}
setFeedback(null);
deleteCertificateMutation.mutate(certificate.id);
};
const handleRenewCertificate = (certificate: TlsCertificateItem) => {
if (!window.confirm(`确认提交证书 ${certificate.name} 的续期申请吗?`)) {
return;
}
setFeedback(null);
renewCertificateMutation.mutate(certificate.id);
};
const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => {
setSelectedCertificateId(certificate.id);
setIsDetailOpen(true);
};
const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => {
if (certificate.provider === 'acme') {
setApplyMode('edit-acme');
setApplyCertificate(certificate);
} else {
setSelectedCertificateId(certificate.id);
setIsEditorOpen(true);
}
};
return (
<>
<div className="space-y-6">
<PageHeader
title="证书"
description="统一查看、导入、编辑和删除已添加的 TLS 证书。"
action={
<div className="flex flex-wrap gap-3">
<Link
href="/website"
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回网站
</Link>
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: ['tls-certificates'],
})
}
>
刷新证书
</SecondaryButton>
<Link
href="/website/dns-account"
className="inline-flex min-h-[46px] items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
DNS 账号
</Link>
<PrimaryButton
type="button"
onClick={() => setIsImportOpen(true)}
>
导入证书
</PrimaryButton>
<PrimaryButton type="button" onClick={() => setIsApplyOpen(true)}>
申请证书
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard
title="证书列表"
description="展示证书有效期、备注和状态,支持直接查看详情、编辑内容或删除证书。"
>
{certificatesQuery.isLoading ? (
<LoadingState />
) : certificatesQuery.isError ? (
<ErrorState
title="证书列表加载失败"
description={getErrorMessage(certificatesQuery.error)}
/>
) : certificates.length === 0 ? (
<EmptyState
title="暂无证书"
description="点击右上角“添加证书”开始录入。"
/>
) : (
<div className="space-y-3">
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
return (
<div
key={certificate.id}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{certificate.name}
</p>
<StatusBadge
label={status.label}
variant={status.variant}
/>
</div>
<div className="text-xs leading-5 text-[var(--foreground-secondary)]">
<p>生效:{formatDateTime(certificate.not_before)}</p>
<p>到期:{formatDateTime(certificate.not_after)}</p>
<p>
来源:
{certificate.provider === 'acme'
? 'ACME 申请'
: '手动上传'}
</p>
{certificate.provider === 'upload' &&
certificate.apply_status === 'applying' ? (
<p className="text-blue-500">状态:转换申请中...</p>
) : certificate.apply_status === 'applying' ? (
<p className="text-blue-500">状态:申请中...</p>
) : null}
{certificate.provider === 'upload' &&
certificate.apply_status === 'error' ? (
<p className="text-red-500">
状态:转换失败 ({certificate.apply_message})
</p>
) : certificate.apply_status === 'error' ? (
<p className="text-red-500">
状态:申请失败 ({certificate.apply_message})
</p>
) : null}
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() =>
handleOpenCertificateDetail(certificate)
}
className="px-3 py-2 text-xs"
>
查看
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() =>
handleOpenCertificateEditor(certificate)
}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
{certificate.provider === 'acme' && (
<SecondaryButton
type="button"
onClick={() => handleRenewCertificate(certificate)}
disabled={renewCertificateMutation.isPending}
className="px-3 py-2 text-xs"
>
续期
</SecondaryButton>
)}
<DangerButton
type="button"
onClick={() => handleDeleteCertificate(certificate)}
disabled={deleteCertificateMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</div>
</div>
);
})}
</div>
)}
</AppCard>
</div>
{isImportOpen ? (
<CertificateImportModal
isOpen={isImportOpen}
onClose={() => setIsImportOpen(false)}
onImported={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 已导入。`,
});
}}
/>
) : null}
{isApplyOpen ? (
<CertificateApplyModal
isOpen={isApplyOpen}
onClose={() => setIsApplyOpen(false)}
onApplied={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 申请任务已提交。`,
});
}}
/>
) : null}
{applyCertificate ? (
<CertificateApplyModal
isOpen={true}
onClose={() => setApplyCertificate(null)}
mode={applyMode}
certificate={applyCertificate}
onApplied={(certificate) => {
setFeedback({
tone: 'success',
message:
applyMode === 'convert-upload'
? `证书 ${certificate.name} 转换申请已提交。`
: `证书 ${certificate.name} 配置已更新,重新申请中...`,
});
}}
/>
) : null}
{isDetailOpen ? (
<CertificateDetailModal
certificateId={selectedCertificateId}
isOpen={isDetailOpen}
onClose={() => setIsDetailOpen(false)}
onEdit={() => {
setIsDetailOpen(false);
const certificate = certificates.find(
(item) => item.id === selectedCertificateId,
);
if (certificate) {
handleOpenCertificateEditor(certificate);
}
}}
onDelete={() => {
const certificate = certificates.find(
(item) => item.id === selectedCertificateId,
);
if (certificate) {
setIsDetailOpen(false);
handleDeleteCertificate(certificate);
}
}}
deleting={deleteCertificateMutation.isPending}
/>
) : null}
{isEditorOpen ? (
<CertificateEditorModal
certificateId={selectedCertificateId}
isOpen={isEditorOpen}
onClose={() => setIsEditorOpen(false)}
onSaved={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 已更新。`,
});
}}
onConvert={(certificate) => {
setIsEditorOpen(false);
setApplyMode('convert-upload');
setApplyCertificate(certificate);
}}
/>
) : null}
</>
);
}
@@ -0,0 +1,63 @@
export interface TlsCertificateItem {
id: number;
name: string;
cert_pem?: string;
key_pem?: string;
provider: string;
acme_account_id: number;
dns_account_id: number;
key_algorithm: string;
auto_renew: boolean;
primary_domain: string;
other_domains: string;
disable_cname: boolean;
skip_dns: boolean;
dns1: string;
dns2: string;
apply_status: string;
apply_message: string;
not_before: string;
not_after: string;
remark: string;
created_at: string;
updated_at: string;
}
export interface TlsCertificateDetailItem extends TlsCertificateItem {
cert_pem?: never;
key_pem?: never;
}
export interface TlsCertificateContentItem extends TlsCertificateItem {
cert_pem: string;
key_pem: string;
}
export interface TlsCertificateMutationPayload {
name: string;
cert_pem: string;
key_pem: string;
remark: string;
}
export interface TlsCertificateApplyPayload {
name: string;
remark: string;
acme_account_id: number;
dns_account_id: number;
key_algorithm: string;
auto_renew: boolean;
primary_domain: string;
other_domains: string;
disable_cname: boolean;
skip_dns: boolean;
dns1: string;
dns2: string;
}
export interface TlsCertificateFileImportPayload {
name: string;
remark: string;
certFile: File;
keyFile: File;
}
@@ -0,0 +1,128 @@
import { ApiError, apiRequest, getApiUrl } from '@/lib/api/client';
import type { ApiEnvelope } from '@/types/api';
import type {
LatestReleaseInfo,
ReleaseChannel,
UpgradeStreamSnapshot,
UploadedServerBinaryInfo,
} from '@/features/update/types';
export function getLatestRelease(channel: ReleaseChannel = 'stable') {
return apiRequest<LatestReleaseInfo>(
`/update/latest-release?channel=${channel}`,
);
}
export function upgradeServer(channel: ReleaseChannel = 'stable') {
return apiRequest<LatestReleaseInfo>('/update/upgrade', {
method: 'POST',
body: JSON.stringify({ channel }),
});
}
export function uploadServerBinary(
binary: File,
onProgress?: (progress: number) => void,
): Promise<UploadedServerBinaryInfo> {
const formData = new FormData();
formData.append('binary', binary);
if (!onProgress) {
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upload', {
method: 'POST',
body: formData,
});
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', getApiUrl('/update/manual-upload'));
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
xhr.addEventListener('load', () => {
let payload: ApiEnvelope<UploadedServerBinaryInfo> | null = null;
try {
payload = JSON.parse(
xhr.responseText,
) as ApiEnvelope<UploadedServerBinaryInfo>;
} catch {
payload = null;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject(
new ApiError(
payload?.message || `请求失败(${xhr.status})`,
xhr.status,
),
);
return;
}
if (!payload) {
reject(new ApiError('响应格式无效', xhr.status));
return;
}
if (!payload.success) {
reject(new ApiError(payload.message || '请求失败', xhr.status));
return;
}
resolve(payload.data);
});
xhr.addEventListener('error', () => {
reject(new ApiError('上传过程中网络连接中断,请检查网络后重试', 0));
});
xhr.send(formData);
});
}
export function confirmManualServerUpgrade(uploadToken: string) {
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upgrade', {
method: 'POST',
body: JSON.stringify({ upload_token: uploadToken }),
});
}
export function createUpgradeLogsWebSocket() {
if (typeof window === 'undefined') {
return null;
}
const apiUrl = getApiUrl('/update/logs/ws');
const resolvedUrl = apiUrl.startsWith('http://')
? `ws://${apiUrl.slice('http://'.length)}`
: apiUrl.startsWith('https://')
? `wss://${apiUrl.slice('https://'.length)}`
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${apiUrl}`;
return new WebSocket(resolvedUrl);
}
export function parseUpgradeStreamSnapshot(
rawMessage: string,
): UpgradeStreamSnapshot | null {
try {
const parsed = JSON.parse(rawMessage) as Partial<UpgradeStreamSnapshot>;
if (
typeof parsed.in_progress !== 'boolean' ||
typeof parsed.upgrade_status !== 'string' ||
!Array.isArray(parsed.upgrade_logs)
) {
return null;
}
return {
in_progress: parsed.in_progress,
upgrade_status: parsed.upgrade_status,
upgrade_logs: parsed.upgrade_logs,
};
} catch {
return null;
}
}
@@ -0,0 +1,477 @@
'use client';
import { marked } from 'marked';
import { useEffect, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import type {
LatestReleaseInfo,
ReleaseChannel,
UploadedServerBinaryInfo,
} from '@/features/update/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
interface VersionUpgradeModalProps {
isOpen: boolean;
onClose: () => void;
currentVersion: string;
release: LatestReleaseInfo | null | undefined;
selectedChannel: ReleaseChannel;
uploadedBinary: UploadedServerBinaryInfo | null;
isLoading: boolean;
releaseErrorMessage?: string;
manualStatusMessage?: string;
manualErrorMessage?: string;
canUpgrade: boolean;
isChecking: boolean;
isUpgrading: boolean;
isUploadingBinary: boolean;
uploadProgress: number;
isConfirmingManualUpgrade: boolean;
onChannelChange: (channel: ReleaseChannel) => void;
onCheck: () => void;
onUpgrade: () => void;
onUploadBinary: (file: File) => void;
onConfirmManualUpgrade: () => void;
}
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
if (!release) {
return { label: '未检查', variant: 'info' as const };
}
if (release.in_progress) {
return { label: '升级中', variant: 'warning' as const };
}
if (release.has_update) {
return { label: '可升级', variant: 'warning' as const };
}
return { label: '最新', variant: 'success' as const };
}
function formatLogTimestamp(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '0000-00-00 00:00:00.000';
}
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
const minute = `${date.getMinutes()}`.padStart(2, '0');
const second = `${date.getSeconds()}`.padStart(2, '0');
const millisecond = `${date.getMilliseconds()}`.padStart(3, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
}
function formatLogLevel(level: string) {
const normalized = (level || 'info').toUpperCase();
return normalized.padEnd(8, ' ');
}
export function VersionUpgradeModal({
isOpen,
onClose,
currentVersion,
release,
selectedChannel,
uploadedBinary,
isLoading,
releaseErrorMessage,
manualStatusMessage,
manualErrorMessage,
canUpgrade,
isChecking,
isUpgrading,
isUploadingBinary,
uploadProgress,
isConfirmingManualUpgrade,
onChannelChange,
onCheck,
onUpgrade,
onUploadBinary,
onConfirmManualUpgrade,
}: VersionUpgradeModalProps) {
const upgradeBadge = getUpgradeBadge(release);
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
const uploadPhaseLabel =
uploadProgress >= 100 ? '已上传,正在服务端校验版本...' : '上传中...';
const selectedChannelLabel =
selectedChannel === 'preview' ? '预览版' : '正式版';
const toggleChannel = () => {
onChannelChange(selectedChannel === 'preview' ? 'stable' : 'preview');
};
const canConfirmManualUpgrade = Boolean(
uploadedBinary?.ready_to_upgrade && uploadedBinary.upload_token,
);
const showConfirmManualUpgradeAction = canConfirmManualUpgrade;
const upgradeLogs = release?.upgrade_logs ?? [];
useEffect(() => {
if (!isOpen) {
setSelectedBinary(null);
}
}, [isOpen]);
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="版本"
description="默认检查正式版更新;你也可以手动检查 preview 发布并选择升级。升级开始后服务会短暂重启。"
size="lg"
>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
<AppCard title="服务端版本">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{currentVersion || 'unknown'}
</p>
<StatusBadge
label={upgradeBadge.label}
variant={upgradeBadge.variant}
/>
</div>
</AppCard>
<AppCard title="最新版本">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{release?.tag_name || '未检查'}
</p>
{canUpgrade ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={selectedChannelLabel}
variant={
selectedChannel === 'preview' ? 'warning' : 'info'
}
onClick={toggleChannel}
disabled={
isChecking || isUpgrading || isUploadingBinary
}
/>
</div>
</div>
) : null}
</div>
<SecondaryButton
type="button"
onClick={onCheck}
disabled={isChecking || isUpgrading || isUploadingBinary}
className="w-full md:w-auto"
>
{isChecking ? '检查中...' : '检查'}
</SecondaryButton>
</div>
</AppCard>
</div>
{isLoading ? <LoadingState /> : null}
{!isLoading && releaseErrorMessage ? (
<ErrorState title="版本检查失败" description={releaseErrorMessage} />
) : null}
{!isLoading && !releaseErrorMessage && !release ? (
<EmptyState
title={`尚未检查${selectedChannelLabel}`}
description={`点击“检查${selectedChannelLabel}”后会在这里展示对应 GitHub Release 信息。`}
/>
) : null}
{!isLoading && !releaseErrorMessage && release ? (
<AppCard
title={`GitHub ${selectedChannelLabel} · ${release.tag_name}`}
description={
release.published_at
? `发布时间:${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
: '未提供发布时间'
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={release.has_update ? '发现新版本' : '已经是最新版本'}
variant={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<StatusBadge label="Preview 发布" variant="warning" />
) : (
<StatusBadge label="正式发布" variant="info" />
)}
{!release.upgrade_supported ? (
<StatusBadge
label="当前平台不支持自动升级"
variant="danger"
/>
) : null}
{release.in_progress ? (
<StatusBadge label="升级任务执行中" variant="warning" />
) : null}
</div>
<div
className="prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]"
dangerouslySetInnerHTML={{
__html: marked.parse(
release.body || '暂无更新说明',
) as string,
}}
/>
<a
href={release.html_url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
查看发布详情
</a>
{canUpgrade ? (
<div className="flex justify-end">
<PrimaryButton
type="button"
onClick={onUpgrade}
disabled={
!release.has_update ||
release.in_progress ||
isUpgrading ||
!release.upgrade_supported ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUpgrading
? '升级中...'
: release.in_progress
? '升级中...'
: selectedChannel === 'preview'
? '升级预览版'
: '升级正式版'}
</PrimaryButton>
</div>
) : null}
</div>
</AppCard>
) : null}
{release ? (
<AppCard title="升级日志">
{upgradeLogs.length > 0 ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={
release.upgrade_status === 'failed'
? '升级失败'
: release.upgrade_status === 'succeeded'
? '准备重启'
: release.in_progress
? '升级中'
: '空闲'
}
variant={
release.upgrade_status === 'failed'
? 'danger'
: release.in_progress
? 'warning'
: release.upgrade_status === 'succeeded'
? 'success'
: 'info'
}
/>
</div>
<div className="max-h-72 overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[#0f172a] px-4 py-3">
{upgradeLogs.map((log, index) => (
<pre
key={`${log.created_at}-${index}`}
className="overflow-x-auto border-b border-white/8 py-2 font-mono text-[12px] leading-6 text-slate-200 last:border-b-0"
>
<span className="text-slate-400">
{formatLogTimestamp(log.created_at)}
</span>
<span className="text-slate-500"> | </span>
<span className="text-cyan-300">
{formatLogLevel(log.level)}
</span>
<span className="text-slate-500"> | </span>
<span className="break-all whitespace-pre-wrap text-slate-100">
{log.message}
</span>
</pre>
))}
</div>
</div>
) : (
<EmptyState title="暂无升级日志" />
)}
</AppCard>
) : null}
{canUpgrade && (false as boolean) ? (
<AppCard title="手动升级">
<div className="space-y-4">
<ResourceField
label="服务端二进制"
hint="支持上传已编译好的当前平台 Server 可执行文件。"
>
<ResourceInput
type="file"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setSelectedBinary(file);
}}
disabled={isUploadingBinary || isConfirmingManualUpgrade}
/>
</ResourceField>
<div className="flex justify-end">
{showConfirmManualUpgradeAction ? (
<PrimaryButton
type="button"
onClick={onConfirmManualUpgrade}
disabled={
!canConfirmManualUpgrade ||
isConfirmingManualUpgrade ||
isUploadingBinary ||
isUpgrading
}
>
{isConfirmingManualUpgrade ? '升级中...' : '升级'}
</PrimaryButton>
) : (
<PrimaryButton
type="button"
onClick={() => {
if (selectedBinary) {
onUploadBinary(selectedBinary);
}
}}
disabled={
!selectedBinary ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUploadingBinary
? uploadProgress >= 100
? '服务端校验中...'
: '上传中...'
: '上传并检查'}
</PrimaryButton>
)}
</div>
{isUploadingBinary ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-[var(--foreground-secondary)]">
<span>{uploadPhaseLabel}</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--border-default)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-200"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : null}
{manualErrorMessage ? (
<ErrorState
title="手动升级检查失败"
description={manualErrorMessage}
/>
) : null}
{!manualErrorMessage && manualStatusMessage ? (
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
{manualStatusMessage}
</div>
) : null}
{uploadedBinary ? (
<div className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={
uploadedBinary.ready_to_upgrade
? '可确认升级'
: uploadedBinary.has_update
? '待确认'
: '不可升级'
}
variant={
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
}
/>
{!uploadedBinary.upgrade_supported ? (
<StatusBadge
label="当前版本不支持手动升级"
variant="danger"
/>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
文件名
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.file_name}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传时间
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.uploaded_at
? formatDateTime(uploadedBinary.uploaded_at)
: '未知'}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
当前版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.current_version}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.detected_version}
</p>
</div>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{uploadedBinary.comparison_message}
</p>
</div>
) : (
<EmptyState title="尚未上传升级包" />
)}
</div>
</AppCard>
) : null}
</div>
</AppModal>
);
}
@@ -0,0 +1,40 @@
export type ReleaseChannel = 'stable' | 'preview';
export interface UpgradeLogItem {
level: string;
message: string;
created_at: string;
}
export interface LatestReleaseInfo {
tag_name: string;
body: string;
html_url: string;
published_at: string;
channel: ReleaseChannel;
prerelease: boolean;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
in_progress: boolean;
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
upgrade_logs: UpgradeLogItem[];
}
export interface UpgradeStreamSnapshot {
in_progress: boolean;
upgrade_status: 'idle' | 'running' | 'succeeded' | 'failed' | string;
upgrade_logs: UpgradeLogItem[];
}
export interface UploadedServerBinaryInfo {
upload_token: string;
file_name: string;
detected_version: string;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
ready_to_upgrade: boolean;
comparison_message: string;
uploaded_at: string;
}
@@ -0,0 +1,43 @@
import { apiRequest } from '@/lib/api/client';
import type {
ManageUserAction,
ManageUserResult,
UserItem,
UserMutationPayload,
} from '@/features/users/types';
export function getUsers(page: number) {
return apiRequest<UserItem[]>(`/user/?p=${page}`);
}
export function searchUsers(keyword: string) {
return apiRequest<UserItem[]>(
`/user/search?keyword=${encodeURIComponent(keyword)}`,
);
}
export function getUser(id: number) {
return apiRequest<UserItem>(`/user/${id}`);
}
export function createUser(payload: UserMutationPayload) {
return apiRequest<void>('/user/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateUser(payload: UserMutationPayload & { id: number }) {
return apiRequest<void>('/user/update', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function manageUser(username: string, action: ManageUserAction) {
return apiRequest<ManageUserResult>('/user/manage', {
method: 'POST',
body: JSON.stringify({ username, action }),
});
}
@@ -0,0 +1,613 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { useAuth } from '@/components/providers/auth-provider';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import {
createUser,
getUser,
getUsers,
manageUser,
searchUsers,
updateUser,
} from '@/features/users/api/users';
import type {
ManageUserAction,
UserItem,
UserMutationPayload,
} from '@/features/users/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
const ITEMS_PER_PAGE = 10;
const usersListQueryKey = ['users', 'list'] as const;
const userSchema = z.object({
username: z
.string()
.trim()
.min(1, '请输入用户名')
.max(12, '用户名不能超过 12 个字符'),
display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'),
password: z
.string()
.max(20, '密码不能超过 20 个字符')
.refine(
(value) => value.length === 0 || value.length >= 8,
'密码至少需要 8 个字符',
),
});
type UserFormValues = z.infer<typeof userSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: UserFormValues = {
username: '',
display_name: '',
password: '',
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function getRoleMeta(role: number) {
if (role >= 100) {
return { label: '超级管理员', variant: 'warning' as const };
}
if (role >= 10) {
return { label: '管理员', variant: 'info' as const };
}
return { label: '普通用户', variant: 'success' as const };
}
function getStatusMeta(status: number) {
if (status === 1) {
return { label: '已激活', variant: 'success' as const };
}
return { label: '已封禁', variant: 'danger' as const };
}
function toPayload(values: UserFormValues): UserMutationPayload {
return {
username: values.username.trim(),
display_name: values.display_name.trim(),
password: values.password,
};
}
export function UsersPage() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const form = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
defaultValues,
});
const isAdmin = (currentUser?.role ?? 0) >= 10;
const isRoot = (currentUser?.role ?? 0) >= 100;
const usersQuery = useQuery({
queryKey: [...usersListQueryKey, page],
queryFn: () => getUsers(page),
enabled: isAdmin && searchKeyword.length === 0,
});
const searchQuery = useQuery({
queryKey: [...usersListQueryKey, 'search', searchKeyword],
queryFn: () => searchUsers(searchKeyword),
enabled: isAdmin && searchKeyword.length > 0,
});
const editingUserQuery = useQuery({
queryKey: ['users', 'detail', editingUserId],
queryFn: () => getUser(editingUserId as number),
enabled: isAdmin && editingUserId !== null,
});
useEffect(() => {
if (editingUserQuery.data) {
form.reset({
username: editingUserQuery.data.username,
display_name: editingUserQuery.data.display_name || '',
password: '',
});
}
}, [editingUserQuery.data, form]);
const saveMutation = useMutation({
mutationFn: async (values: UserFormValues) => {
if (!editingUserId && values.password.length === 0) {
throw new Error('创建用户时必须填写密码。');
}
const payload = toPayload(values);
if (editingUserId) {
await updateUser({ id: editingUserId, ...payload });
return '用户信息已更新。';
}
await createUser(payload);
return '用户已创建。';
},
onSuccess: async (message) => {
setFeedback({ tone: 'success', message });
setEditingUserId(null);
setIsEditorOpen(false);
form.reset(defaultValues);
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const manageMutation = useMutation({
mutationFn: async ({
username,
action,
}: {
username: string;
action: ManageUserAction;
}) => {
await manageUser(username, action);
return action;
},
onSuccess: async (action) => {
const actionLabel = {
promote: '提升权限',
demote: '降级权限',
delete: '删除用户',
disable: '禁用用户',
enable: '启用用户',
}[action];
setFeedback({ tone: 'success', message: `${actionLabel}操作已完成。` });
await Promise.all([
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery;
const users = useMemo(() => activeQuery.data ?? [], [activeQuery.data]);
const handleSearchSubmit = () => {
setFeedback(null);
setPage(0);
setSearchKeyword(searchInput.trim());
};
const handleResetSearch = () => {
setSearchInput('');
setSearchKeyword('');
setPage(0);
setFeedback(null);
};
const handleResetForm = () => {
setEditingUserId(null);
setIsEditorOpen(false);
setFeedback(null);
form.reset(defaultValues);
};
const handleCreate = () => {
setEditingUserId(null);
setFeedback(null);
form.reset(defaultValues);
setIsEditorOpen(true);
};
const handleEdit = (targetUser: UserItem) => {
setFeedback(null);
setEditingUserId(targetUser.id);
setIsEditorOpen(true);
};
const handleManage = (targetUser: UserItem, action: ManageUserAction) => {
const actionText = {
promote: '提升',
demote: '降级',
delete: '删除',
disable: '禁用',
enable: '启用',
}[action];
if (
action === 'delete' &&
!window.confirm(`确认删除用户“${targetUser.username}”吗?`)
) {
return;
}
if (
(action === 'disable' || action === 'enable') &&
!window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)
) {
return;
}
setFeedback(null);
manageMutation.mutate({ username: targetUser.username, action });
};
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
if (!isAdmin) {
return (
<div className="space-y-6">
<PageHeader
title="用户管理"
description="阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。"
/>
<EmptyState
title="权限不足"
description="用户列表、编辑与账户管理动作仅对管理员开放,请使用管理员账号登录后继续。"
/>
</div>
);
}
return (
<>
<div className="space-y-6">
<PageHeader
title="用户管理"
description="支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。"
action={
<PrimaryButton type="button" onClick={handleCreate}>
新建用户
</PrimaryButton>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<AppCard
title="用户列表"
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: usersListQueryKey,
})
}
>
刷新
</SecondaryButton>
</div>
}
>
<div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex w-full flex-col gap-3 md:flex-row">
<ResourceInput
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="搜索用户 ID、用户名、显示名称或邮箱地址"
className="md:max-w-xl"
/>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearchSubmit}>
搜索
</PrimaryButton>
<SecondaryButton type="button" onClick={handleResetSearch}>
清空
</SecondaryButton>
</div>
</div>
{searchKeyword.length === 0 ? (
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => setPage((value) => Math.max(value - 1, 0))}
disabled={page === 0 || activeQuery.isLoading}
>
上一页
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => setPage((value) => value + 1)}
disabled={
activeQuery.isLoading || users.length < ITEMS_PER_PAGE
}
>
下一页
</SecondaryButton>
</div>
) : null}
</div>
{activeQuery.isLoading ? (
<LoadingState />
) : activeQuery.isError ? (
<ErrorState
title="用户列表加载失败"
description={getErrorMessage(activeQuery.error)}
/>
) : users.length === 0 ? (
<EmptyState
title="暂无用户"
description="当前条件下没有可展示的用户记录。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">用户</th>
<th className="px-3 py-3 font-medium">邮箱</th>
<th className="px-3 py-3 font-medium">角色</th>
<th className="px-3 py-3 font-medium">状态</th>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{users.map((targetUser) => {
const roleMeta = getRoleMeta(targetUser.role);
const statusMeta = getStatusMeta(targetUser.status);
const canManage =
(currentUser?.role ?? 0) > targetUser.role;
return (
<tr key={targetUser.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-1">
<p className="font-medium text-[var(--foreground-primary)]">
{targetUser.username}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
显示名称:{targetUser.display_name || '未设置'}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
ID:{targetUser.id}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{targetUser.email || '未绑定'}
</td>
<td className="px-3 py-4">
<StatusBadge
label={roleMeta.label}
variant={roleMeta.variant}
/>
</td>
<td className="px-3 py-4">
<StatusBadge
label={statusMeta.label}
variant={statusMeta.variant}
/>
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => handleEdit(targetUser)}
className="px-3 py-2 text-xs"
disabled={!canManage}
>
编辑
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() =>
handleManage(
targetUser,
targetUser.status === 1
? 'disable'
: 'enable',
)
}
className="px-3 py-2 text-xs"
disabled={
!canManage || manageMutation.isPending
}
>
{targetUser.status === 1 ? '禁用' : '启用'}
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() =>
handleManage(
targetUser,
targetUser.role >= 10
? 'demote'
: 'promote',
)
}
className="px-3 py-2 text-xs"
disabled={
!canManage ||
manageMutation.isPending ||
(!isRoot && targetUser.role < 10)
}
>
{targetUser.role >= 10 ? '降级' : '提升'}
</SecondaryButton>
<DangerButton
type="button"
onClick={() =>
handleManage(targetUser, 'delete')
}
className="px-3 py-2 text-xs"
disabled={
!canManage || manageMutation.isPending
}
>
删除
</DangerButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleResetForm}
title={editingUserId ? '编辑用户' : '创建新用户'}
description={
editingUserId
? '可更新用户名、显示名称与密码。留空密码表示不修改。'
: '新建用户默认创建为普通用户。'
}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleResetForm}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="user-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending
? '保存中...'
: editingUserId
? '保存修改'
: '创建用户'}
</PrimaryButton>
</div>
}
>
{editingUserId && editingUserQuery.isLoading ? (
<LoadingState />
) : editingUserId && editingUserQuery.isError ? (
<ErrorState
title="用户详情加载失败"
description={getErrorMessage(editingUserQuery.error)}
/>
) : (
<form
id="user-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="用户名"
error={form.formState.errors.username?.message}
>
<ResourceInput
placeholder="请输入用户名"
{...form.register('username')}
/>
</ResourceField>
<ResourceField
label="显示名称"
error={form.formState.errors.display_name?.message}
>
<ResourceInput
placeholder="请输入显示名称"
{...form.register('display_name')}
/>
</ResourceField>
<ResourceField
label={editingUserId ? '新密码' : '密码'}
hint={
editingUserId
? '留空表示保持原密码不变。'
: '密码长度需为 8 到 20 个字符。'
}
error={form.formState.errors.password?.message}
>
<ResourceInput
type="password"
placeholder="请输入密码"
{...form.register('password')}
/>
</ResourceField>
{editingUserId && editingUserQuery.data ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
绑定邮箱
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{editingUserQuery.data.email || '未绑定'}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
第三方账号
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
GitHub:{editingUserQuery.data.github_id || '未绑定'}
</p>
<p className="mt-1 text-sm text-[var(--foreground-primary)]">
微信:{editingUserQuery.data.wechat_id || '未绑定'}
</p>
</div>
</div>
) : null}
</form>
)}
</AppModal>
</>
);
}
@@ -0,0 +1,29 @@
export interface UserItem {
id: number;
username: string;
display_name: string;
role: number;
status: number;
email?: string;
github_id?: string;
wechat_id?: string;
}
export interface UserMutationPayload {
id?: number;
username: string;
display_name: string;
password: string;
}
export type ManageUserAction =
| 'promote'
| 'demote'
| 'delete'
| 'disable'
| 'enable';
export interface ManageUserResult {
role: number;
status: number;
}
@@ -0,0 +1,95 @@
import { apiRequest } from '@/lib/api/client';
import type {
WAFRuleGroup,
WAFRuleGroupPayload,
WAFIPGroup,
WAFIPGroupAutoTestPayload,
WAFIPGroupAutoTestResult,
WAFIPGroupPayload,
WAFIPGroupSyncResult,
WAFSiteRuleGroups,
} from '@/features/waf/types';
export function getWAFRuleGroups() {
return apiRequest<WAFRuleGroup[]>('/waf/rule-groups');
}
export function createWAFRuleGroup(payload: WAFRuleGroupPayload) {
return apiRequest<WAFRuleGroup>('/waf/rule-groups', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateWAFRuleGroup(id: number, payload: WAFRuleGroupPayload) {
return apiRequest<WAFRuleGroup>(`/waf/rule-groups/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteWAFRuleGroup(id: number) {
return apiRequest<void>(`/waf/rule-groups/${id}/delete`, {
method: 'POST',
});
}
export function replaceWAFRuleGroupSites(id: number, ids: number[]) {
return apiRequest<WAFRuleGroup>(`/waf/rule-groups/${id}/sites`, {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
export function getWAFSiteRuleGroups(routeId: number) {
return apiRequest<WAFSiteRuleGroups>(`/waf/sites/${routeId}/rule-groups`);
}
export function replaceWAFSiteRuleGroups(routeId: number, ids: number[]) {
return apiRequest<WAFSiteRuleGroups>(`/waf/sites/${routeId}/rule-groups`, {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
export function getWAFIPGroups() {
return apiRequest<WAFIPGroup[]>('/waf/ip-groups');
}
export function getWAFIPGroup(id: number) {
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}`);
}
export function createWAFIPGroup(payload: WAFIPGroupPayload) {
return apiRequest<WAFIPGroup>('/waf/ip-groups', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateWAFIPGroup(id: number, payload: WAFIPGroupPayload) {
return apiRequest<WAFIPGroup>(`/waf/ip-groups/${id}/update`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function deleteWAFIPGroup(id: number) {
return apiRequest<void>(`/waf/ip-groups/${id}/delete`, {
method: 'POST',
});
}
export function syncWAFIPGroup(id: number) {
return apiRequest<WAFIPGroupSyncResult>(`/waf/ip-groups/${id}/sync`, {
method: 'POST',
});
}
export function testWAFIPGroupAutoConfig(payload: WAFIPGroupAutoTestPayload) {
return apiRequest<WAFIPGroupAutoTestResult>('/waf/ip-groups/test', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -0,0 +1,217 @@
import type {ProxyRoutePoWConfig} from '@/features/proxy-routes/types';
import type {WAFRuleGroup, WAFRuleGroupPayload} from '@/features/waf/types';
import type {
CountryOption,
ListFieldKey,
RuleDimension,
RuleListRenderable,
RuleListType,
RuleModalState,
WAFTab,
} from './types';
export const defaultPowConfig: ProxyRoutePoWConfig = {
difficulty: 4,
algorithm: 'fast',
session_ttl: 600,
challenge_ttl: 300,
whitelist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
blacklist: {
ips: [],
ip_cidrs: [],
paths: [],
path_regexes: [],
user_agents: [],
},
};
export const emptyDraft: WAFRuleGroupPayload = {
name: '',
enabled: true,
block_status_code: 418,
block_response_body: '',
ip_whitelist: [],
ip_blacklist: [],
ip_whitelist_group_ids: [],
ip_blacklist_group_ids: [],
country_whitelist: [],
country_blacklist: [],
region_whitelist: [],
region_blacklist: [],
pow_enabled: false,
pow_config: defaultPowConfig,
remark: '',
};
export const defaultRuleModalState: RuleModalState = {
open: false,
listType: 'blacklist',
dimension: 'ip',
ipValue: '',
ipGroupIDs: [],
countryValues: [],
};
export const tabItems: Array<{
id: WAFTab;
label: string;
}> = [
{ id: 'basic', label: '基本信息' },
{ id: 'lists', label: '黑白名单' },
{ id: 'pow', label: 'PoW' },
{ id: 'block', label: '拦截返回' },
];
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
}
export function textToList(text: string) {
return text
.split(/[\n,,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function listToText(items: string[] | undefined) {
return (items ?? []).join('\n');
}
export function parseTextareaList(text: string) {
return text
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function normalizeItems(items: string[]) {
return Array.from(
new Set(items.map((item) => item.trim()).filter(Boolean)),
).sort((left, right) => left.localeCompare(right));
}
export function buildDraft(group: WAFRuleGroup | null): WAFRuleGroupPayload {
if (!group) {
return { ...emptyDraft };
}
return {
name: group.name,
enabled: group.enabled,
block_status_code: group.block_status_code || 418,
block_response_body: group.block_response_body ?? '',
ip_whitelist: group.ip_whitelist ?? [],
ip_blacklist: group.ip_blacklist ?? [],
ip_whitelist_group_ids: group.ip_whitelist_group_ids ?? [],
ip_blacklist_group_ids: group.ip_blacklist_group_ids ?? [],
country_whitelist: group.country_whitelist ?? [],
country_blacklist: group.country_blacklist ?? [],
region_whitelist: group.region_whitelist ?? [],
region_blacklist: group.region_blacklist ?? [],
pow_enabled: group.pow_enabled ?? false,
pow_config: group.pow_config ?? defaultPowConfig,
remark: group.remark ?? '',
};
}
export function countRuleEntries(group: RuleListRenderable) {
return (
group.ip_whitelist.length +
group.ip_blacklist.length +
group.ip_whitelist_group_ids.length +
group.ip_blacklist_group_ids.length +
group.country_whitelist.length +
group.country_blacklist.length +
group.region_whitelist.length +
group.region_blacklist.length
);
}
export function buildCountryOptions() {
const zhDisplayNames = new Intl.DisplayNames(['zh-CN'], { type: 'region' });
const enDisplayNames = new Intl.DisplayNames(['en'], { type: 'region' });
const options: CountryOption[] = [];
for (let first = 65; first <= 90; first += 1) {
for (let second = 65; second <= 90; second += 1) {
const code = String.fromCharCode(first, second);
const zhName = zhDisplayNames.of(code);
const enName = enDisplayNames.of(code);
if (
!zhName ||
zhName === code ||
/未知/.test(zhName) ||
!enName ||
enName === code ||
/Unknown/.test(enName)
) {
continue;
}
options.push({
code,
zhName,
label: `${code} ${zhName}`,
searchText: `${code} ${zhName} ${enName}`.toLowerCase(),
});
}
}
return options.sort((left, right) => left.code.localeCompare(right.code));
}
export function getListFieldKey(
listType: RuleListType,
dimension: RuleDimension,
): ListFieldKey {
if (dimension === 'ip') {
return listType === 'whitelist' ? 'ip_whitelist' : 'ip_blacklist';
}
if (dimension === 'ip_group') {
return listType === 'whitelist'
? 'ip_whitelist_group_ids'
: 'ip_blacklist_group_ids';
}
return listType === 'whitelist' ? 'country_whitelist' : 'country_blacklist';
}
export function updateDraftList(
draft: WAFRuleGroupPayload,
key: ListFieldKey,
updater: (items: string[]) => string[],
) {
switch (key) {
case 'ip_whitelist':
return { ...draft, ip_whitelist: updater(draft.ip_whitelist) };
case 'ip_blacklist':
return { ...draft, ip_blacklist: updater(draft.ip_blacklist) };
case 'ip_whitelist_group_ids':
return {
...draft,
ip_whitelist_group_ids: updater(
draft.ip_whitelist_group_ids.map(String),
).map(Number),
};
case 'ip_blacklist_group_ids':
return {
...draft,
ip_blacklist_group_ids: updater(
draft.ip_blacklist_group_ids.map(String),
).map(Number),
};
case 'country_whitelist':
return { ...draft, country_whitelist: updater(draft.country_whitelist) };
case 'country_blacklist':
return { ...draft, country_blacklist: updater(draft.country_blacklist) };
}
}
export function formatCountryItem(code: string, labelMap: Map<string, string>) {
return labelMap.get(code) ?? code;
}
@@ -0,0 +1,784 @@
'use client';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {ArrowLeft, Download, Eye, Play, Plus, Save, Trash2,} from 'lucide-react';
import {useRouter} from 'next/navigation';
import {useEffect, useMemo, useState} from 'react';
import {EmptyState} from '@/components/feedback/empty-state';
import {ErrorState} from '@/components/feedback/error-state';
import {InlineMessage} from '@/components/feedback/inline-message';
import {LoadingState} from '@/components/feedback/loading-state';
import {PageHeader} from '@/components/layout/page-header';
import {AppCard} from '@/components/ui/app-card';
import {AppModal} from '@/components/ui/app-modal';
import {
createWAFIPGroup,
deleteWAFIPGroup,
getWAFIPGroups,
syncWAFIPGroup,
testWAFIPGroupAutoConfig,
updateWAFIPGroup,
} from '@/features/waf/api/waf';
import type {
WAFIPGroup,
WAFIPGroupAutoTestResult,
WAFIPGroupPayload,
WAFIPGroupSubscriptionFormat,
WAFIPGroupType,
} from '@/features/waf/types';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
ResourceTextarea,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import {cn} from '@/lib/utils/cn';
import {getErrorMessage, listToText, parseTextareaList} from './helpers';
type FeedbackState = {
tone: 'success' | 'danger' | 'info';
message: string;
};
type IPGroupDraft = WAFIPGroupPayload & {
ip_list_text: string;
auto_config_text: string;
};
const emptyIPGroupDraft: IPGroupDraft = {
name: '',
type: 'manual',
enabled: true,
ip_list: [],
ip_list_text: '',
auto_config: {},
auto_config_text: '{}',
subscription_url: '',
subscription_format: 'text',
subscription_mapping_rule: '',
sync_interval_minutes: 1440,
remark: '',
};
const typeLabels: Record<WAFIPGroupType, string> = {
manual: '手动',
automatic: '自动',
subscription: '订阅',
};
const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
];
function buildDraft(group: WAFIPGroup | null): IPGroupDraft {
if (!group) {
return { ...emptyIPGroupDraft };
}
return {
name: group.name,
type: group.type,
enabled: group.enabled,
ip_list: group.ip_list ?? [],
ip_list_text: listToText(group.ip_list),
auto_config: group.auto_config ?? {},
auto_config_text: JSON.stringify(group.auto_config ?? {}, null, 2),
subscription_url: group.subscription_url ?? '',
subscription_format: group.subscription_format ?? 'text',
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
sync_interval_minutes: group.sync_interval_minutes || 1440,
remark: group.remark ?? '',
};
}
function buildPayload(draft: IPGroupDraft): WAFIPGroupPayload {
const autoConfig =
draft.type === 'automatic'
? parseAutomaticConfig(draft.auto_config_text)
: {};
return {
name: draft.name,
type: draft.type,
enabled: draft.enabled,
ip_list: parseTextareaList(draft.ip_list_text),
auto_config: autoConfig,
subscription_url: draft.subscription_url,
subscription_format: draft.subscription_format,
subscription_mapping_rule: draft.subscription_mapping_rule,
sync_interval_minutes: draft.sync_interval_minutes,
remark: draft.remark,
};
}
function parseAutomaticConfig(text: string): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
}
return parsed as Record<string, unknown>;
}
function appendAutomaticPresetRule(
autoConfigText: string,
rule: (typeof automaticPresetRules)[number],
) {
const parsed = JSON.parse(autoConfigText || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('自动配置必须是 JSON 对象。');
}
const config = parsed as Record<string, unknown>;
const rules = Array.isArray(config.rules) ? config.rules : [];
const exists = rules.some(
(item) =>
item &&
typeof item === 'object' &&
'expr' in item &&
(item as { expr?: unknown }).expr === rule.expr,
);
const nextRules = exists ? rules : [...rules, rule];
return JSON.stringify(
{
lookback_minutes:
typeof config.lookback_minutes === 'number'
? config.lookback_minutes
: 60,
...config,
rules: nextRules,
},
null,
2,
);
}
export function WAFIPGroupsPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
const [draft, setDraft] = useState<IPGroupDraft>(emptyIPGroupDraft);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [autoTestResult, setAutoTestResult] =
useState<WAFIPGroupAutoTestResult | null>(null);
const [isCapturedIPsModalOpen, setIsCapturedIPsModalOpen] = useState(false);
const groupsQuery = useQuery({
queryKey: ['waf', 'ip-groups'],
queryFn: getWAFIPGroups,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const selectedGroup = useMemo(
() =>
selectedID === 0
? null
: (groups.find((group) => group.id === selectedID) ??
groups[0] ??
null),
[groups, selectedID],
);
useEffect(() => {
if (selectedGroup) {
setSelectedID(selectedGroup.id);
setDraft(buildDraft(selectedGroup));
}
}, [selectedGroup]);
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
]);
};
const saveMutation = useMutation({
mutationFn: (payload: WAFIPGroupPayload) => {
if (selectedGroup) {
return updateWAFIPGroup(selectedGroup.id, payload);
}
return createWAFIPGroup(payload);
},
onSuccess: async (group) => {
setSelectedID(group.id);
setFeedback({ tone: 'success', message: 'IP 组已保存。' });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteWAFIPGroup,
onSuccess: async () => {
setSelectedID(null);
setFeedback({ tone: 'success', message: 'IP 组已删除。' });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const syncMutation = useMutation({
mutationFn: syncWAFIPGroup,
onSuccess: async (result) => {
setSelectedID(result.group.id);
setFeedback({ tone: 'success', message: result.message });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const testMutation = useMutation({
mutationFn: testWAFIPGroupAutoConfig,
onSuccess: (result) => {
setAutoTestResult(result);
setFeedback({
tone: result.matched_count > 0 ? 'success' : 'info',
message:
result.matched_count > 0
? `规则测试完成,命中 ${result.matched_count} 个 IP。`
: '规则测试完成,当前未命中任何 IP。',
});
},
onError: (error) => {
setAutoTestResult(null);
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
useEffect(() => {
setAutoTestResult(null);
}, [draft.type, draft.auto_config_text, selectedID]);
if (groupsQuery.isLoading) {
return <LoadingState />;
}
if (groupsQuery.isError) {
return (
<ErrorState
title="IP 组加载失败"
description={getErrorMessage(groupsQuery.error)}
/>
);
}
const saveDraft = () => {
try {
saveMutation.mutate(buildPayload(draft));
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
const testDraft = () => {
try {
testMutation.mutate({
auto_config: parseAutomaticConfig(draft.auto_config_text),
});
} catch (error) {
setAutoTestResult(null);
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
return (
<div className="space-y-6">
<PageHeader
title="IP 组"
description="维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。"
action={
<div className="flex flex-wrap gap-3">
<SecondaryButton type="button" onClick={() => router.push('/waf')}>
<ArrowLeft className="mr-2 h-4 w-4" />
返回 WAF
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setDraft({ ...emptyIPGroupDraft, name: '自定义 IP 组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建 IP 组
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<AppCard title="IP 组列表">
{groups.length === 0 && selectedID !== 0 ? (
<EmptyState title="暂无 IP 组" />
) : (
<div className="space-y-2">
{groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setSelectedID(group.id)}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition',
selectedGroup?.id === group.id
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<span className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
{group.name}
</span>
<span className="text-xs text-[var(--foreground-secondary)]">
{typeLabels[group.type]}
</span>
</span>
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
{group.enabled ? '启用' : '停用'} · {group.ip_list.length}{' '}
条 · 被引用 {group.referenced_by_rule_count} 次
</span>
</button>
))}
</div>
)}
</AppCard>
<AppCard
title={selectedGroup ? selectedGroup.name : '新建 IP 组'}
description={
draft.type === 'automatic'
? '自动 IP 组会按 Expr 规则定时从请求日志中聚合命中 IP。'
: '保存后可在 WAF 规则组黑白名单中引用。'
}
action={
<div className="flex flex-wrap gap-3">
{draft.type === 'automatic' ? (
<SecondaryButton
type="button"
disabled={testMutation.isPending}
onClick={testDraft}
>
<Play className="mr-2 h-4 w-4" />
{testMutation.isPending ? '测试中...' : '测试规则'}
</SecondaryButton>
) : null}
{selectedGroup?.type === 'subscription' ||
selectedGroup?.type === 'automatic' ? (
<SecondaryButton
type="button"
disabled={syncMutation.isPending}
onClick={() => syncMutation.mutate(selectedGroup.id)}
>
<Download className="mr-2 h-4 w-4" />
{syncMutation.isPending
? '执行中...'
: selectedGroup.type === 'automatic'
? '立即执行'
: '立即同步'}
</SecondaryButton>
) : null}
{selectedGroup?.type === 'automatic' ? (
<SecondaryButton
type="button"
onClick={() => setIsCapturedIPsModalOpen(true)}
>
<Eye className="mr-2 h-4 w-4" />
查看已抓取 IP
</SecondaryButton>
) : null}
<PrimaryButton
type="button"
disabled={saveMutation.isPending}
onClick={saveDraft}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? '保存中...' : '保存 IP 组'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="IP 组名称">
<ResourceInput
value={draft.name}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="类型">
<ResourceSelect
value={draft.type}
onChange={(event) =>
setDraft((current) => ({
...current,
type: event.target.value as WAFIPGroupType,
}))
}
>
<option value="manual">手动</option>
<option value="automatic">自动</option>
<option value="subscription">订阅</option>
</ResourceSelect>
</ResourceField>
<ToggleField
label="启用 IP 组"
description="关闭后保留配置,但发布时不会展开到 WAF 运行时名单。"
checked={draft.enabled}
onChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
<ResourceField label="备注">
<ResourceInput
value={draft.remark}
onChange={(event) =>
setDraft((current) => ({
...current,
remark: event.target.value,
}))
}
/>
</ResourceField>
</div>
{draft.type === 'subscription' ? (
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="订阅 URL">
<ResourceInput
value={draft.subscription_url}
placeholder="https://example.com/ip-list.txt"
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_url: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="订阅格式">
<ResourceSelect
value={draft.subscription_format}
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_format: event.target
.value as WAFIPGroupSubscriptionFormat,
}))
}
>
<option value="text">文本列表</option>
<option value="json">JSON</option>
</ResourceSelect>
</ResourceField>
<ResourceField
label="同步间隔(分钟)"
hint="最小 5 分钟,默认 1440 分钟。"
>
<ResourceInput
type="number"
min={5}
value={draft.sync_interval_minutes}
onChange={(event) =>
setDraft((current) => ({
...current,
sync_interval_minutes: Number(event.target.value),
}))
}
/>
</ResourceField>
<ResourceField
label="JSON 映射规则"
hint="留空表示根数组;示例:data.items[]。文本格式无需填写。"
>
<ResourceInput
value={draft.subscription_mapping_rule}
disabled={draft.subscription_format !== 'json'}
onChange={(event) =>
setDraft((current) => ({
...current,
subscription_mapping_rule: event.target.value,
}))
}
/>
</ResourceField>
</div>
) : null}
{draft.type === 'automatic' ? (
<div className="space-y-4">
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField
label="同步间隔(分钟)"
hint="定时从请求日志挖掘恶意 IP 的周期。最小 5 分钟,默认 1440 分钟。"
>
<ResourceInput
type="number"
min={5}
value={draft.sync_interval_minutes}
onChange={(event) =>
setDraft((current) => ({
...current,
sync_interval_minutes: Number(event.target.value),
}))
}
/>
</ResourceField>
</div>
<ResourceField
label="预设规则"
hint="表达式按单个 IP 的请求日志聚合指标计算。"
container="div"
>
<div className="flex flex-wrap gap-2">
{automaticPresetRules.map((rule) => (
<SecondaryButton
key={rule.expr}
type="button"
onClick={() => {
try {
setDraft((current) => ({
...current,
auto_config_text: appendAutomaticPresetRule(
current.auto_config_text,
rule,
),
}));
} catch (error) {
setFeedback({
tone: 'danger',
message: getErrorMessage(error),
});
}
}}
>
<Plus className="mr-2 h-4 w-4" />
{rule.name}
</SecondaryButton>
))}
</div>
</ResourceField>
<ResourceField
label="自动配置 JSON"
hint="可用字段:request_count、status_404_count、status_404_ratio、ip_host_count、ip_host_ratio。方法:StatusCount(code)、StatusRatio(code)。支持 ttl(秒,默认 -1 永久拉黑)。"
>
<ResourceTextarea
value={draft.auto_config_text}
className="min-h-64 font-mono"
onChange={(event) =>
setDraft((current) => ({
...current,
auto_config_text: event.target.value,
}))
}
/>
</ResourceField>
{autoTestResult ? (
<div className="space-y-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-[var(--foreground-primary)]">
测试结果
</div>
<div className="text-xs text-[var(--foreground-secondary)]">
回看 {autoTestResult.lookback_minutes} 分钟 · 规则{' '}
{autoTestResult.rule_count} 条
</div>
</div>
<InlineMessage
tone={
autoTestResult.matched_count > 0 ? 'success' : 'info'
}
message={
autoTestResult.matched_count > 0
? `命中 ${autoTestResult.matched_count} 个 IP。`
: '当前没有匹配到任何 IP。'
}
/>
{autoTestResult.matched_count > 0 ? (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-3">
<div className="mb-2 text-xs font-medium text-[var(--foreground-secondary)]">
命中 IP 列表
</div>
<pre className="overflow-x-auto text-sm break-all whitespace-pre-wrap text-[var(--foreground-primary)]">
{autoTestResult.matched_ips.join('\n')}
</pre>
</div>
) : null}
</div>
) : null}
</div>
) : (
<ResourceField
label="IP / IP 段"
hint={
draft.type === 'subscription'
? '订阅同步会覆盖此列表;也可以先手动保存当前内容。'
: '支持单个 IP 或 CIDR,每行一个。'
}
>
<ResourceTextarea
value={draft.ip_list_text}
className="min-h-72 font-mono"
placeholder={'203.0.113.10\n198.51.100.0/24'}
onChange={(event) =>
setDraft((current) => ({
...current,
ip_list_text: event.target.value,
}))
}
/>
</ResourceField>
)}
{selectedGroup ? (
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
<div className="text-sm text-[var(--foreground-secondary)]">
{selectedGroup.last_sync_status
? `${selectedGroup.last_sync_status}: ${selectedGroup.last_sync_message}`
: '尚无同步记录'}
</div>
<DangerButton
type="button"
disabled={deleteMutation.isPending}
onClick={() => {
if (
window.confirm(
`确认删除 IP 组 ${selectedGroup.name} 吗?`,
)
) {
deleteMutation.mutate(selectedGroup.id);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DangerButton>
</div>
) : null}
</div>
</AppCard>
</div>
{selectedGroup?.type === 'automatic' ? (
<AppModal
isOpen={isCapturedIPsModalOpen}
title={`已抓取 IP 列表 - ${selectedGroup.name}`}
description="展示当前自动挖掘并放入黑名单的 IP 列表及其到期状态。"
size="lg"
onClose={() => setIsCapturedIPsModalOpen(false)}
footer={
<div className="flex justify-end">
<SecondaryButton
type="button"
onClick={() => setIsCapturedIPsModalOpen(false)}
>
关闭
</SecondaryButton>
</div>
}
>
<div className="space-y-4">
{!selectedGroup.ext_ips || selectedGroup.ext_ips.length === 0 ? (
<EmptyState
title="暂无抓取记录"
description="该自动规则暂未抓取或命中任何恶意 IP,点击「立即执行」手动触发抓取。"
/>
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)]">
<thead className="bg-[var(--surface-muted)]">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
IP 地址
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
抓取时间
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-[var(--foreground-secondary)]">
封禁剩余时间
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)] bg-[var(--surface-elevated)]">
{selectedGroup.ext_ips.map((item) => {
const autoConfig = selectedGroup.auto_config as
| { ttl?: number }
| undefined;
const ttl = autoConfig?.ttl ?? -1;
let expireText = '永久';
if (ttl > 0) {
const capturedDate = new Date(item.captured_at);
const expireDate = new Date(
capturedDate.getTime() + ttl * 1000,
);
const now = new Date();
if (expireDate.getTime() <= now.getTime()) {
expireText = '已过期';
} else {
const diffMs = expireDate.getTime() - now.getTime();
const diffMins = Math.round(diffMs / (60 * 1000));
if (diffMins < 60) {
expireText = `${diffMins} 分钟后`;
} else {
const diffHours = Math.round(diffMins / 60);
expireText = `${diffHours} 小时后`;
}
}
}
return (
<tr key={item.ip}>
<td className="px-4 py-3 font-mono text-sm font-medium whitespace-nowrap text-[var(--foreground-primary)]">
{item.ip}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-[var(--foreground-secondary)]">
{new Date(item.captured_at).toLocaleString()}
</td>
<td
className={cn(
'px-4 py-3 text-sm font-medium whitespace-nowrap',
ttl > 0 ? 'text-amber-500' : 'text-emerald-500',
)}
>
{expireText}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</AppModal>
) : null}
</div>
);
}
@@ -0,0 +1,219 @@
import {useEffect, useState} from 'react';
import type {ProxyRoutePoWConfig} from '@/features/proxy-routes/types';
import {
ResourceField,
ResourceInput,
ResourceTextarea,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import {listToText, parseTextareaList} from './helpers';
export function PowTabPanel({
enabled,
config,
onChange,
}: {
enabled: boolean;
config: ProxyRoutePoWConfig;
onChange: (enabled: boolean, config: ProxyRoutePoWConfig) => void;
}) {
const [draft, setDraft] = useState(() => ({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
}));
useEffect(() => {
setDraft({
whitelist: {
ips: listToText(config.whitelist?.ips),
ip_cidrs: listToText(config.whitelist?.ip_cidrs),
paths: listToText(config.whitelist?.paths),
path_regexes: listToText(config.whitelist?.path_regexes),
user_agents: listToText(config.whitelist?.user_agents),
},
blacklist: {
ips: listToText(config.blacklist?.ips),
ip_cidrs: listToText(config.blacklist?.ip_cidrs),
paths: listToText(config.blacklist?.paths),
path_regexes: listToText(config.blacklist?.path_regexes),
user_agents: listToText(config.blacklist?.user_agents),
},
});
}, [config]);
const updateConfig = (
newEnabled: boolean,
newConfig: Partial<ProxyRoutePoWConfig>,
newDraft?: typeof draft,
) => {
const nextConfig = { ...config, ...newConfig };
if (newDraft) {
setDraft(newDraft);
nextConfig.whitelist = {
ips: parseTextareaList(newDraft.whitelist.ips),
ip_cidrs: parseTextareaList(newDraft.whitelist.ip_cidrs),
paths: parseTextareaList(newDraft.whitelist.paths),
path_regexes: parseTextareaList(newDraft.whitelist.path_regexes),
user_agents: parseTextareaList(newDraft.whitelist.user_agents),
};
nextConfig.blacklist = {
ips: parseTextareaList(newDraft.blacklist.ips),
ip_cidrs: parseTextareaList(newDraft.blacklist.ip_cidrs),
paths: parseTextareaList(newDraft.blacklist.paths),
path_regexes: parseTextareaList(newDraft.blacklist.path_regexes),
user_agents: parseTextareaList(newDraft.blacklist.user_agents),
};
}
onChange(newEnabled, nextConfig);
};
const updateList = (
scope: 'whitelist' | 'blacklist',
key: keyof ProxyRoutePoWConfig['whitelist'],
value: string,
) => {
const nextDraft = {
...draft,
[scope]: {
...draft[scope],
[key]: value,
},
};
updateConfig(enabled, {}, nextDraft);
};
return (
<div className="space-y-6">
<ToggleField
label="启用 PoW 防护"
description="启用后,命中该规则组的请求需要完成浏览器计算挑战。"
checked={enabled}
onChange={(newEnabled) => updateConfig(newEnabled, {})}
/>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<ResourceField label="算法">
<select
value={config.algorithm}
onChange={(event) =>
updateConfig(enabled, {
algorithm: event.target.value as 'fast' | 'slow',
})
}
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--border-strong)]"
>
<option value="fast">Fast</option>
<option value="slow">Slow</option>
</select>
</ResourceField>
<ResourceField label="难度">
<ResourceInput
type="number"
min={1}
max={16}
value={config.difficulty}
onChange={(event) =>
updateConfig(enabled, { difficulty: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="会话 TTL">
<ResourceInput
type="number"
min={60}
value={config.session_ttl}
onChange={(event) =>
updateConfig(enabled, { session_ttl: Number(event.target.value) })
}
/>
</ResourceField>
<ResourceField label="挑战 TTL">
<ResourceInput
type="number"
min={30}
value={config.challenge_ttl}
onChange={(event) =>
updateConfig(enabled, {
challenge_ttl: Number(event.target.value),
})
}
/>
</ResourceField>
</div>
<div className="grid gap-5 xl:grid-cols-2">
{(['whitelist', 'blacklist'] as const).map((scope) => (
<div
key={scope}
className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
{scope === 'whitelist'
? '白名单(跳过 PoW)'
: '黑名单(必须 PoW)'}
</h3>
<div className="mt-4 space-y-4">
<ResourceField label="IP">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ips}
onChange={(event) =>
updateList(scope, 'ips', event.target.value)
}
/>
</ResourceField>
<ResourceField label="IP CIDR">
<ResourceTextarea
className="min-h-20"
value={draft[scope].ip_cidrs}
onChange={(event) =>
updateList(scope, 'ip_cidrs', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径">
<ResourceTextarea
className="min-h-20"
value={draft[scope].paths}
onChange={(event) =>
updateList(scope, 'paths', event.target.value)
}
/>
</ResourceField>
<ResourceField label="路径正则">
<ResourceTextarea
className="min-h-20"
value={draft[scope].path_regexes}
onChange={(event) =>
updateList(scope, 'path_regexes', event.target.value)
}
/>
</ResourceField>
<ResourceField label="User-Agent">
<ResourceTextarea
className="min-h-20"
value={draft[scope].user_agents}
onChange={(event) =>
updateList(scope, 'user_agents', event.target.value)
}
/>
</ResourceField>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import {cn} from '@/lib/utils/cn';
export function RuleChip({
label,
tone,
onRemove,
}: {
label: string;
tone: 'whitelist' | 'blacklist';
onRemove: () => void;
}) {
return (
<span
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm',
tone === 'whitelist'
? 'border-emerald-500/30 bg-emerald-500/18 text-[var(--foreground-primary)]'
: 'border-rose-500/30 bg-rose-500/18 text-[var(--foreground-primary)]',
)}
>
<span className="break-all">{label}</span>
<button
type="button"
onClick={onRemove}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current/20 text-xs text-[var(--foreground-primary)] transition hover:bg-black/10"
aria-label={`移除 ${label}`}
>
×
</button>
</span>
);
}
@@ -0,0 +1,308 @@
import {useEffect, useMemo, useState} from 'react';
import {Search} from 'lucide-react';
import {AppModal} from '@/components/ui/app-modal';
import type {WAFIPGroup} from '@/features/waf/types';
import {
PrimaryButton,
ResourceField,
ResourceTextarea,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import {cn} from '@/lib/utils/cn';
import {normalizeItems} from './helpers';
import type {CountryOption, RuleDimension, RuleListType, RuleModalState,} from './types';
export function RuleEntryModal({
state,
countryOptions,
ipGroups,
pending,
onClose,
onChange,
onSubmit,
}: {
state: RuleModalState;
countryOptions: CountryOption[];
ipGroups: WAFIPGroup[];
pending: boolean;
onClose: () => void;
onChange: (patch: Partial<RuleModalState>) => void;
onSubmit: () => void;
}) {
const [keyword, setKeyword] = useState('');
useEffect(() => {
if (!state.open) {
return;
}
setKeyword('');
}, [state.dimension, state.open]);
const selectedCountrySet = useMemo(
() => new Set(state.countryValues),
[state.countryValues],
);
const selectedIPGroupSet = useMemo(
() => new Set(state.ipGroupIDs),
[state.ipGroupIDs],
);
const filteredCountries = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
return countryOptions
.filter((option) => !normalized || option.searchText.includes(normalized))
.sort((left, right) => {
const leftSelected = selectedCountrySet.has(left.code) ? 1 : 0;
const rightSelected = selectedCountrySet.has(right.code) ? 1 : 0;
return (
rightSelected - leftSelected || left.code.localeCompare(right.code)
);
});
}, [countryOptions, keyword, selectedCountrySet]);
const toggleCountry = (code: string) => {
const values = selectedCountrySet.has(code)
? state.countryValues.filter((item) => item !== code)
: normalizeItems([...state.countryValues, code]);
onChange({ countryValues: values });
};
const selectFiltered = () => {
onChange({
countryValues: normalizeItems([
...state.countryValues,
...filteredCountries.map((option) => option.code),
]),
});
};
const clearCountries = () => onChange({ countryValues: [] });
const toggleIPGroup = (id: number) => {
const values = selectedIPGroupSet.has(id)
? state.ipGroupIDs.filter((item) => item !== id)
: [...state.ipGroupIDs, id].sort((left, right) => left - right);
onChange({ ipGroupIDs: values });
};
const typeLabel = state.listType === 'blacklist' ? '黑名单' : '白名单';
const dimensionLabel =
state.dimension === 'ip'
? 'IP'
: state.dimension === 'ip_group'
? 'IP 组'
: '地域';
return (
<AppModal
isOpen={state.open}
title={`添加${typeLabel}规则`}
description={`当前准备新增 ${dimensionLabel} 维度的${typeLabel}项。`}
size="lg"
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
取消
</SecondaryButton>
<PrimaryButton type="button" disabled={pending} onClick={onSubmit}>
{pending ? '处理中...' : '添加'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="类型" container="div">
<div className="grid grid-cols-2 gap-3">
{[
{ value: 'blacklist', label: '黑名单' },
{ value: 'whitelist', label: '白名单' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() =>
onChange({ listType: option.value as RuleListType })
}
className={cn(
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
state.listType === option.value
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
)}
>
{option.label}
</button>
))}
</div>
</ResourceField>
<ResourceField label="维度" container="div">
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'ip', label: 'IP' },
{ value: 'ip_group', label: 'IP 组' },
{ value: 'country', label: '地域' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() =>
onChange({ dimension: option.value as RuleDimension })
}
className={cn(
'rounded-2xl border px-4 py-3 text-sm font-medium transition',
state.dimension === option.value
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
)}
>
{option.label}
</button>
))}
</div>
</ResourceField>
</div>
{state.dimension === 'ip' ? (
<ResourceField
label="IP / IP 段"
hint="支持单个 IP、CIDR,或使用换行/逗号一次添加多个。"
>
<ResourceTextarea
value={state.ipValue}
placeholder="例如 1.1.1.1 或 192.168.0.0/24"
onChange={(event) => onChange({ ipValue: event.target.value })}
/>
</ResourceField>
) : state.dimension === 'ip_group' ? (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
选择 IP 组
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
发布版本只保存引用 ID,IP 组成员由 Agent 按 checksum
差异同步。
</p>
</div>
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
已选 {state.ipGroupIDs.length}
</span>
</div>
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
{ipGroups.length > 0 ? (
ipGroups.map((group) => {
const selected = selectedIPGroupSet.has(group.id);
return (
<label
key={group.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selected
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
)}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleIPGroup(group.id)}
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
{group.name}
</span>
<span className="mt-1 block text-xs text-[var(--foreground-secondary)]">
{group.type} · {group.ip_list.length} 条 ·{' '}
{group.enabled ? '启用' : '停用'}
</span>
</span>
</label>
);
})
) : (
<p className="text-sm text-[var(--foreground-muted)]">
暂无 IP 组,请先进入 IP 组管理页面创建。
</p>
)}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索国家代码或中文名"
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
/>
<button
type="button"
onClick={selectFiltered}
className="text-xs font-medium text-[var(--brand-primary)]"
>
全选当前
</button>
<button
type="button"
onClick={clearCountries}
className="text-xs font-medium text-[var(--foreground-secondary)]"
>
清空
</button>
</div>
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
地域多选
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
选项显示为「国家代码 国家中文名」。
</p>
</div>
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
已选 {state.countryValues.length}
</span>
</div>
<div className="mt-4 max-h-80 space-y-2 overflow-y-auto pr-1">
{filteredCountries.map((option) => {
const selected = selectedCountrySet.has(option.code);
return (
<label
key={option.code}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selected
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-panel)] hover:bg-[var(--surface-muted)]',
)}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleCountry(option.code)}
className="h-4 w-4 rounded border-[var(--border-default)] accent-[var(--brand-primary)]"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--foreground-primary)]">
{option.label}
</span>
</span>
</label>
);
})}
</div>
</div>
</div>
)}
</div>
</AppModal>
);
}
@@ -0,0 +1,64 @@
import {RuleChip} from './rule-chip';
export function RuleListSection({
title,
description,
items,
groupItems = [],
tone,
emptyText,
onRemove,
onRemoveGroup,
}: {
title: string;
description: string;
items: string[];
groupItems?: Array<{ id: number; name: string; enabled: boolean }>;
tone: 'whitelist' | 'blacklist';
emptyText: string;
onRemove: (item: string) => void;
onRemoveGroup?: (id: number) => void;
}) {
const total = items.length + groupItems.length;
return (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
{title}
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
{description}
</p>
</div>
<span className="shrink-0 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-semibold text-[var(--foreground-primary)]">
{total}
</span>
</div>
<div className="mt-5">
{total > 0 ? (
<div className="flex flex-wrap gap-2">
{groupItems.map((group) => (
<RuleChip
key={`group-${group.id}`}
label={`IP组: ${group.name}${group.enabled ? '' : ' (停用)'}`}
tone={tone}
onRemove={() => onRemoveGroup?.(group.id)}
/>
))}
{items.map((item) => (
<RuleChip
key={item}
label={item}
tone={tone}
onRemove={() => onRemove(item)}
/>
))}
</div>
) : (
<p className="text-sm text-[var(--foreground-muted)]">{emptyText}</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Search } from 'lucide-react';
import { Drawer } from '@/components/ui/drawer';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import {
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import type { WAFRuleGroup } from '@/features/waf/types';
import { cn } from '@/lib/utils/cn';
export function SiteApplyDrawer({
group,
routes,
open,
onOpenChange,
onSave,
pending,
}: {
group: WAFRuleGroup | null;
routes: ProxyRouteItem[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (ids: number[]) => void;
pending: boolean;
}) {
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
useEffect(() => {
setSelectedIDs(group?.applied_site_ids ?? []);
setKeyword('');
}, [group, open]);
const filteredRoutes = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
if (!normalized) {
return routes;
}
return routes.filter((route) =>
[route.site_name, route.primary_domain, ...route.domains]
.join(' ')
.toLowerCase()
.includes(normalized),
);
}, [keyword, routes]);
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const toggleID = (id: number) => {
setSelectedIDs((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id].sort((left, right) => left - right),
);
};
const selectFiltered = () => {
const next = new Set(selectedIDs);
filteredRoutes.forEach((route) => next.add(route.id));
setSelectedIDs([...next].sort((left, right) => left - right));
};
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
direction="right"
title={group ? `应用 ${group.name}` : '应用规则组'}
description="选择这个自定义规则组要叠加到哪些网站。"
footer={
<div className="flex justify-end gap-3">
<SecondaryButton type="button" onClick={() => onOpenChange(false)}>
取消
</SecondaryButton>
<PrimaryButton
type="button"
disabled={!group || pending}
onClick={() => onSave(selectedIDs)}
>
{pending ? '保存中...' : '保存应用范围'}
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3">
<Search className="h-4 w-4 text-[var(--foreground-secondary)]" />
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索网站或域名"
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--foreground-primary)] outline-none placeholder:text-[var(--foreground-muted)]"
/>
<button
type="button"
onClick={selectFiltered}
className="text-xs font-medium text-[var(--brand-primary)]"
>
全选当前
</button>
</div>
<div className="space-y-2">
{filteredRoutes.map((route) => (
<button
key={route.id}
type="button"
onClick={() => toggleID(route.id)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition',
selectedSet.has(route.id)
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<span
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border',
selectedSet.has(route.id)
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'border-[var(--border-default)]',
)}
>
{selectedSet.has(route.id) ? (
<Check className="h-3 w-3" />
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--foreground-primary)]">
{route.site_name}
</span>
<span className="block truncate text-xs text-[var(--foreground-secondary)]">
{route.domains.join(', ')}
</span>
</span>
</button>
))}
</div>
</div>
</Drawer>
);
}
@@ -0,0 +1,36 @@
import type { LucideIcon } from 'lucide-react';
export function StatCard({
icon: Icon,
label,
value,
hint,
}: {
icon: LucideIcon;
label: string;
value: string;
hint?: string;
}) {
return (
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-medium tracking-[0.22em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="text-2xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
</div>
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-primary)]">
<Icon className="h-5 w-5" />
</span>
</div>
{hint && (
<p className="mt-3 text-sm leading-6 text-[var(--foreground-secondary)]">
{hint}
</p>
)}
</div>
);
}
@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils/cn';
export function TabButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'group rounded-[24px] border px-4 py-4 text-left transition',
active
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] shadow-[var(--shadow-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:border-[var(--border-strong)] hover:bg-[var(--surface-muted)]',
)}
>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{label}
</p>
</button>
);
}
@@ -0,0 +1,45 @@
import type { WAFRuleGroupPayload } from '@/features/waf/types';
export type FeedbackState = {
tone: 'success' | 'danger' | 'info';
message: string;
};
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
export type RuleListType = 'whitelist' | 'blacklist';
export type RuleDimension = 'ip' | 'ip_group' | 'country';
export type ListFieldKey =
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist';
export type CountryOption = {
code: string;
zhName: string;
label: string;
searchText: string;
};
export type RuleModalState = {
open: boolean;
listType: RuleListType;
dimension: RuleDimension;
ipValue: string;
ipGroupIDs: number[];
countryValues: string[];
};
export type RuleListRenderable = Pick<
WAFRuleGroupPayload,
| 'ip_whitelist'
| 'ip_blacklist'
| 'ip_whitelist_group_ids'
| 'ip_blacklist_group_ids'
| 'country_whitelist'
| 'country_blacklist'
| 'region_whitelist'
| 'region_blacklist'
>;
@@ -0,0 +1,742 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Globe2, Network, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import {
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import {
createWAFRuleGroup,
deleteWAFRuleGroup,
getWAFIPGroups,
getWAFRuleGroups,
replaceWAFRuleGroupSites,
updateWAFRuleGroup,
} from '@/features/waf/api/waf';
import type {
WAFIPGroup,
WAFRuleGroup,
WAFRuleGroupPayload,
} from '@/features/waf/types';
import { cn } from '@/lib/utils/cn';
import { RuleEntryModal } from './rule-entry-modal';
import { SiteApplyDrawer } from './site-apply-drawer';
import { PowTabPanel } from './pow-tab-panel';
import { RuleListSection } from './rule-list-section';
import { TabButton } from './tab-button';
import {
buildCountryOptions,
buildDraft,
countRuleEntries,
defaultRuleModalState,
emptyDraft,
formatCountryItem,
getErrorMessage,
getListFieldKey,
normalizeItems,
tabItems,
textToList,
updateDraftList,
} from './helpers';
import type {
FeedbackState,
ListFieldKey,
RuleModalState,
WAFTab,
} from './types';
export function WAFPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState<WAFTab>('basic');
const [draft, setDraft] = useState<WAFRuleGroupPayload>(emptyDraft);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [applyGroup, setApplyGroup] = useState<WAFRuleGroup | null>(null);
const [ruleModal, setRuleModal] = useState<RuleModalState>(
defaultRuleModalState,
);
const groupsQuery = useQuery({
queryKey: ['waf', 'rule-groups'],
queryFn: getWAFRuleGroups,
});
const ipGroupsQuery = useQuery({
queryKey: ['waf', 'ip-groups'],
queryFn: getWAFIPGroups,
});
const routesQuery = useQuery({
queryKey: ['proxy-routes'],
queryFn: getProxyRoutes,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const ipGroups = useMemo(
() => ipGroupsQuery.data ?? [],
[ipGroupsQuery.data],
);
const routes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
const countryOptions = useMemo(() => buildCountryOptions(), []);
const countryLabelMap = useMemo(
() => new Map(countryOptions.map((option) => [option.code, option.label])),
[countryOptions],
);
const selectedGroup = useMemo(
() =>
selectedID === 0
? null
: (groups.find((group) => group.id === selectedID) ??
groups[0] ??
null),
[groups, selectedID],
);
useEffect(() => {
if (selectedGroup) {
setSelectedID(selectedGroup.id);
setDraft(buildDraft(selectedGroup));
}
}, [selectedGroup]);
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['waf', 'rule-groups'] }),
queryClient.invalidateQueries({ queryKey: ['waf', 'ip-groups'] }),
queryClient.invalidateQueries({ queryKey: ['config-versions', 'diff'] }),
]);
};
const saveMutation = useMutation({
mutationFn: (payload: WAFRuleGroupPayload) => {
if (selectedGroup) {
return updateWAFRuleGroup(selectedGroup.id, payload);
}
return createWAFRuleGroup(payload);
},
onSuccess: async (group) => {
setSelectedID(group.id);
setFeedback({ tone: 'success', message: 'WAF 规则组已保存。' });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: deleteWAFRuleGroup,
onSuccess: async () => {
setSelectedID(null);
setFeedback({ tone: 'success', message: 'WAF 规则组已删除。' });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const applyMutation = useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
replaceWAFRuleGroupSites(id, ids),
onSuccess: async () => {
setApplyGroup(null);
setFeedback({ tone: 'success', message: '规则组应用范围已更新。' });
await invalidate();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
if (
groupsQuery.isLoading ||
routesQuery.isLoading ||
ipGroupsQuery.isLoading
) {
return <LoadingState />;
}
if (groupsQuery.isError) {
return (
<ErrorState
title="WAF 加载失败"
description={getErrorMessage(groupsQuery.error)}
/>
);
}
if (routesQuery.isError) {
return (
<ErrorState
title="网站列表加载失败"
description={getErrorMessage(routesQuery.error)}
/>
);
}
if (ipGroupsQuery.isError) {
return (
<ErrorState
title="IP 组加载失败"
description={getErrorMessage(ipGroupsQuery.error)}
/>
);
}
if (!selectedGroup && groups.length === 0) {
return (
<EmptyState
title="WAF 尚未初始化"
description="刷新页面后系统会自动创建全局规则组。"
/>
);
}
const currentRuleCount = countRuleEntries(draft);
const appliedSiteNames = selectedGroup?.is_global
? ['全部网站']
: (selectedGroup?.applied_site_ids ?? [])
.map(
(id) =>
routes.find((route) => route.id === id)?.site_name ?? `网站 #${id}`,
)
.sort((left, right) => left.localeCompare(right));
const ipGroupByID = new Map(ipGroups.map((group) => [group.id, group]));
const whitelistGroupItems = draft.ip_whitelist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((group): group is WAFIPGroup => Boolean(group));
const blacklistGroupItems = draft.ip_blacklist_group_ids
.map((id) => ipGroupByID.get(id))
.filter((group): group is WAFIPGroup => Boolean(group));
const openRuleModal = () => {
setRuleModal({ ...defaultRuleModalState, open: true });
};
const closeRuleModal = () => {
setRuleModal(defaultRuleModalState);
};
const applyRuleModal = () => {
const values =
ruleModal.dimension === 'ip'
? textToList(ruleModal.ipValue)
: ruleModal.dimension === 'ip_group'
? ruleModal.ipGroupIDs.map(String)
: normalizeItems(ruleModal.countryValues);
if (values.length === 0) {
setFeedback({
tone: 'danger',
message:
ruleModal.dimension === 'ip'
? '请先输入 IP 或 IP 段。'
: ruleModal.dimension === 'ip_group'
? '请先选择 IP 组。'
: '请先选择地域。',
});
return;
}
const listKey = getListFieldKey(ruleModal.listType, ruleModal.dimension);
setDraft((current) =>
updateDraftList(current, listKey, (items) =>
normalizeItems([...items, ...values]),
),
);
setFeedback({
tone: 'info',
message: '名单项已添加到当前草稿,保存后生效。',
});
closeRuleModal();
setActiveTab('lists');
};
const removeRuleItem = (key: ListFieldKey, value: string) => {
setDraft((current) =>
updateDraftList(current, key, (items) =>
items.filter((item) => item !== value),
),
);
};
const removeRuleGroup = (key: ListFieldKey, id: number) => {
setDraft((current) =>
updateDraftList(current, key, (items) =>
items.filter((item) => item !== String(id)),
),
);
};
const overviewItems: Array<{ label: string; value: ReactNode }> = [
{
label: '当前规则组',
value: selectedGroup ? selectedGroup.name : '新建规则组',
},
{ label: '启用状态', value: draft.enabled ? '启用中' : '已停用' },
{ label: '当前规则数', value: `${currentRuleCount} 条` },
{
label: '生效范围',
value: selectedGroup?.is_global
? '全部网站'
: `${selectedGroup?.applied_site_count ?? 0} 个网站`,
},
{
label: '拦截返回',
value: draft.block_response_body.trim()
? `${draft.block_status_code} + 自定义页面`
: `${draft.block_status_code} 状态码`,
},
{
label: '最后更新',
value: selectedGroup?.updated_at
? new Date(selectedGroup.updated_at).toLocaleString('zh-CN')
: '未保存',
},
];
return (
<>
<div className="space-y-6">
<PageHeader
title="WAF"
description="按规则组维护 WAF 与 PoW 防护规则,全局规则组始终应用到所有网站。"
action={
<div className="flex flex-wrap gap-3">
<SecondaryButton
type="button"
onClick={() => router.push('/waf/ip-groups')}
>
<Network className="mr-2 h-4 w-4" />
管理 IP 组
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
setSelectedID(0);
setActiveTab('basic');
setDraft({ ...emptyDraft, name: '自定义规则组' });
}}
>
<Plus className="mr-2 h-4 w-4" />
新建规则组
</PrimaryButton>
</div>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<AppCard title="规则组">
<div className="space-y-2">
{groups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => {
setSelectedID(group.id);
setActiveTab('basic');
}}
className={cn(
'w-full rounded-2xl border px-4 py-3 text-left transition',
selectedGroup?.id === group.id
? 'border-[var(--border-strong)] bg-[var(--accent-soft)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] hover:bg-[var(--surface-muted)]',
)}
>
<span className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2">
{group.is_global ? (
<Globe2 className="h-4 w-4" />
) : (
<ShieldCheck className="h-4 w-4" />
)}
<span className="truncate text-sm font-semibold text-[var(--foreground-primary)]">
{group.name}
</span>
</span>
<span className="text-xs text-[var(--foreground-secondary)]">
{group.enabled ? '启用' : '停用'}
</span>
</span>
<span className="mt-2 block text-xs text-[var(--foreground-secondary)]">
{group.is_global
? '应用全部网站'
: `已应用 ${group.applied_site_count} 个网站`}{' '}
· {countRuleEntries(group)} 条规则
</span>
</button>
))}
</div>
</AppCard>
<AppCard
title={selectedGroup ? selectedGroup.name : '新建规则组'}
description="简介:白名单命中后直接放行;未命中白名单时继续判断黑名单。"
action={
<div className="flex flex-wrap gap-3">
{selectedGroup && !selectedGroup.is_global ? (
<SecondaryButton
type="button"
onClick={() => setApplyGroup(selectedGroup)}
>
一键应用
</SecondaryButton>
) : null}
<PrimaryButton
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(draft)}
>
<Save className="mr-2 h-4 w-4" />
{saveMutation.isPending ? '保存中...' : '保存规则组'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-4">
{tabItems.map((tab) => (
<TabButton
key={tab.id}
label={tab.label}
active={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
/>
))}
</div>
{activeTab === 'basic' ? (
<div className="space-y-5">
<div className="grid gap-5 xl:grid-cols-2">
<ResourceField label="规则组名称">
<ResourceInput
value={draft.name}
disabled={selectedGroup?.is_global}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value,
}))
}
/>
</ResourceField>
<ToggleField
label="启用规则组"
description="关闭后保留配置,但不会参与匹配。"
checked={draft.enabled}
onChange={(checked) =>
setDraft((current) => ({
...current,
enabled: checked,
}))
}
/>
<ResourceField
label="备注"
className="xl:col-span-2"
hint="用于记录规则组用途、业务说明或变更备注。"
>
<ResourceInput
value={draft.remark}
onChange={(event) =>
setDraft((current) => ({
...current,
remark: event.target.value,
}))
}
/>
</ResourceField>
</div>
<div className="grid gap-5">
<div className="space-y-5">
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
配置总览
</h3>
</div>
<span className="rounded-full border border-[var(--border-default)] px-2.5 py-1 text-xs font-medium text-[var(--foreground-secondary)]">
{selectedGroup?.is_global ? '全局' : '自定义'}
</span>
</div>
<dl className="mt-5 grid gap-4 sm:grid-cols-2">
{overviewItems.map((item) => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-4"
>
<dt className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
{item.label}
</dt>
<dd className="mt-2 text-sm font-medium text-[var(--foreground-primary)]">
{item.value}
</dd>
</div>
))}
</dl>
<div className="mt-5">
<p className="text-xs font-medium tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
当前应用网站
</p>
<div className="mt-3 flex flex-wrap gap-2">
{appliedSiteNames.length > 0 ? (
appliedSiteNames.map((name) => (
<span
key={name}
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-2 text-sm text-[var(--foreground-secondary)]"
>
{name}
</span>
))
) : (
<span className="rounded-2xl border border-dashed border-[var(--border-default)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
尚未绑定网站,可点击右上角「一键应用」进行配置。
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
) : null}
{activeTab === 'lists' ? (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
黑白名单规则
</h3>
</div>
<PrimaryButton type="button" onClick={openRuleModal}>
<Plus className="mr-2 h-4 w-4" />
添加
</PrimaryButton>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RuleListSection
title="IP 白名单"
description="命中后直接放行,不再继续判断黑名单。"
items={draft.ip_whitelist}
groupItems={whitelistGroupItems}
tone="whitelist"
emptyText="暂无 IP 白名单规则。"
onRemove={(item) => removeRuleItem('ip_whitelist', item)}
onRemoveGroup={(id) =>
removeRuleGroup('ip_whitelist_group_ids', id)
}
/>
<RuleListSection
title="IP 黑名单"
description="未命中白名单时,命中这些 IP / IP 段将被拦截。"
items={draft.ip_blacklist}
groupItems={blacklistGroupItems}
tone="blacklist"
emptyText="暂无 IP 黑名单规则。"
onRemove={(item) => removeRuleItem('ip_blacklist', item)}
onRemoveGroup={(id) =>
removeRuleGroup('ip_blacklist_group_ids', id)
}
/>
<RuleListSection
title="地域白名单"
description="显示格式为国家代码与中文名,命中后直接放行。"
items={draft.country_whitelist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="whitelist"
emptyText="暂无地域白名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_whitelist', code);
}}
/>
<RuleListSection
title="地域黑名单"
description="当请求未命中白名单时,命中这些地域将被拦截。"
items={draft.country_blacklist.map((code) =>
formatCountryItem(code, countryLabelMap),
)}
tone="blacklist"
emptyText="暂无地域黑名单规则。"
onRemove={(item) => {
const code = item.split(' ')[0] ?? item;
removeRuleItem('country_blacklist', code);
}}
/>
</div>
</div>
) : null}
{activeTab === 'pow' ? (
<PowTabPanel
key={selectedID}
enabled={draft.pow_enabled}
config={draft.pow_config}
onChange={(enabled, config) =>
setDraft((current) => ({
...current,
pow_enabled: enabled,
pow_config: config,
}))
}
/>
) : null}
{activeTab === 'block' ? (
<div className="grid gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-5">
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<h3 className="text-sm font-semibold text-[var(--foreground-primary)]">
拦截返回状态码
</h3>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
建议使用 403、418、451 等明确表达策略拦截含义的状态码。
</p>
<div className="mt-4 space-y-4">
<ResourceField label="状态码">
<ResourceInput
type="number"
min={400}
max={599}
value={draft.block_status_code}
onChange={(event) =>
setDraft((current) => ({
...current,
block_status_code: Number(event.target.value),
}))
}
/>
</ResourceField>
<div className="flex flex-wrap gap-2">
{[403, 418, 451, 503].map((code) => (
<button
key={code}
type="button"
onClick={() =>
setDraft((current) => ({
...current,
block_status_code: code,
}))
}
className={cn(
'rounded-full border px-3 py-2 text-sm transition',
draft.block_status_code === code
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-panel)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-muted)]',
)}
>
{code}
</button>
))}
</div>
</div>
</div>
</div>
<div className="rounded-[26px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5">
<ResourceField
label="拦截页面"
hint="支持直接输入 HTML 或纯文本。留空时只返回状态码。"
>
<ResourceTextarea
value={draft.block_response_body}
className="min-h-72"
placeholder="<html><body><h1>Request blocked</h1></body></html>"
onChange={(event) =>
setDraft((current) => ({
...current,
block_response_body: event.target.value,
}))
}
/>
</ResourceField>
</div>
</div>
) : null}
<div className="flex flex-wrap justify-between gap-3 border-t border-[var(--border-default)] pt-6">
<div>
{selectedGroup && !selectedGroup.is_global ? (
<DangerButton
type="button"
disabled={deleteMutation.isPending}
onClick={() => {
if (
window.confirm(
`确认删除 WAF 规则组 ${selectedGroup.name} 吗?`,
)
) {
deleteMutation.mutate(selectedGroup.id);
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</DangerButton>
) : null}
</div>
</div>
</div>
</AppCard>
</div>
</div>
<RuleEntryModal
state={ruleModal}
countryOptions={countryOptions}
ipGroups={ipGroups}
pending={saveMutation.isPending}
onClose={closeRuleModal}
onChange={(patch) =>
setRuleModal((current) => ({ ...current, ...patch, open: true }))
}
onSubmit={applyRuleModal}
/>
<SiteApplyDrawer
group={applyGroup}
routes={routes}
open={Boolean(applyGroup)}
pending={applyMutation.isPending}
onOpenChange={(open) => {
if (!open) {
setApplyGroup(null);
}
}}
onSave={(ids) => {
if (applyGroup) {
applyMutation.mutate({ id: applyGroup.id, ids });
}
}}
/>
</>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { ProxyRoutePoWConfig } from '@/features/proxy-routes/types';
export interface WAFRuleGroup {
id: number;
name: string;
enabled: boolean;
is_global: boolean;
block_status_code: number;
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
region_blacklist: string[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
applied_site_ids: number[];
applied_site_count: number;
created_at: string;
updated_at: string;
}
export interface WAFRuleGroupPayload {
name: string;
enabled: boolean;
block_status_code: number;
block_response_body: string;
ip_whitelist: string[];
ip_blacklist: string[];
ip_whitelist_group_ids: number[];
ip_blacklist_group_ids: number[];
country_whitelist: string[];
country_blacklist: string[];
region_whitelist: string[];
region_blacklist: string[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
}
export interface WAFSiteRuleGroups {
route_id: number;
global_rule_group: WAFRuleGroup | null;
rule_groups: WAFRuleGroup[];
applied_rule_groups: WAFRuleGroup[];
applied_ids: number[];
}
export type WAFIPGroupType = 'manual' | 'automatic' | 'subscription';
export type WAFIPGroupSubscriptionFormat = 'text' | 'json';
export interface WAFIPGroupExtIP {
ip: string;
captured_at: string;
}
export interface WAFIPGroup {
id: number;
name: string;
type: WAFIPGroupType;
enabled: boolean;
ip_list: string[];
auto_config: Record<string, unknown>;
ext_ips?: WAFIPGroupExtIP[];
subscription_url: string;
subscription_format: WAFIPGroupSubscriptionFormat;
subscription_mapping_rule: string;
sync_interval_minutes: number;
last_synced_at?: string;
next_sync_at?: string;
last_sync_status: string;
last_sync_message: string;
remark: string;
referenced_by_rule_count: number;
created_at: string;
updated_at: string;
}
export interface WAFIPGroupPayload {
name: string;
type: WAFIPGroupType;
enabled: boolean;
ip_list: string[];
auto_config: Record<string, unknown>;
subscription_url: string;
subscription_format: WAFIPGroupSubscriptionFormat;
subscription_mapping_rule: string;
sync_interval_minutes: number;
remark: string;
}
export interface WAFIPGroupSyncResult {
group: WAFIPGroup;
ip_count: number;
synced_at: string;
next_sync_at: string;
status: string;
message: string;
}
export interface WAFIPGroupAutoTestPayload {
auto_config: Record<string, unknown>;
}
export interface WAFIPGroupAutoTestResult {
matched_ips: string[];
matched_count: number;
lookback_minutes: number;
rule_count: number;
tested_at: string;
}
@@ -0,0 +1,331 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { InlineMessage } from '@/components/feedback/inline-message';
import { AppModal } from '@/components/ui/app-modal';
import {
applyTlsCertificate,
convertTlsCertificateToAcme,
updateAcmeCertificate,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { getDnsAccounts } from '@/features/dns-accounts/api/dns-accounts';
import { getDefaultAcmeAccount } from '@/features/acme-accounts/api/acme-accounts';
import {
acmeApplySchema,
defaultAcmeApplyValues,
type AcmeApplyFormValues,
} from '@/features/websites/schemas';
import { getErrorMessage } from '@/features/websites/utils';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
ToggleField,
} from '@/features/shared/components/resource-primitives';
interface CertificateApplyModalProps {
isOpen: boolean;
onClose: () => void;
onApplied?: (certificate: TlsCertificateItem) => void;
mode?: 'create' | 'edit-acme' | 'convert-upload';
certificate?: TlsCertificateItem | null;
}
export function CertificateApplyModal({
isOpen,
onClose,
onApplied,
mode = 'create',
certificate,
}: CertificateApplyModalProps) {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<{
tone: 'success' | 'danger';
message: string;
} | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const dnsAccountsQuery = useQuery({
queryKey: ['dns-accounts'],
queryFn: getDnsAccounts,
enabled: isOpen,
});
const defaultAcmeAccountQuery = useQuery({
queryKey: ['acme-accounts', 'default'],
queryFn: getDefaultAcmeAccount,
enabled: isOpen,
});
const form = useForm<AcmeApplyFormValues>({
resolver: zodResolver(acmeApplySchema),
defaultValues: defaultAcmeApplyValues,
});
useEffect(() => {
if (!isOpen) return;
setFeedback(null);
setShowAdvanced(false);
if (certificate) {
form.reset({
name: certificate.name,
primary_domain:
mode === 'convert-upload' ? '' : certificate.primary_domain || '',
other_domains:
mode === 'convert-upload' ? '' : certificate.other_domains || '',
remark: certificate.remark || '',
acme_account_id:
mode === 'convert-upload' ? 0 : certificate.acme_account_id,
dns_account_id:
mode === 'convert-upload' ? 0 : certificate.dns_account_id,
key_algorithm: certificate.key_algorithm || 'EC256',
auto_renew: mode === 'convert-upload' ? true : certificate.auto_renew,
dns1: mode === 'convert-upload' ? '' : certificate.dns1 || '',
dns2: mode === 'convert-upload' ? '' : certificate.dns2 || '',
disable_cname:
mode === 'convert-upload' ? false : certificate.disable_cname,
skip_dns: mode === 'convert-upload' ? false : certificate.skip_dns,
});
if (
mode !== 'convert-upload' &&
(certificate.dns1 ||
certificate.dns2 ||
certificate.disable_cname ||
certificate.skip_dns)
) {
setShowAdvanced(true);
}
} else {
form.reset(defaultAcmeApplyValues);
}
}, [isOpen, form, mode, certificate]);
useEffect(() => {
if (
defaultAcmeAccountQuery.data &&
form.getValues('acme_account_id') === 0
) {
form.setValue('acme_account_id', defaultAcmeAccountQuery.data.id);
}
}, [defaultAcmeAccountQuery.data, form, isOpen]);
const applyMutation = useMutation({
mutationFn: (values: AcmeApplyFormValues) => {
if (mode === 'edit-acme' && certificate) {
return updateAcmeCertificate(certificate.id, values);
}
if (mode === 'convert-upload' && certificate) {
return convertTlsCertificateToAcme(certificate.id, values);
}
return applyTlsCertificate(values);
},
onSuccess: async (certificate) => {
await queryClient.invalidateQueries({ queryKey: ['tls-certificates'] });
onApplied?.(certificate);
onClose();
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const onSubmit = form.handleSubmit((values) => {
setFeedback(null);
applyMutation.mutate(values);
});
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title={
mode === 'edit-acme'
? '编辑并重新申请证书'
: mode === 'convert-upload'
? '转换为申请证书'
: '申请证书'
}
description={
mode === 'edit-acme'
? '修改 ACME 证书配置。保存后将使用新配置重新申请证书。'
: mode === 'convert-upload'
? '填写 ACME 申请资料。申请成功后,当前手动证书会原地转换为可自动续签的申请证书。'
: "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。"
}
size="xl"
>
<form className="space-y-5" onSubmit={onSubmit}>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书名称"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="例如:主站证书"
{...form.register('name')}
/>
</ResourceField>
<ResourceField
label="主域名"
error={form.formState.errors.primary_domain?.message}
>
<ResourceInput
placeholder="example.com 或 *.example.com"
{...form.register('primary_domain')}
/>
</ResourceField>
</div>
<ResourceField
label="其他域名"
hint="每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。"
error={form.formState.errors.other_domains?.message}
>
<textarea
className="w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm text-[var(--foreground-primary)] transition outline-none focus:border-[var(--brand-primary)]"
rows={3}
placeholder="example.net"
{...form.register('other_domains')}
/>
</ResourceField>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="DNS 服务商账号"
error={form.formState.errors.dns_account_id?.message}
>
<ResourceSelect {...form.register('dns_account_id')}>
<option value={0}>请选择 DNS 账号</option>
{dnsAccountsQuery.data?.map((acc) => (
<option key={acc.id} value={acc.id}>
{acc.name} ({acc.type})
</option>
))}
</ResourceSelect>
</ResourceField>
<ResourceField
label="密钥算法"
error={form.formState.errors.key_algorithm?.message}
>
<ResourceSelect {...form.register('key_algorithm')}>
<option value="RSA2048">RSA 2048</option>
<option value="RSA4096">RSA 4096</option>
<option value="EC256">ECC 256</option>
<option value="EC384">ECC 384</option>
</ResourceSelect>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-1">
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceInput
placeholder="可选,用于记录证书用途。"
{...form.register('remark')}
/>
</ResourceField>
<ToggleField
label="开启自动续签"
description="开启后,将在证书过期前 7 天自动续期。"
checked={form.watch('auto_renew')}
onChange={(checked) => form.setValue('auto_renew', checked)}
/>
</div>
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)]">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--surface-muted)]"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<span>高级选项</span>
<svg
className={`h-4 w-4 transition-transform duration-200 ${showAdvanced ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showAdvanced && (
<div className="space-y-4 border-t border-[var(--border-default)] px-4 py-4">
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="DNS 验证服务器 1"
hint="可选,如 8.8.8.8"
error={form.formState.errors.dns1?.message}
>
<ResourceInput
placeholder="为空则使用默认权威 DNS"
{...form.register('dns1')}
/>
</ResourceField>
<ResourceField
label="DNS 验证服务器 2"
hint="可选,如 1.1.1.1"
error={form.formState.errors.dns2?.message}
>
<ResourceInput
placeholder="为空则使用默认权威 DNS"
{...form.register('dns2')}
/>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<ToggleField
label="跳过 CNAME 检查"
description="在执行 DNS-01 验证时不追踪 CNAME 记录。"
checked={form.watch('disable_cname')}
onChange={(checked) =>
form.setValue('disable_cname', checked)
}
/>
<ToggleField
label="跳过 DNS 前置检查"
description="直接请求 Let's Encrypt 验证而不做本地校验。"
checked={form.watch('skip_dns')}
onChange={(checked) => form.setValue('skip_dns', checked)}
/>
</div>
</div>
)}
</div>
<PrimaryButton type="submit" disabled={applyMutation.isPending}>
{applyMutation.isPending
? '提交中...'
: mode === 'edit-acme'
? '保存并申请'
: mode === 'convert-upload'
? '开始转换'
: '开始申请'}
</PrimaryButton>
</form>
</AppModal>
);
}

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