Refactor code structure for improved readability and maintainability

This commit is contained in:
ryan
2026-03-11 22:58:24 +08:00
parent ca1c147dfe
commit 27f96fa353
15 changed files with 2594 additions and 1745 deletions
+18
View File
@@ -73,6 +73,24 @@ func TestPhase1PublishLifecycle(t *testing.T) {
t.Fatal("expected published version to contain snapshot, rendered config and checksum") t.Fatal("expected published version to contain snapshot, rendered config and checksum")
} }
repeatPublishReq := httptest.NewRequest(http.MethodPost, "/api/config-versions/publish", nil)
repeatPublishReq.Header.Set("Authorization", "Bearer "+token)
repeatPublishRecorder := httptest.NewRecorder()
engine.ServeHTTP(repeatPublishRecorder, repeatPublishReq)
if repeatPublishRecorder.Code != http.StatusOK {
t.Fatalf("unexpected status %d for repeated publish: %s", repeatPublishRecorder.Code, repeatPublishRecorder.Body.String())
}
var repeatPublishResp apiResponse
if err := json.Unmarshal(repeatPublishRecorder.Body.Bytes(), &repeatPublishResp); err != nil {
t.Fatalf("failed to unmarshal repeated publish response: %v", err)
}
if repeatPublishResp.Success {
t.Fatal("expected repeated publish without route changes to be rejected")
}
if !strings.Contains(repeatPublishResp.Message, "当前规则没有变更") {
t.Fatalf("unexpected repeated publish message: %s", repeatPublishResp.Message)
}
initialSnapshot := version1.SnapshotJSON initialSnapshot := version1.SnapshotJSON
initialRendered := version1.RenderedConfig initialRendered := version1.RenderedConfig
+9 -8
View File
@@ -7,14 +7,15 @@ import (
"atsflare/service" "atsflare/service"
"bytes" "bytes"
"encoding/json" "encoding/json"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
) )
func TestPhase2RateLimitOptionsHotReload(t *testing.T) { func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
@@ -180,8 +181,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
}) })
var createdNode service.NodeView var createdNode service.NodeView
decodeResponseData(t, createdNodeResp, &createdNode) decodeResponseData(t, createdNodeResp, &createdNode)
if createdNode.AgentToken == "" || !createdNode.Pending { if createdNode.AgentToken == "" || createdNode.Status != service.NodeStatusPending {
t.Fatal("expected created node to expose agent token while pending") t.Fatal("expected created node to expose agent token with pending status")
} }
heartbeatPayload := map[string]any{ heartbeatPayload := map[string]any{
@@ -237,8 +238,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
if len(nodes) != 1 { if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes)) t.Fatalf("expected 1 node, got %d", len(nodes))
} }
if nodes[0].Pending { if nodes[0].Status != service.NodeStatusOnline {
t.Fatal("expected registered node to clear pending state") t.Fatal("expected registered node to become online")
} }
if nodes[0].AgentToken != createdNode.AgentToken { if nodes[0].AgentToken != createdNode.AgentToken {
t.Fatal("expected node auth token to remain stable after occupancy") t.Fatal("expected node auth token to remain stable after occupancy")
@@ -394,7 +395,7 @@ func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
if len(nodes) != 1 { if len(nodes) != 1 {
t.Fatalf("expected 1 discovered node, got %d", len(nodes)) t.Fatalf("expected 1 discovered node, got %d", len(nodes))
} }
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Pending { if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Status != service.NodeStatusOnline {
t.Fatal("expected discovered node to be created online with issued agent token") t.Fatal("expected discovered node to be created online with issued agent token")
} }
} }
-1
View File
@@ -63,7 +63,6 @@ type NodeView struct {
Name string `json:"name"` Name string `json:"name"`
IP string `json:"ip"` IP string `json:"ip"`
AgentToken string `json:"agent_token"` AgentToken string `json:"agent_token"`
Pending bool `json:"pending"`
AutoUpdateEnabled bool `json:"auto_update_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"`
UpdateRequested bool `json:"update_requested"` UpdateRequested bool `json:"update_requested"`
AgentVersion string `json:"agent_version"` AgentVersion string `json:"agent_version"`
+22
View File
@@ -137,6 +137,21 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
return result, nil return result, nil
} }
func HasConfigChanges() (bool, error) {
bundle, err := buildCurrentConfigBundle(false)
if err != nil {
return false, err
}
activeVersion, err := model.GetActiveConfigVersion()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return len(bundle.Routes) > 0, nil
}
return false, err
}
return activeVersion.Checksum != bundle.Checksum, nil
}
func PublishConfigVersion(createdBy string) (*ReleaseResult, error) { func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
bundle, err := buildCurrentConfigBundle(true) bundle, err := buildCurrentConfigBundle(true)
if err != nil { if err != nil {
@@ -145,6 +160,13 @@ func PublishConfigVersion(createdBy string) (*ReleaseResult, error) {
if len(bundle.Routes) == 0 { if len(bundle.Routes) == 0 {
return nil, errors.New("没有可发布的启用规则") return nil, errors.New("没有可发布的启用规则")
} }
activeVersion, err := model.GetActiveConfigVersion()
if err == nil && activeVersion.Checksum == bundle.Checksum {
return nil, errors.New("当前规则没有变更,不能重复发布")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
supportFilesJSON, err := json.Marshal(bundle.SupportFiles) supportFilesJSON, err := json.Marshal(bundle.SupportFiles)
if err != nil { if err != nil {
return nil, err return nil, err
-1
View File
@@ -178,7 +178,6 @@ func buildNodeView(node *model.Node) *NodeView {
LastError: node.LastError, LastError: node.LastError,
CreatedAt: node.CreatedAt, CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt, UpdatedAt: node.UpdatedAt,
Pending: status == NodeStatusPending,
AutoUpdateEnabled: node.AutoUpdateEnabled, AutoUpdateEnabled: node.AutoUpdateEnabled,
UpdateRequested: node.UpdateRequested, UpdateRequested: node.UpdateRequested,
} }
@@ -0,0 +1,11 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { NodeDetailPage } from '@/features/nodes/components/node-detail-page';
export default function NodeDetailRoute() {
const searchParams = useSearchParams();
return <NodeDetailPage nodeId={searchParams.get('id') ?? ''} />;
}
@@ -1,13 +1,14 @@
'use client'; 'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state'; import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state'; import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message'; import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { import {
@@ -49,26 +50,42 @@ function truncateChecksum(checksum: string) {
return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum; 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.active_version
);
}
function DiffList({ title, items }: { title: string; items: string[] }) { function DiffList({ title, items }: { title: string; items: string[] }) {
return ( return (
<div className='space-y-3'> <div className="space-y-3">
<div className='flex items-center justify-between gap-3'> <div className="flex items-center justify-between gap-3">
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{title}</p> <p className="text-sm font-semibold text-[var(--foreground-primary)]">
<StatusBadge label={`${items.length} 项`} variant={items.length > 0 ? 'info' : 'warning'} /> {title}
</p>
<StatusBadge
label={`${items.length} 项`}
variant={items.length > 0 ? 'info' : 'warning'}
/>
</div> </div>
{items.length > 0 ? ( {items.length > 0 ? (
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
{items.map((item) => ( {items.map((item) => (
<span <span
key={item} key={item}
className='rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]' className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
> >
{item} {item}
</span> </span>
))} ))}
</div> </div>
) : ( ) : (
<p className='text-sm text-[var(--foreground-secondary)]'>当前无相关变更。</p> <p className="text-sm text-[var(--foreground-secondary)]">
当前无相关变更。
</p>
)} )}
</div> </div>
); );
@@ -76,65 +93,102 @@ function DiffList({ title, items }: { title: string; items: string[] }) {
function SupportFilesList({ files }: { files: SupportFile[] }) { function SupportFilesList({ files }: { files: SupportFile[] }) {
if (files.length === 0) { if (files.length === 0) {
return <p className='text-sm text-[var(--foreground-secondary)]'>当前发布不需要额外支持文件。</p>; return (
<p className="text-sm text-[var(--foreground-secondary)]">
当前发布不需要额外支持文件。
</p>
);
} }
return ( return (
<div className='space-y-3'> <div className="space-y-3">
{files.map((file) => ( {files.map((file) => (
<details <details
key={file.path} key={file.path}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3' 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)]'> <summary className="cursor-pointer text-sm font-medium text-[var(--foreground-primary)]">
{file.path} {file.path}
</summary> </summary>
<CodeBlock className='mt-3 max-h-72 whitespace-pre-wrap'>{file.content}</CodeBlock> <CodeBlock className="mt-3 max-h-72 whitespace-pre-wrap">
{file.content}
</CodeBlock>
</details> </details>
))} ))}
</div> </div>
); );
} }
function VersionDetailCard({ function SnapshotModal({
version, version,
onClose,
}: { }: {
version: ConfigVersionItem; version: ConfigVersionItem | null;
onClose: () => void;
}) { }) {
return ( return (
<AppCard <AppModal
title={`版本 ${version.version}`} isOpen={Boolean(version)}
description='查看当前版本保存的快照 JSON 与最终渲染配置,便于比对发布结果。' onClose={onClose}
action={ title={version ? `版本 ${version.version}` : '查看快照'}
<StatusBadge label={version.is_active ? '当前激活' : '历史版本'} variant={version.is_active ? 'success' : 'info'} /> description="在弹窗中查看快照 JSON 与渲染结果,避免把版本页主视图撑长。"
size="xl"
footer={
<div className="flex justify-end">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
</div>
} }
> >
<div className='grid gap-4 md:grid-cols-3'> {version ? (
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'> <div className="space-y-5">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Checksum</p> <div className="grid gap-4 md:grid-cols-3">
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>{version.checksum}</p> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
</div> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'> Checksum
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建人</p> </p>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{version.created_by || '系统'}</p> <p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
</div> {version.checksum}
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'> </p>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建时间</p> </div>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{formatDateTime(version.created_at)}</p> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
</div> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
</div> 创建人
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{version.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(version.created_at)}
</p>
</div>
</div>
<div className='mt-5 space-y-4'> <div>
<div> <p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
<p className='mb-2 text-sm font-semibold text-[var(--foreground-primary)]'>快照 JSON</p> 快照 JSON
<CodeBlock className='max-h-96 whitespace-pre-wrap'>{version.snapshot_json}</CodeBlock> </p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
渲染结果
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config}
</CodeBlock>
</div>
</div> </div>
<div> ) : null}
<p className='mb-2 text-sm font-semibold text-[var(--foreground-primary)]'>渲染结果</p> </AppModal>
<CodeBlock className='max-h-[32rem] whitespace-pre-wrap'>{version.rendered_config}</CodeBlock>
</div>
</div>
</AppCard>
); );
} }
@@ -151,56 +205,102 @@ function PublishPreviewCard({
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const canPublish = preview.route_count > 0 && hasConfigDiff(diff);
return ( return (
<AppCard <AppCard
title='发布前预览' title="发布前预览"
description='先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。' description="先核对增删改域名、渲染结果与支持文件,再决定是否发布为新激活版本。"
action={<StatusBadge label={`启用规则 ${preview.route_count} 条`} variant='info' />} action={
<StatusBadge
label={`启用规则 ${preview.route_count} 条`}
variant="info"
/>
}
> >
<div className='space-y-5'> <div className="space-y-5">
<div className='grid gap-4 md:grid-cols-4'> <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'> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>当前激活版本</p> <p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{diff.active_version || '无'}</p> 当前激活版本
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{diff.active_version || '无'}
</p>
</div> </div>
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4'> <div className="rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--status-success-foreground)]'>新增域名</p> <p className="text-xs tracking-[0.2em] text-[var(--status-success-foreground)] uppercase">
<p className='mt-2 text-lg font-semibold text-[var(--status-success-foreground)]'>{diff.added_domains.length}</p> 新增域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-success-foreground)]">
{diff.added_domains.length}
</p>
</div> </div>
<div className='rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4'> <div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-4">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--status-warning-foreground)]'>删除域名</p> <p className="text-xs tracking-[0.2em] text-[var(--status-warning-foreground)] uppercase">
<p className='mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]'>{diff.removed_domains.length}</p> 删除域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-warning-foreground)]">
{diff.removed_domains.length}
</p>
</div> </div>
<div className='rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4'> <div className="rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-4">
<p className='text-xs uppercase tracking-[0.2em] text-[var(--status-info-foreground)]'>修改域名</p> <p className="text-xs tracking-[0.2em] text-[var(--status-info-foreground)] uppercase">
<p className='mt-2 text-lg font-semibold text-[var(--status-info-foreground)]'>{diff.modified_domains.length}</p> 修改域名
</p>
<p className="mt-2 text-lg font-semibold text-[var(--status-info-foreground)]">
{diff.modified_domains.length}
</p>
</div> </div>
</div> </div>
<div className='grid gap-5 xl:grid-cols-3'> {!canPublish ? (
<DiffList title='新增域名' items={diff.added_domains} /> <InlineMessage
<DiffList title='删除域名' items={diff.removed_domains} /> tone="info"
<DiffList title='修改域名' items={diff.modified_domains} /> 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>
<div> <div>
<div className='mb-2 flex flex-wrap items-center justify-between gap-3'> <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-sm font-semibold text-[var(--foreground-primary)]">
<p className='text-xs text-[var(--foreground-secondary)]'>Checksum:{preview.checksum}</p> 渲染结果
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
Checksum:{preview.checksum}
</p>
</div> </div>
<CodeBlock className='max-h-[32rem] whitespace-pre-wrap'>{preview.rendered_config}</CodeBlock> <CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.rendered_config}
</CodeBlock>
</div> </div>
<div> <div>
<p className='mb-2 text-sm font-semibold text-[var(--foreground-primary)]'>支持文件</p> <p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
支持文件
</p>
<SupportFilesList files={preview.support_files} /> <SupportFilesList files={preview.support_files} />
</div> </div>
<div className='flex flex-wrap gap-3'> <div className="flex flex-wrap gap-3">
<PrimaryButton type='button' onClick={onConfirm} disabled={isPublishing}> <PrimaryButton
type="button"
onClick={onConfirm}
disabled={isPublishing || !canPublish}
>
{isPublishing ? '发布中...' : '确认发布'} {isPublishing ? '发布中...' : '确认发布'}
</PrimaryButton> </PrimaryButton>
<SecondaryButton type='button' onClick={onCancel} disabled={isPublishing}> <SecondaryButton
type="button"
onClick={onCancel}
disabled={isPublishing}
>
取消预览 取消预览
</SecondaryButton> </SecondaryButton>
</div> </div>
@@ -212,7 +312,9 @@ function PublishPreviewCard({
export function ConfigVersionsPage() { export function ConfigVersionsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [selectedVersion, setSelectedVersion] = useState<ConfigVersionItem | null>(null); const [selectedVersionId, setSelectedVersionId] = useState<number | null>(
null,
);
const [publishPreview, setPublishPreview] = useState<{ const [publishPreview, setPublishPreview] = useState<{
preview: ConfigPreviewResult; preview: ConfigPreviewResult;
diff: ConfigDiffResult; diff: ConfigDiffResult;
@@ -224,12 +326,24 @@ export function ConfigVersionsPage() {
queryFn: getConfigVersions, queryFn: getConfigVersions,
}); });
const versions = useMemo(
() => versionsQuery.data ?? [],
[versionsQuery.data],
);
const selectedVersion = useMemo(
() => versions.find((item) => item.id === selectedVersionId) ?? null,
[selectedVersionId, versions],
);
const publishMutation = useMutation({ const publishMutation = useMutation({
mutationFn: publishConfigVersion, mutationFn: publishConfigVersion,
onSuccess: async (version) => { onSuccess: async (version) => {
setFeedback({ tone: 'success', message: `发布成功,版本 ${version.version}` }); setFeedback({
tone: 'success',
message: `发布成功,版本 ${version.version}`,
});
setPublishPreview(null); setPublishPreview(null);
setSelectedVersion(version); setSelectedVersionId(version.id);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
}, },
onError: (error) => { onError: (error) => {
@@ -240,8 +354,11 @@ export function ConfigVersionsPage() {
const activateMutation = useMutation({ const activateMutation = useMutation({
mutationFn: activateConfigVersion, mutationFn: activateConfigVersion,
onSuccess: async (version) => { onSuccess: async (version) => {
setFeedback({ tone: 'success', message: `已激活版本 ${version.version}` }); setFeedback({
setSelectedVersion(version); tone: 'success',
message: `已激活版本 ${version.version}`,
});
setSelectedVersionId(version.id);
await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
}, },
onError: (error) => { onError: (error) => {
@@ -254,7 +371,10 @@ export function ConfigVersionsPage() {
setIsPreviewLoading(true); setIsPreviewLoading(true);
try { try {
const [preview, diff] = await Promise.all([getConfigVersionPreview(), getConfigVersionDiff()]); const [preview, diff] = await Promise.all([
getConfigVersionPreview(),
getConfigVersionDiff(),
]);
setPublishPreview({ preview, diff }); setPublishPreview({ preview, diff });
} catch (error) { } catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -276,23 +396,27 @@ export function ConfigVersionsPage() {
activateMutation.mutate(version.id); activateMutation.mutate(version.id);
}; };
const versions = versionsQuery.data || [];
return ( return (
<div className='space-y-6'> <>
<PageHeader <div className="space-y-6">
title='配置版本' <PageHeader
description='查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。' title="配置版本"
action={ description="查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。"
<PrimaryButton type='button' onClick={handleOpenPublishPreview} disabled={isPreviewLoading}> action={
{isPreviewLoading ? '加载预览中...' : '预览并发布'} <PrimaryButton
</PrimaryButton> type="button"
} onClick={handleOpenPublishPreview}
/> disabled={isPreviewLoading}
>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null} {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className='grid gap-6'>
{publishPreview ? ( {publishPreview ? (
<PublishPreviewCard <PublishPreviewCard
preview={publishPreview.preview} preview={publishPreview.preview}
@@ -302,75 +426,106 @@ export function ConfigVersionsPage() {
onCancel={() => setPublishPreview(null)} onCancel={() => setPublishPreview(null)}
/> />
) : null} ) : null}
<AppCard
title="历史版本"
description="发布成功后会立即刷新列表,不再需要手动刷新页面。"
action={
<SecondaryButton
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: versionsQueryKey,
})
}
>
刷新列表
</SecondaryButton>
}
>
{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> </div>
<AppCard title='历史版本'> <SnapshotModal
{versionsQuery.isLoading ? ( version={selectedVersion}
<LoadingState /> onClose={() => setSelectedVersionId(null)}
) : 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={() => setSelectedVersion(version)}
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>
{selectedVersion ? <VersionDetailCard version={selectedVersion} /> : null}
</div>
); );
} }
@@ -0,0 +1,572 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } 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 { 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 { getPublicStatus } from '@/features/auth/api/public';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
import {
deleteNode,
getNodes,
requestNodeAgentUpdate,
updateNode,
} from '@/features/nodes/api/nodes';
import type { NodeMutationPayload } from '@/features/nodes/types';
import {
CodeBlock,
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
ToggleField,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
buildNodeInstallCommand,
getApplyLabel,
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
getServerUrl,
getUpdateMode,
isMeaningfulTime,
shouldShowManualUpdate,
} from '@/features/nodes/utils';
const nodesQueryKey = ['nodes'];
const nodeSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入节点名')
.max(128, '节点名不能超过 128 个字符'),
auto_update_enabled: z.boolean(),
});
type NodeFormValues = z.infer<typeof nodeSchema>;
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
const defaultValues: NodeFormValues = {
name: '',
auto_update_enabled: false,
};
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function toPayload(values: NodeFormValues): NodeMutationPayload {
return {
name: values.name.trim(),
auto_update_enabled: values.auto_update_enabled,
};
}
async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [serverUrl, setServerUrl] = useState('');
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
defaultValues,
});
const watchedAutoUpdate = useWatch({
control: form.control,
name: 'auto_update_enabled',
});
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
refetchInterval: 5000,
});
const publicStatusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const node = useMemo(() => {
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
const applyLogsQuery = useQuery({
queryKey: ['apply-logs', node?.node_id ?? ''],
queryFn: () => getApplyLogs(node?.node_id),
enabled: Boolean(node?.node_id),
refetchInterval: 5000,
});
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
useEffect(() => {
if (!node || isEditorOpen) {
return;
}
form.reset({
name: node.name,
auto_update_enabled: node.auto_update_enabled,
});
}, [form, isEditorOpen, node]);
const saveMutation = useMutation({
mutationFn: async (values: NodeFormValues) =>
updateNode(Number(nodeId), toPayload(values)),
onSuccess: async (updatedNode) => {
setFeedback({ tone: 'success', message: '节点已更新。' });
setIsEditorOpen(false);
form.reset({
name: updatedNode.name,
auto_update_enabled: updatedNode.auto_update_enabled,
});
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const updateAgentMutation = useMutation({
mutationFn: () => requestNodeAgentUpdate(Number(nodeId)),
onSuccess: async (updatedNode) => {
setFeedback({
tone: 'success',
message: `已向节点 ${updatedNode.name} 下发更新指令。`,
});
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteNode(Number(nodeId)),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' });
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/node');
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleSubmit = form.handleSubmit((values) => {
setFeedback(null);
saveMutation.mutate(values);
});
const handleDelete = () => {
if (!node) {
return;
}
if (
!window.confirm(
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate();
};
const handleCopy = async (value: string, message: string) => {
try {
await copyToClipboard(value);
setFeedback({ tone: 'success', message });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
if (nodesQuery.isLoading) {
return <LoadingState />;
}
if (nodesQuery.isError) {
return (
<ErrorState
title="节点详情加载失败"
description={getErrorMessage(nodesQuery.error)}
/>
);
}
if (!node) {
return (
<EmptyState
title="节点不存在"
description="该节点可能已被删除,或当前 ID 无法匹配到节点记录。"
/>
);
}
const normalizedServerUrl = getServerUrl(serverUrl);
const nodeInstallCommand =
normalizedServerUrl && node.agent_token
? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
: '';
const updateMode = getUpdateMode(node);
const serverVersion = publicStatusQuery.data?.version ?? '';
const showManualUpdate = shouldShowManualUpdate(
node.agent_version || '',
serverVersion,
);
const applyLogs = applyLogsQuery.data ?? [];
return (
<>
<div className="space-y-6">
<PageHeader
title={node.name}
description="节点详情每 5 秒自动刷新一次。部署命令、Node ID、Token、更新模式和最近应用记录统一在这里查看。"
action={
<>
<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>
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(true)}
>
编辑节点
</SecondaryButton>
{showManualUpdate ? (
<PrimaryButton
type="button"
onClick={() => updateAgentMutation.mutate()}
disabled={
updateAgentMutation.isPending || node.update_requested
}
>
{node.update_requested ? '等待更新中...' : '手动升级 Agent'}
</PrimaryButton>
) : null}
<DangerButton
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
删除节点
</DangerButton>
</>
}
/>
{feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<div className="grid gap-4 xl:grid-cols-4">
<AppCard title="连接状态">
<div className="space-y-3">
<StatusBadge
label={getNodeStatusLabel(node.status)}
variant={getNodeStatusVariant(node.status)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
最近心跳:
{isMeaningfulTime(node.last_seen_at)
? ` ${formatRelativeTime(
node.last_seen_at,
)} · ${formatDateTime(node.last_seen_at)}`
: ' 暂无'}
</p>
</div>
</AppCard>
<AppCard title="版本信息">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>Agent:{node.agent_version || 'unknown'}</p>
<p>Nginx:{node.nginx_version || 'unknown'}</p>
<p>当前配置:{node.current_version || '未应用'}</p>
</div>
</AppCard>
<AppCard title="更新模式">
<div className="space-y-3">
<StatusBadge
label={updateMode.label}
variant={updateMode.variant}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{node.update_requested
? '已等待节点在下一次心跳后执行更新。'
: node.auto_update_enabled
? '节点已启用自动更新。'
: '当前仅支持手动触发更新。'}
</p>
</div>
</AppCard>
<AppCard title="最近应用">
<div className="space-y-3">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(node.latest_apply_result)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(
node.latest_apply_at,
)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无应用记录'}
</p>
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard
title="节点标识与部署"
description="Node ID、Token 与部署命令不再在列表中展示,统一收敛到详情页。"
action={
nodeInstallCommand ? (
<PrimaryButton
type="button"
onClick={() =>
void handleCopy(
nodeInstallCommand,
'节点专属部署命令已复制。',
)
}
>
复制部署命令
</PrimaryButton>
) : null
}
>
<div className="space-y-4">
<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">
Node ID
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.node_id}
</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">
Agent Token
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.agent_token || '暂无'}
</p>
</div>
</div>
<ResourceField
label="Server URL"
hint="默认使用当前控制面来源地址,可按需改为外部访问地址。"
>
<ResourceInput
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
/>
</ResourceField>
{nodeInstallCommand ? (
<CodeBlock className="whitespace-pre-wrap">
{nodeInstallCommand}
</CodeBlock>
) : null}
</div>
</AppCard>
<AppCard title="运行信息">
<div className="space-y-4 text-sm text-[var(--foreground-secondary)]">
<div>
<p className="font-medium text-[var(--foreground-primary)]">
IP 地址
</p>
<p className="mt-1">{node.ip || '暂无'}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
最近错误
</p>
<p className="mt-1 break-words whitespace-pre-wrap">
{node.last_error || '无'}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
创建时间
</p>
<p className="mt-1">{formatDateTime(node.created_at)}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
更新时间
</p>
<p className="mt-1">{formatDateTime(node.updated_at)}</p>
</div>
</div>
</AppCard>
</div>
<AppCard
title="最近应用记录"
description="仅展示当前节点的应用历史。"
action={
<Link
href={`/apply-log?node_id=${encodeURIComponent(node.node_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>
}
>
{applyLogsQuery.isLoading ? (
<LoadingState />
) : applyLogsQuery.isError ? (
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(applyLogsQuery.error)}
/>
) : applyLogs.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)]">
{applyLogs.slice(0, 10).map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={log.result === 'success' ? '成功' : '失败'}
variant={
log.result === 'success' ? 'success' : 'danger'
}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatRelativeTime(log.created_at)} ·{' '}
{formatDateTime(log.created_at)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="max-w-80 break-words whitespace-pre-wrap">
{log.message || '—'}
</p>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={() => setIsEditorOpen(false)}
title="编辑节点"
description="更新模式和节点名都在详情页维护。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(false)}
disabled={saveMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="node-detail-editor-form"
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? '保存中...' : '保存修改'}
</PrimaryButton>
</div>
}
>
<form
id="node-detail-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<ResourceField
label="节点名"
hint="示例:shanghai-edge-1"
error={form.formState.errors.name?.message}
>
<ResourceInput
placeholder="shanghai-edge-1"
{...form.register('name')}
/>
</ResourceField>
<ToggleField
label="启用自动更新"
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
checked={watchedAutoUpdate}
onChange={(checked) =>
form.setValue('auto_update_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</form>
</AppModal>
</>
);
}
@@ -3,7 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -15,17 +15,14 @@ import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal'; import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { getPublicStatus } from '@/features/auth/api/public';
import { import {
createNode, createNode,
deleteNode, deleteNode,
getNodes, getNodes,
requestNodeAgentUpdate,
updateNode, updateNode,
} from '@/features/nodes/api/nodes'; } from '@/features/nodes/api/nodes';
import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; import type { NodeMutationPayload } from '@/features/nodes/types';
import { import {
CodeBlock,
DangerButton, DangerButton,
PrimaryButton, PrimaryButton,
ResourceField, ResourceField,
@@ -34,10 +31,15 @@ import {
ToggleField, ToggleField,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import {
getApplyLabel,
getApplyVariant,
getNodeStatusLabel,
getNodeStatusVariant,
isMeaningfulTime,
} from '@/features/nodes/utils';
const nodesQueryKey = ['nodes']; const nodesQueryKey = ['nodes'];
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
const nodeSchema = z.object({ const nodeSchema = z.object({
name: z name: z
@@ -71,145 +73,11 @@ function toPayload(values: NodeFormValues): NodeMutationPayload {
}; };
} }
function isMeaningfulTime(value: string | null | undefined) {
return Boolean(value) && !String(value).startsWith('0001-01-01');
}
function getStatusVariant(status: NodeItem['status']) {
if (status === 'online') {
return 'success';
}
if (status === 'pending') {
return 'warning';
}
return 'danger';
}
function getStatusLabel(status: NodeItem['status']) {
if (status === 'online') {
return '在线';
}
if (status === 'pending') {
return '待接入';
}
return '离线';
}
function getApplyVariant(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return 'success';
}
if (result === 'failed') {
return 'danger';
}
return 'warning';
}
function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动更新', variant: 'success' as const };
}
return { label: '手动更新', variant: 'info' as const };
}
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;
}
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);
}
function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
function buildNodeInstallCommand(serverUrl: string, agentToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --agent-token ${agentToken}`,
].join('\n');
}
async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
export function NodesPage() { export function NodesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingNodeId, setEditingNodeId] = useState<number | null>(null); const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<NodeItem | null>(null);
const [serverUrl, setServerUrl] = useState('');
const [, setRefreshTick] = useState(0);
const form = useForm<NodeFormValues>({ const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema), resolver: zodResolver(nodeSchema),
@@ -224,30 +92,9 @@ export function NodesPage() {
const nodesQuery = useQuery({ const nodesQuery = useQuery({
queryKey: nodesQueryKey, queryKey: nodesQueryKey,
queryFn: getNodes, queryFn: getNodes,
refetchInterval: 5000,
}); });
const publicStatusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
useEffect(() => {
const timer = window.setInterval(() => {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey });
setRefreshTick((value) => value + 1);
}, 30000);
return () => {
window.clearInterval(timer);
};
}, [queryClient]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async (values: NodeFormValues) => { mutationFn: async (values: NodeFormValues) => {
const payload = toPayload(values); const payload = toPayload(values);
@@ -255,14 +102,13 @@ export function NodesPage() {
? updateNode(editingNodeId, payload) ? updateNode(editingNodeId, payload)
: createNode(payload); : createNode(payload);
}, },
onSuccess: async (node) => { onSuccess: async () => {
setFeedback({ setFeedback({
tone: 'success', tone: 'success',
message: editingNodeId ? '节点已更新。' : '节点已创建。', message: editingNodeId ? '节点已更新。' : '节点已创建。',
}); });
setEditingNodeId(null); setEditingNodeId(null);
setIsEditorOpen(false); setIsEditorOpen(false);
setSelectedNode(node);
form.reset(defaultValues); form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
@@ -271,27 +117,11 @@ export function NodesPage() {
}, },
}); });
const updateAgentMutation = useMutation({
mutationFn: requestNodeAgentUpdate,
onSuccess: async (node) => {
setFeedback({
tone: 'success',
message: `已向节点 ${node.name} 下发更新指令。`,
});
setSelectedNode(node);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: deleteNode, mutationFn: deleteNode,
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '节点已删除。' }); setFeedback({ tone: 'success', message: '节点已删除。' });
setEditingNodeId(null); setEditingNodeId(null);
setSelectedNode(null);
form.reset(defaultValues); form.reset(defaultValues);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
@@ -301,16 +131,6 @@ export function NodesPage() {
}); });
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const normalizedServerUrl = getServerUrl(serverUrl);
const serverVersion = publicStatusQuery.data?.version ?? '';
const selectedNodeFromList = useMemo(() => {
if (!selectedNode) {
return null;
}
return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode;
}, [nodes, selectedNode]);
const handleReset = () => { const handleReset = () => {
setFeedback(null); setFeedback(null);
@@ -326,43 +146,31 @@ export function NodesPage() {
setIsEditorOpen(true); setIsEditorOpen(true);
}; };
const handleEdit = (node: NodeItem) => { const handleEdit = (
nodeId: number,
name: string,
autoUpdateEnabled: boolean,
) => {
setFeedback(null); setFeedback(null);
setEditingNodeId(node.id); setEditingNodeId(nodeId);
setSelectedNode(node);
form.reset({ form.reset({
name: node.name, name,
auto_update_enabled: node.auto_update_enabled, auto_update_enabled: autoUpdateEnabled,
}); });
setIsEditorOpen(true); setIsEditorOpen(true);
}; };
const handleOpenDeployModal = (node: NodeItem) => { const handleDelete = (nodeId: number, nodeName: string) => {
setFeedback(null);
setSelectedNode(node);
setIsDeployModalOpen(true);
};
const handleDelete = (node: NodeItem) => {
if ( if (
!window.confirm( !window.confirm(
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, `确认删除节点“${nodeName}”吗?删除后该节点需要重新创建并重新接入。`,
) )
) { ) {
return; return;
} }
setFeedback(null); setFeedback(null);
deleteMutation.mutate(node.id); deleteMutation.mutate(nodeId);
};
const handleCopy = async (value: string, successMessage: string) => {
try {
await copyToClipboard(value);
setFeedback({ tone: 'success', message: successMessage });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
}; };
const handleSubmit = form.handleSubmit((values) => { const handleSubmit = form.handleSubmit((values) => {
@@ -370,20 +178,12 @@ export function NodesPage() {
saveMutation.mutate(values); saveMutation.mutate(values);
}); });
const nodeInstallCommand =
normalizedServerUrl && selectedNodeFromList?.agent_token
? buildNodeInstallCommand(
normalizedServerUrl,
selectedNodeFromList.agent_token,
)
: '';
return ( return (
<> <>
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="节点管理" title="节点管理"
description="查看节点在线状态、最近心跳、部署入口与 Agent 更新动作,并支持预创建节点。" description="节点列表只保留状态、版本与最近活动等关键内容。节点部署、Token、更新模式与手动升级入口统一收敛到详情页。"
action={ action={
<> <>
<SecondaryButton type="button" onClick={handleCreate}> <SecondaryButton type="button" onClick={handleCreate}>
@@ -405,6 +205,7 @@ export function NodesPage() {
<AppCard <AppCard
title="节点列表" title="节点列表"
description="列表每 5 秒自动刷新一次。"
action={ action={
<SecondaryButton <SecondaryButton
type="button" type="button"
@@ -412,7 +213,7 @@ export function NodesPage() {
void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) void queryClient.invalidateQueries({ queryKey: nodesQueryKey })
} }
> >
刷新列表 立即刷新
</SecondaryButton> </SecondaryButton>
} }
> >
@@ -426,7 +227,7 @@ export function NodesPage() {
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<EmptyState <EmptyState
title="暂无节点" title="暂无节点"
description="请先创建一个节点,然后在列表中打开该节点的部署弹窗。" description="请先创建一个节点,然后进入详情页查看专属部署命令。"
/> />
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -435,231 +236,105 @@ export function NodesPage() {
<tr className="text-[var(--foreground-secondary)]"> <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>
<th className="px-3 py-3 font-medium">Agent / Nginx</th> <th className="px-3 py-3 font-medium">Agent / Nginx</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> <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> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border-default)]"> <tbody className="divide-y divide-[var(--border-default)]">
{nodes.map((node) => { {nodes.map((node) => (
const updateMode = getUpdateMode(node); <tr key={node.id} className="align-top">
const showManualUpdate = shouldShowManualUpdate( <td className="px-3 py-4">
node.agent_version || '', <div className="space-y-1">
serverVersion, <p className="font-medium text-[var(--foreground-primary)]">
); {node.name}
return (
<tr key={node.id} className="align-top">
<td className="px-3 py-4">
<div className="space-y-2">
<p className="font-medium text-[var(--foreground-primary)]">
{node.name}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
Node ID:{node.node_id}
</p>
<p className="text-xs break-all text-[var(--foreground-secondary)]">
Token:{node.agent_token || '暂无'}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
IP:{node.ip || '暂无'}
</p>
</div>
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getStatusLabel(node.status)}
variant={getStatusVariant(node.status)}
/>
<StatusBadge
label={node.pending ? '未占用' : '已绑定'}
variant={node.pending ? 'warning' : 'success'}
/>
</div>
</td>
<td className="px-3 py-4">
<StatusBadge
label={updateMode.label}
variant={updateMode.variant}
/>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.agent_version || 'unknown'} /{' '}
{node.nginx_version || 'unknown'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{node.current_version || '未应用'}
</td>
<td className="px-3 py-4">
<div className="space-y-2">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(
node.latest_apply_result,
)}
/>
<p className="max-w-56 text-xs leading-5 text-[var(--foreground-secondary)]">
{node.latest_apply_message || '暂无记录'}
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
</p>
</div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<p className="max-w-56 break-words whitespace-pre-wrap">
{node.last_error || '无'}
</p> </p>
</td> <p className="text-xs text-[var(--foreground-secondary)]">
<td className="px-3 py-4"> IP:{node.ip || '暂无'}
<div className="flex flex-wrap gap-2"> </p>
{showManualUpdate ? ( </div>
<PrimaryButton </td>
type="button" <td className="px-3 py-4">
onClick={() => <StatusBadge
updateAgentMutation.mutate(node.id) label={getNodeStatusLabel(node.status)}
} variant={getNodeStatusVariant(node.status)}
disabled={ />
updateAgentMutation.isPending || </td>
node.update_requested <td className="px-3 py-4 text-[var(--foreground-secondary)]">
} {node.agent_version || 'unknown'} /{' '}
className="px-3 py-2 text-xs" {node.nginx_version || 'unknown'}
> </td>
升级 <td className="px-3 py-4 text-[var(--foreground-secondary)]">
</PrimaryButton> {node.current_version || '未应用'}
) : null} </td>
<SecondaryButton <td className="px-3 py-4">
type="button" <div className="space-y-2">
onClick={() => handleOpenDeployModal(node)} <StatusBadge
className="px-3 py-2 text-xs" label={getApplyLabel(node.latest_apply_result)}
> variant={getApplyVariant(node.latest_apply_result)}
部署 />
</SecondaryButton> <p className="text-xs text-[var(--foreground-secondary)]">
<SecondaryButton {isMeaningfulTime(node.latest_apply_at)
type="button" ? `${formatRelativeTime(
onClick={() => handleEdit(node)} node.latest_apply_at,
className="px-3 py-2 text-xs" )} · ${formatDateTime(node.latest_apply_at)}`
> : '暂无'}
编辑 </p>
</SecondaryButton> </div>
<DangerButton </td>
type="button" <td className="px-3 py-4 text-[var(--foreground-secondary)]">
onClick={() => handleDelete(node)} {isMeaningfulTime(node.last_seen_at)
disabled={deleteMutation.isPending} ? `${formatRelativeTime(
className="px-3 py-2 text-xs" node.last_seen_at,
> )} · ${formatDateTime(node.last_seen_at)}`
删除 : '暂无'}
</DangerButton> </td>
</div> <td className="px-3 py-4">
</td> <div className="flex flex-wrap gap-2">
</tr> <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.id,
node.name,
node.auto_update_enabled,
)
}
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> </tbody>
</table> </table>
</div> </div>
)} )}
</AppCard> </AppCard>
</div> </div>
<AppModal
isOpen={isDeployModalOpen}
onClose={() => setIsDeployModalOpen(false)}
title="节点专属部署命令"
description="仅展示节点专属 Agent Token 部署方式。Discovery Token 部署入口已从管理端移除。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setIsDeployModalOpen(false)}
>
关闭
</SecondaryButton>
{nodeInstallCommand ? (
<PrimaryButton
type="button"
onClick={() =>
void handleCopy(
nodeInstallCommand,
'节点专属部署命令已复制。',
)
}
>
复制命令
</PrimaryButton>
) : null}
</div>
}
>
{selectedNodeFromList ? (
<div className="space-y-4">
<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">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{selectedNodeFromList.name}
</p>
<StatusBadge
label={selectedNodeFromList.pending ? '未占用' : '已绑定'}
variant={
selectedNodeFromList.pending ? 'warning' : 'success'
}
/>
</div>
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">
Node ID:{selectedNodeFromList.node_id}
</p>
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">
IP:{selectedNodeFromList.ip || '暂无'}
</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">
Agent Token
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedNodeFromList.agent_token || '暂无'}
</p>
</div>
</div>
<ResourceField
label="Server URL"
hint="默认使用当前控制面来源地址,可按需改为外部访问地址。"
>
<ResourceInput
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
/>
</ResourceField>
{nodeInstallCommand ? (
<CodeBlock className="whitespace-pre-wrap">
{nodeInstallCommand}
</CodeBlock>
) : null}
</div>
) : (
<EmptyState
title="尚未选择节点"
description="请从节点列表中点击“部署”打开当前节点的专属安装命令。"
/>
)}
</AppModal>
<AppModal <AppModal
isOpen={isEditorOpen} isOpen={isEditorOpen}
onClose={handleReset} onClose={handleReset}
title={editingNodeId ? '编辑节点' : '新增节点'} title={editingNodeId ? '编辑节点' : '新增节点'}
description="预创建节点后会立即生成节点专属 Token,可继续复制专属安装命令。" description="预创建节点后可在详情页查看专属 Token、部署命令与更新控制。"
footer={ footer={
<div className="flex flex-wrap justify-end gap-3"> <div className="flex flex-wrap justify-end gap-3">
<SecondaryButton <SecondaryButton
-1
View File
@@ -4,7 +4,6 @@ export interface NodeItem {
name: string; name: string;
ip: string; ip: string;
agent_token: string; agent_token: string;
pending: boolean;
auto_update_enabled: boolean; auto_update_enabled: boolean;
update_requested: boolean; update_requested: boolean;
agent_version: string; agent_version: string;
+133
View File
@@ -0,0 +1,133 @@
import type { NodeItem } from '@/features/nodes/types';
export function isMeaningfulTime(value: string | null | undefined) {
return Boolean(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 === 'failed') {
return 'danger';
}
return 'warning';
}
export function getApplyLabel(result: NodeItem['latest_apply_result']) {
if (result === 'success') {
return '成功';
}
if (result === 'failed') {
return '失败';
}
return '暂无';
}
export function getUpdateMode(node: NodeItem) {
if (node.update_requested) {
return { label: '等待更新', variant: 'warning' as const };
}
if (node.auto_update_enabled) {
return { label: '自动更新', variant: 'success' as const };
}
return { label: '手动更新', variant: 'info' as const };
}
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/ATSFlare/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');
}
@@ -14,7 +14,10 @@ import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal'; import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { publishConfigVersion } from '@/features/config-versions/api/config-versions'; import {
getConfigVersionDiff,
publishConfigVersion,
} from '@/features/config-versions/api/config-versions';
import { import {
createProxyRoute, createProxyRoute,
deleteProxyRoute, deleteProxyRoute,
@@ -54,7 +57,10 @@ const proxyRouteSchema = z
.string() .string()
.trim() .trim()
.min(1, '请输入源站地址') .min(1, '请输入源站地址')
.refine((value) => /^https?:\/\//.test(value), '源站地址必须以 http:// 或 https:// 开头') .refine(
(value) => /^https?:\/\//.test(value),
'源站地址必须以 http:// 或 https:// 开头',
)
.refine((value) => { .refine((value) => {
try { try {
new URL(value); new URL(value);
@@ -133,6 +139,7 @@ const defaultValues: ProxyRouteFormValues = {
const routesQueryKey = ['proxy-routes']; const routesQueryKey = ['proxy-routes'];
const certificatesQueryKey = ['tls-certificates']; const certificatesQueryKey = ['tls-certificates'];
const versionsQueryKey = ['config-versions'];
function getErrorMessage(error: unknown) { function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; return error instanceof Error ? error.message : '请求失败,请稍后重试。';
@@ -163,7 +170,8 @@ function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
origin_url: values.origin_url.trim(), origin_url: values.origin_url.trim(),
enabled: values.enabled, enabled: values.enabled,
enable_https: values.enable_https, enable_https: values.enable_https,
cert_id: values.enable_https && values.cert_id ? Number(values.cert_id) : null, cert_id:
values.enable_https && values.cert_id ? Number(values.cert_id) : null,
redirect_http: values.enable_https ? values.redirect_http : false, redirect_http: values.enable_https ? values.redirect_http : false,
custom_headers: values.custom_headers custom_headers: values.custom_headers
.map((item) => ({ key: item.key.trim(), value: item.value.trim() })) .map((item) => ({ key: item.key.trim(), value: item.value.trim() }))
@@ -217,7 +225,8 @@ export function ProxyRoutesPage() {
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null); const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false);
const [matchResult, setMatchResult] = useState<ManagedDomainMatchResult | null>(null); const [matchResult, setMatchResult] =
useState<ManagedDomainMatchResult | null>(null);
const [isMatching, setIsMatching] = useState(false); const [isMatching, setIsMatching] = useState(false);
const form = useForm<ProxyRouteFormValues>({ const form = useForm<ProxyRouteFormValues>({
@@ -232,8 +241,14 @@ export function ProxyRoutesPage() {
const watchedDomain = useWatch({ control: form.control, name: 'domain' }); const watchedDomain = useWatch({ control: form.control, name: 'domain' });
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' }); const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
const watchedEnableHttps = useWatch({ control: form.control, name: 'enable_https' }); const watchedEnableHttps = useWatch({
const watchedRedirectHttp = useWatch({ control: form.control, name: 'redirect_http' }); control: form.control,
name: 'enable_https',
});
const watchedRedirectHttp = useWatch({
control: form.control,
name: 'redirect_http',
});
const watchedCertId = useWatch({ control: form.control, name: 'cert_id' }); const watchedCertId = useWatch({ control: form.control, name: 'cert_id' });
const routesQuery = useQuery({ const routesQuery = useQuery({
@@ -254,7 +269,10 @@ export function ProxyRoutesPage() {
: createProxyRoute(payload); : createProxyRoute(payload);
}, },
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: editingRouteId ? '规则已更新。' : '规则已创建。' }); setFeedback({
tone: 'success',
message: editingRouteId ? '规则已更新。' : '规则已创建。',
});
setEditingRouteId(null); setEditingRouteId(null);
setIsEditorOpen(false); setIsEditorOpen(false);
setMatchResult(null); setMatchResult(null);
@@ -279,14 +297,43 @@ export function ProxyRoutesPage() {
const publishMutation = useMutation({ const publishMutation = useMutation({
mutationFn: publishConfigVersion, mutationFn: publishConfigVersion,
onSuccess: (version) => { onSuccess: async (version) => {
setFeedback({ tone: 'success', message: `发布成功,版本 ${version.version}` }); setFeedback({
tone: 'success',
message: `发布成功,版本 ${version.version}`,
});
await queryClient.invalidateQueries({ queryKey: versionsQueryKey });
}, },
onError: (error) => { onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}, },
}); });
const handlePublish = async () => {
setFeedback(null);
try {
const diff = await getConfigVersionDiff();
const hasChanges =
diff.added_domains.length > 0 ||
diff.removed_domains.length > 0 ||
diff.modified_domains.length > 0 ||
!diff.active_version;
if (!hasChanges) {
setFeedback({
tone: 'info',
message: '当前规则没有变更,已阻止重复发布。',
});
return;
}
publishMutation.mutate();
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
useEffect(() => { useEffect(() => {
if (!watchedEnableHttps) { if (!watchedEnableHttps) {
setMatchResult(null); setMatchResult(null);
@@ -333,7 +380,10 @@ export function ProxyRoutesPage() {
}; };
}, [watchedDomain, watchedEnableHttps, form]); }, [watchedDomain, watchedEnableHttps, form]);
const certificates = useMemo(() => certificatesQuery.data ?? [], [certificatesQuery.data]); const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const certificateMap = useMemo( const certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])), () => new Map(certificates.map((item) => [item.id, item])),
[certificates], [certificates],
@@ -390,288 +440,376 @@ export function ProxyRoutesPage() {
return ( return (
<> <>
<div className='space-y-6'> <div className="space-y-6">
<PageHeader <PageHeader
title='反代规则' title="反代规则"
description='维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。' description="维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。"
action={ action={
<> <>
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}> <PrimaryButton
{publishMutation.isPending ? '发布中...' : '发布当前规则'} type="button"
</PrimaryButton> onClick={() => void handlePublish()}
<SecondaryButton type='button' onClick={handleCreate}> disabled={publishMutation.isPending}
新增规则 >
</SecondaryButton> {publishMutation.isPending ? '发布中...' : '发布当前规则'}
</> </PrimaryButton>
} <SecondaryButton type="button" onClick={handleCreate}>
/> 新增规则
</SecondaryButton>
</>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null} {feedback ? (
<InlineMessage tone={feedback.tone} message={feedback.message} />
) : null}
<AppCard title='规则列表'> <AppCard title="规则列表">
{routesQuery.isLoading ? ( {routesQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : routesQuery.isError ? ( ) : routesQuery.isError ? (
<ErrorState title='规则列表加载失败' description={getErrorMessage(routesQuery.error)} /> <ErrorState
) : routes.length === 0 ? ( title="规则列表加载失败"
<EmptyState title='暂无反代规则' description='请先创建至少一条规则,然后再进行发布。' /> description={getErrorMessage(routesQuery.error)}
) : ( />
<div className='overflow-x-auto'> ) : routes.length === 0 ? (
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'> <EmptyState
<thead> title="暂无反代规则"
<tr className='text-[var(--foreground-secondary)]'> description="请先创建至少一条规则,然后再进行发布。"
<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'>HTTPS</th> <div className="overflow-x-auto">
<th className='px-3 py-3 font-medium'>请求头</th> <table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<th className='px-3 py-3 font-medium'>状态</th> <thead>
<th className='px-3 py-3 font-medium'>备注</th> <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> <th className="px-3 py-3 font-medium">HTTPS</th>
</thead> <th className="px-3 py-3 font-medium">请求头</th>
<tbody className='divide-y divide-[var(--border-default)]'> <th className="px-3 py-3 font-medium">状态</th>
{routes.map((route) => { <th className="px-3 py-3 font-medium">备注</th>
const headers = parseCustomHeaders(route.custom_headers); <th className="px-3 py-3 font-medium">更新时间</th>
const certificate = route.cert_id ? certificateMap.get(route.cert_id) : null; <th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{routes.map((route) => {
const headers = parseCustomHeaders(route.custom_headers);
const certificate = route.cert_id
? certificateMap.get(route.cert_id)
: null;
return ( return (
<tr key={route.id} className='align-top'> <tr key={route.id} className="align-top">
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{route.domain}</td> <td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{route.origin_url}</td> {route.domain}
<td className='px-3 py-4'> </td>
{route.enable_https ? ( <td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className='space-y-2'> {route.origin_url}
<StatusBadge </td>
label={route.redirect_http ? 'HTTPS + 重定向' : 'HTTPS'} <td className="px-3 py-4">
variant='info' {route.enable_https ? (
/> <div className="space-y-2">
<p className='text-xs text-[var(--foreground-secondary)]'> <StatusBadge
{certificate ? certificate.name : '证书未加载'} label={
</p> route.redirect_http
? 'HTTPS + 重定向'
: 'HTTPS'
}
variant="info"
/>
</div>
) : (
<StatusBadge label="HTTP" variant="warning" />
)}
</td>
<td className="px-3 py-4">
<StatusBadge
label={
headers.length > 0 ? `${headers.length} 条` : '无'
}
variant={headers.length > 0 ? 'success' : 'warning'}
/>
</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)]">
{route.remark || '—'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(route.updated_at)}
</td>
<td className="px-3 py-4">
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => handleEdit(route)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDelete(route)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div> </div>
) : ( </td>
<StatusBadge label='HTTP' variant='warning' /> </tr>
)} );
</td> })}
<td className='px-3 py-4'> </tbody>
<StatusBadge </table>
label={headers.length > 0 ? `${headers.length} 条` : '无'} </div>
variant={headers.length > 0 ? 'success' : 'warning'} )}
/> </AppCard>
</td> </div>
<td className='px-3 py-4'> <AppModal
<StatusBadge label={route.enabled ? '启用' : '停用'} variant={route.enabled ? 'success' : 'warning'} /> isOpen={isEditorOpen}
</td> onClose={handleReset}
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{route.remark || '—'}</td> title={editingRouteId ? '编辑规则' : '新增规则'}
<td className='px-3 py-4 text-[var(--foreground-secondary)]'> description="新增或修改反代规则后,可直接回到列表页继续发布。"
{formatDateTime(route.updated_at)} size="xl"
</td> footer={
<td className='px-3 py-4'> <div className="flex flex-wrap justify-end gap-3">
<div className='flex flex-wrap gap-2'> <SecondaryButton
<SecondaryButton type="button"
type='button' onClick={handleReset}
onClick={() => handleEdit(route)} disabled={saveMutation.isPending}
className='px-3 py-2 text-xs' >
> 取消
编辑 </SecondaryButton>
</SecondaryButton> <PrimaryButton
<DangerButton type="submit"
type='button' form="proxy-route-editor-form"
onClick={() => handleDelete(route)} disabled={saveMutation.isPending}
disabled={deleteMutation.isPending} >
className='px-3 py-2 text-xs' {saveMutation.isPending
> ? '保存中...'
删除 : editingRouteId
</DangerButton> ? '保存修改'
</div> : '新增规则'}
</td> </PrimaryButton>
</tr> </div>
); }
})} >
</tbody> <form
</table> id="proxy-route-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="域名"
hint="示例:example.com"
error={form.formState.errors.domain?.message}
>
<ResourceInput
placeholder="example.com"
{...form.register('domain')}
/>
</ResourceField>
<ResourceField
label="源站地址"
hint="示例:https://origin.internal"
error={form.formState.errors.origin_url?.message}
>
<ResourceInput
placeholder="https://origin.internal"
{...form.register('origin_url')}
/>
</ResourceField>
</div> </div>
)}
</AppCard>
</div>
<AppModal
isOpen={isEditorOpen}
onClose={handleReset}
title={editingRouteId ? '编辑规则' : '新增规则'}
description='新增或修改反代规则后,可直接回到列表页继续发布。'
size='xl'
footer={
<div className='flex flex-wrap justify-end gap-3'>
<SecondaryButton type='button' onClick={handleReset} disabled={saveMutation.isPending}>
取消
</SecondaryButton>
<PrimaryButton type='submit' form='proxy-route-editor-form' disabled={saveMutation.isPending}>
{saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'}
</PrimaryButton>
</div>
}
>
<form id='proxy-route-editor-form' className='space-y-5' onSubmit={handleSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<ResourceField
label='域名'
hint='示例:example.com'
error={form.formState.errors.domain?.message}
>
<ResourceInput placeholder='example.com' {...form.register('domain')} />
</ResourceField>
<ResourceField
label='源站地址'
hint='示例:https://origin.internal'
error={form.formState.errors.origin_url?.message}
>
<ResourceInput placeholder='https://origin.internal' {...form.register('origin_url')} />
</ResourceField>
</div>
<div className='grid gap-4 lg:grid-cols-2'> <div className="grid gap-4 lg:grid-cols-2">
<ToggleField <ToggleField
label='启用规则' label="启用规则"
description='关闭后该规则不会参与配置渲染与发布。' description="关闭后该规则不会参与配置渲染与发布。"
checked={watchedEnabled} checked={watchedEnabled}
onChange={(checked) => form.setValue('enabled', checked, { shouldDirty: true })} onChange={(checked) =>
/> form.setValue('enabled', checked, { shouldDirty: true })
<ToggleField
label='启用 HTTPS'
description='启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。'
checked={watchedEnableHttps}
onChange={(checked) => {
form.setValue('enable_https', checked, { shouldDirty: true, shouldValidate: true });
if (!checked) {
form.setValue('cert_id', '', { shouldDirty: true, shouldValidate: true });
form.setValue('redirect_http', false, { shouldDirty: true, shouldValidate: true });
} }
}} />
/> <ToggleField
</div> label="启用 HTTPS"
description="启用后必须关联 TLS 证书,可选择是否将 HTTP 自动重定向到 HTTPS。"
checked={watchedEnableHttps}
onChange={(checked) => {
form.setValue('enable_https', checked, {
shouldDirty: true,
shouldValidate: true,
});
if (!checked) {
form.setValue('cert_id', '', {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('redirect_http', false, {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
<div className='grid gap-4 lg:grid-cols-[1.2fr_0.8fr]'> <div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<ResourceField <ResourceField
label='TLS 证书' label="TLS 证书"
hint='启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。' hint="启用 HTTPS 后可自动推荐匹配证书,也支持手动选择。"
error={form.formState.errors.cert_id?.message} error={form.formState.errors.cert_id?.message}
> >
<ResourceSelect <ResourceSelect
value={watchedCertId} value={watchedCertId}
disabled={!watchedEnableHttps || certificatesQuery.isLoading} disabled={!watchedEnableHttps || certificatesQuery.isLoading}
onChange={(event) => onChange={(event) =>
form.setValue('cert_id', event.target.value, { form.setValue('cert_id', event.target.value, {
shouldDirty: true,
shouldValidate: true,
})
}
>
<option value="">请选择证书</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{buildCertificateLabel(certificate)}
</option>
))}
</ResourceSelect>
</ResourceField>
<ToggleField
label="HTTP 跳转 HTTPS"
description="仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。"
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true, shouldDirty: true,
shouldValidate: true, shouldValidate: true,
}) })
} }
> />
<option value=''>请选择证书</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{buildCertificateLabel(certificate)}
</option>
))}
</ResourceSelect>
</ResourceField>
<ToggleField
label='HTTP 跳转 HTTPS'
description='仅在启用 HTTPS 后可开启。开启后会将 HTTP 请求重定向到 HTTPS。'
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>自定义请求头</p>
<p className='mt-1 text-xs leading-5 text-[var(--foreground-secondary)]'>
可为空。若填写,请保证 Header 名称合法且不包含换行。
</p>
</div>
<SecondaryButton
type='button'
onClick={() => append({ key: '', value: '' })}
className='px-3 py-2 text-xs'
>
添加请求头
</SecondaryButton>
</div> </div>
<div className='mt-4 space-y-4'> <div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
{fields.map((field, index) => ( <div className="flex flex-wrap items-center justify-between gap-3">
<div key={field.id} className='grid gap-3 md:grid-cols-[1fr_1fr_auto]'> <div>
<ResourceField <p className="text-sm font-semibold text-[var(--foreground-primary)]">
label={index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`} 自定义请求头
error={form.formState.errors.custom_headers?.[index]?.key?.message} </p>
<p className="mt-1 text-xs leading-5 text-[var(--foreground-secondary)]">
可为空。若填写,请保证 Header 名称合法且不包含换行。
</p>
</div>
<SecondaryButton
type="button"
onClick={() => append({ key: '', value: '' })}
className="px-3 py-2 text-xs"
>
添加请求头
</SecondaryButton>
</div>
<div className="mt-4 space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="grid gap-3 md:grid-cols-[1fr_1fr_auto]"
> >
<ResourceInput <ResourceField
placeholder='X-Trace-Id' label={
{...form.register(`custom_headers.${index}.key`)} index === 0 ? 'Header 名称' : `Header 名称 ${index + 1}`
/> }
</ResourceField> error={
<ResourceField form.formState.errors.custom_headers?.[index]?.key
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`} ?.message
error={form.formState.errors.custom_headers?.[index]?.value?.message} }
>
<ResourceInput
placeholder='$request_id'
{...form.register(`custom_headers.${index}.value`)}
/>
</ResourceField>
<div className='flex items-end'>
<DangerButton
type='button'
onClick={() => handleRemoveHeader(index)}
className='w-full px-3 py-3 text-xs md:w-auto'
> >
删除 <ResourceInput
</DangerButton> placeholder="X-Trace-Id"
{...form.register(`custom_headers.${index}.key`)}
/>
</ResourceField>
<ResourceField
label={index === 0 ? 'Header 值' : `Header 值 ${index + 1}`}
error={
form.formState.errors.custom_headers?.[index]?.value
?.message
}
>
<ResourceInput
placeholder="$request_id"
{...form.register(`custom_headers.${index}.value`)}
/>
</ResourceField>
<div className="flex items-end">
<DangerButton
type="button"
onClick={() => handleRemoveHeader(index)}
className="w-full px-3 py-3 text-xs md:w-auto"
>
删除
</DangerButton>
</div>
</div> </div>
</div> ))}
))}
</div>
</div>
<AppCard title='证书匹配提示' description='根据域名自动匹配托管证书,优先使用精确匹配规则。'>
<div className='space-y-3'>
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
{getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)}
</p>
{matchResult?.matched && matchResult.candidates.length > 1 ? (
<div className='flex flex-wrap gap-2'>
{matchResult.candidates.map((candidate) => (
<StatusBadge
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
label={`${candidate.domain} → ${candidate.certificate_name}`}
variant={candidate.match_type === 'exact' ? 'success' : 'info'}
/>
))}
</div>
) : null}
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]'>
当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`}
</div> </div>
</div> </div>
</AppCard>
<ResourceField <AppCard
label='备注' title="证书匹配提示"
hint='可选,便于标记用途、责任人或特殊说明。' description="根据域名自动匹配托管证书,优先使用精确匹配规则。"
error={form.formState.errors.remark?.message} >
> <div className="space-y-3">
<ResourceTextarea placeholder='例如:主站生产流量入口' {...form.register('remark')} /> <p className="text-sm leading-6 text-[var(--foreground-secondary)]">
</ResourceField> {getMatchMessage(
</form> matchResult,
</AppModal> isMatching,
watchedDomain,
watchedEnableHttps,
)}
</p>
{matchResult?.matched && matchResult.candidates.length > 1 ? (
<div className="flex flex-wrap gap-2">
{matchResult.candidates.map((candidate) => (
<StatusBadge
key={`${candidate.managed_domain_id}-${candidate.certificate_id}`}
label={`${candidate.domain} → ${candidate.certificate_name}`}
variant={
candidate.match_type === 'exact' ? 'success' : 'info'
}
/>
))}
</div>
) : null}
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]">
当前可选证书:
{certificatesQuery.isLoading
? '加载中...'
: `${certificates.length} 张`}
</div>
</div>
</AppCard>
<ResourceField
label="备注"
hint="可选,便于标记用途、责任人或特殊说明。"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea
placeholder="例如:主站生产流量入口"
{...form.register('remark')}
/>
</ResourceField>
</form>
</AppModal>
</> </>
); );
} }
@@ -38,7 +38,11 @@ import { formatDateTime } from '@/lib/utils/date';
const tlsCertificatesQueryKey = ['tls-certificates', 'list'] as const; const tlsCertificatesQueryKey = ['tls-certificates', 'list'] as const;
const manualImportSchema = z.object({ const manualImportSchema = z.object({
name: z.string().trim().min(1, '请输入证书名称').max(255, '证书名称不能超过 255 个字符'), name: z
.string()
.trim()
.min(1, '请输入证书名称')
.max(255, '证书名称不能超过 255 个字符'),
cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'), cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'),
key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'), key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'),
remark: z.string().max(255, '备注不能超过 255 个字符'), remark: z.string().max(255, '备注不能超过 255 个字符'),
@@ -92,7 +96,9 @@ function getCertificateStatus(certificate: TlsCertificateItem) {
return { label: '有效', variant: 'success' as const }; return { label: '有效', variant: 'success' as const };
} }
function toManualPayload(values: ManualImportFormValues): TlsCertificateMutationPayload { function toManualPayload(
values: ManualImportFormValues,
): TlsCertificateMutationPayload {
return { return {
name: values.name.trim(), name: values.name.trim(),
cert_pem: values.cert_pem.trim(), cert_pem: values.cert_pem.trim(),
@@ -122,7 +128,9 @@ export function TlsCertificatesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [fileForm, setFileForm] = useState<FileImportFormValues>(defaultFileValues); const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
const [fileForm, setFileForm] =
useState<FileImportFormValues>(defaultFileValues);
const [certFile, setCertFile] = useState<File | null>(null); const [certFile, setCertFile] = useState<File | null>(null);
const [keyFile, setKeyFile] = useState<File | null>(null); const [keyFile, setKeyFile] = useState<File | null>(null);
const [fileInputNonce, setFileInputNonce] = useState(0); const [fileInputNonce, setFileInputNonce] = useState(0);
@@ -138,12 +146,16 @@ export function TlsCertificatesPage() {
}); });
const manualImportMutation = useMutation({ const manualImportMutation = useMutation({
mutationFn: async (values: ManualImportFormValues) => createTlsCertificate(toManualPayload(values)), mutationFn: async (values: ManualImportFormValues) =>
createTlsCertificate(toManualPayload(values)),
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已导入。' }); setFeedback({ tone: 'success', message: '证书已导入。' });
setImportMode('manual');
setIsImportModalOpen(false); setIsImportModalOpen(false);
manualForm.reset(defaultManualValues); manualForm.reset(defaultManualValues);
await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey }); await queryClient.invalidateQueries({
queryKey: tlsCertificatesQueryKey,
});
}, },
onError: (error) => { onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -155,12 +167,15 @@ export function TlsCertificatesPage() {
importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)), importTlsCertificateFiles(toFilePayload(values, certFile, keyFile)),
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书文件已导入。' }); setFeedback({ tone: 'success', message: '证书文件已导入。' });
setImportMode('manual');
setIsImportModalOpen(false); setIsImportModalOpen(false);
setFileForm(defaultFileValues); setFileForm(defaultFileValues);
setCertFile(null); setCertFile(null);
setKeyFile(null); setKeyFile(null);
setFileInputNonce((value) => value + 1); setFileInputNonce((value) => value + 1);
await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey }); await queryClient.invalidateQueries({
queryKey: tlsCertificatesQueryKey,
});
}, },
onError: (error) => { onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
@@ -171,14 +186,19 @@ export function TlsCertificatesPage() {
mutationFn: deleteTlsCertificate, mutationFn: deleteTlsCertificate,
onSuccess: async () => { onSuccess: async () => {
setFeedback({ tone: 'success', message: '证书已删除。' }); setFeedback({ tone: 'success', message: '证书已删除。' });
await queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey }); await queryClient.invalidateQueries({
queryKey: tlsCertificatesQueryKey,
});
}, },
onError: (error) => { onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) }); setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}, },
}); });
const certificates = useMemo(() => certificatesQuery.data ?? [], [certificatesQuery.data]); const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const handleManualSubmit = manualForm.handleSubmit((values) => { const handleManualSubmit = manualForm.handleSubmit((values) => {
setFeedback(null); setFeedback(null);
@@ -201,207 +221,308 @@ export function TlsCertificatesPage() {
}; };
const handleCloseImportModal = () => { const handleCloseImportModal = () => {
setImportMode('manual');
setIsImportModalOpen(false); setIsImportModalOpen(false);
}; };
return ( return (
<> <>
<div className='space-y-6'> <div className="space-y-6">
<PageHeader <PageHeader
title='TLS 证书' title="TLS 证书"
description='支持手动粘贴 PEM 导入和文件上传导入,并展示证书有效期与到期风险。' description="支持手动粘贴 PEM 导入和文件上传导入,并展示证书有效期与到期风险。"
action={ action={
<PrimaryButton type='button' onClick={() => setIsImportModalOpen(true)}> <PrimaryButton
导入证书 type="button"
</PrimaryButton> onClick={() => {
} setImportMode('manual');
/> setIsImportModalOpen(true);
}}
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null} >
导入证书
<AppCard
title='证书列表'
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: tlsCertificatesQueryKey })}
>
刷新
</SecondaryButton>
}
>
{certificatesQuery.isLoading ? (
<LoadingState />
) : certificatesQuery.isError ? (
<ErrorState title='证书列表加载失败' description={getErrorMessage(certificatesQuery.error)} />
) : certificates.length === 0 ? (
<EmptyState title='暂无证书' description='请先导入至少一张证书,再为 HTTPS 规则或域名绑定使用。' />
) : (
<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>
<th className='px-3 py-3 font-medium'>操作</th>
</tr>
</thead>
<tbody className='divide-y divide-[var(--border-default)]'>
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
return (
<tr key={certificate.id} className='align-top'>
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{certificate.name}</td>
<td className='px-3 py-4'>
<StatusBadge label={status.label} variant={status.variant} />
</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
<div className='space-y-1'>
<p>{formatDateTime(certificate.not_before)}</p>
<p>{formatDateTime(certificate.not_after)}</p>
</div>
</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{certificate.remark || '—'}</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
{formatDateTime(certificate.updated_at)}
</td>
<td className='px-3 py-4'>
<DangerButton
type='button'
onClick={() => handleDelete(certificate)}
disabled={deleteMutation.isPending}
className='px-3 py-2 text-xs'
>
删除
</DangerButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AppCard>
</div>
<AppModal
isOpen={isImportModalOpen}
onClose={handleCloseImportModal}
title='导入证书'
description='手动粘贴和文件导入都保留在同一个弹窗中,避免压缩主列表空间。'
size='xl'
>
<div className='grid gap-6 xl:grid-cols-2'>
<AppCard title='手动导入' description='直接粘贴 PEM 证书和私钥内容,适合快速录入已有证书。'>
<form className='space-y-5' onSubmit={handleManualSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<ResourceField label='证书名称' error={manualForm.formState.errors.name?.message}>
<ResourceInput placeholder='example-com' {...manualForm.register('name')} />
</ResourceField>
<ResourceField
label='备注'
hint='可选,用于记录证书用途或来源。'
error={manualForm.formState.errors.remark?.message}
>
<ResourceInput placeholder='例如:主站生产证书' {...manualForm.register('remark')} />
</ResourceField>
</div>
<ResourceField label='证书 PEM' error={manualForm.formState.errors.cert_pem?.message}>
<ResourceTextarea
placeholder='-----BEGIN CERTIFICATE-----'
className='min-h-40 font-mono text-xs'
{...manualForm.register('cert_pem')}
/>
</ResourceField>
<ResourceField label='私钥 PEM' error={manualForm.formState.errors.key_pem?.message}>
<ResourceTextarea
placeholder='-----BEGIN PRIVATE KEY-----'
className='min-h-40 font-mono text-xs'
{...manualForm.register('key_pem')}
/>
</ResourceField>
<PrimaryButton type='submit' disabled={manualImportMutation.isPending}>
{manualImportMutation.isPending ? '导入中...' : '导入证书'}
</PrimaryButton> </PrimaryButton>
</form> }
</AppCard> />
<AppCard title='文件导入' description='上传证书文件和私钥文件,适合直接复用现有 PEM 文件。'> {feedback ? (
<form className='space-y-5' onSubmit={handleFileSubmit}> <InlineMessage tone={feedback.tone} message={feedback.message} />
<div className='grid gap-4 md:grid-cols-2'> ) : null}
<ResourceField label='证书名称'>
<ResourceInput
value={fileForm.name}
onChange={(event) =>
setFileForm((current) => ({ ...current, name: event.target.value }))
}
placeholder='wildcard-example'
/>
</ResourceField>
<ResourceField label='备注' hint='可选,用于记录部署环境或域名范围。'>
<ResourceInput
value={fileForm.remark}
onChange={(event) =>
setFileForm((current) => ({ ...current, remark: event.target.value }))
}
placeholder='例如:泛域名生产证书'
/>
</ResourceField>
</div>
<div className='grid gap-4 md:grid-cols-2'> <AppCard
<ResourceField title="证书列表"
label='证书文件' action={
hint={certFile ? `已选择:${certFile.name}` : '请选择 PEM/CRT 文件'} <SecondaryButton
> type="button"
<ResourceInput onClick={() =>
key={`cert-${fileInputNonce}`} void queryClient.invalidateQueries({
type='file' queryKey: tlsCertificatesQueryKey,
accept='.pem,.crt,.cer' })
onChange={(event) => setCertFile(event.target.files?.[0] ?? null)} }
/> >
</ResourceField> 刷新
<ResourceField </SecondaryButton>
label='私钥文件' }
hint={keyFile ? `已选择:${keyFile.name}` : '请选择 KEY/PEM 文件'} >
> {certificatesQuery.isLoading ? (
<ResourceInput <LoadingState />
key={`key-${fileInputNonce}`} ) : certificatesQuery.isError ? (
type='file' <ErrorState
accept='.key,.pem' title="证书列表加载失败"
onChange={(event) => setKeyFile(event.target.files?.[0] ?? null)} description={getErrorMessage(certificatesQuery.error)}
/> />
</ResourceField> ) : certificates.length === 0 ? (
</div> <EmptyState
title="暂无证书"
description="请先导入至少一张证书,再为 HTTPS 规则或域名绑定使用。"
/>
) : (
<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>
<th className="px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
<div className='flex flex-wrap gap-3'> return (
<PrimaryButton type='submit' disabled={fileImportMutation.isPending}> <tr key={certificate.id} className="align-top">
{fileImportMutation.isPending ? '上传中...' : '上传文件'} <td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
</PrimaryButton> {certificate.name}
<SecondaryButton </td>
type='button' <td className="px-3 py-4">
onClick={() => { <StatusBadge
setFileForm(defaultFileValues); label={status.label}
setCertFile(null); variant={status.variant}
setKeyFile(null); />
setFileInputNonce((value) => value + 1); </td>
}} <td className="px-3 py-4 text-[var(--foreground-secondary)]">
disabled={fileImportMutation.isPending} <div className="space-y-1">
> <p>{formatDateTime(certificate.not_before)}</p>
清空文件 <p>{formatDateTime(certificate.not_after)}</p>
</SecondaryButton> </div>
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{certificate.remark || '—'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatDateTime(certificate.updated_at)}
</td>
<td className="px-3 py-4">
<DangerButton
type="button"
onClick={() => handleDelete(certificate)}
disabled={deleteMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</td>
</tr>
);
})}
</tbody>
</table>
</div> </div>
</form> )}
</AppCard> </AppCard>
</div> </div>
</AppModal> <AppModal
isOpen={isImportModalOpen}
onClose={handleCloseImportModal}
title="导入证书"
description="手动导入和文档导入通过标签切换,避免在同一层同时堆叠两套表单。"
size="xl"
>
<div className="space-y-6">
<div className="inline-flex rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-1">
<button
type="button"
onClick={() => setImportMode('manual')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
importMode === 'manual'
? 'bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'text-[var(--foreground-secondary)] hover:text-[var(--foreground-primary)]'
}`}
>
手动导入
</button>
<button
type="button"
onClick={() => setImportMode('file')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
importMode === 'file'
? 'bg-[var(--brand-primary)] text-[var(--foreground-inverse)]'
: 'text-[var(--foreground-secondary)] hover:text-[var(--foreground-primary)]'
}`}
>
文档导入
</button>
</div>
{importMode === 'manual' ? (
<AppCard
title="手动导入"
description="直接粘贴 PEM 证书和私钥内容,适合快速录入已有证书。"
>
<form className="space-y-5" onSubmit={handleManualSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书名称"
error={manualForm.formState.errors.name?.message}
>
<ResourceInput
placeholder="example-com"
{...manualForm.register('name')}
/>
</ResourceField>
<ResourceField
label="备注"
hint="可选,用于记录证书用途或来源。"
error={manualForm.formState.errors.remark?.message}
>
<ResourceInput
placeholder="例如:主站生产证书"
{...manualForm.register('remark')}
/>
</ResourceField>
</div>
<ResourceField
label="证书 PEM"
error={manualForm.formState.errors.cert_pem?.message}
>
<ResourceTextarea
placeholder="-----BEGIN CERTIFICATE-----"
className="min-h-40 font-mono text-xs"
{...manualForm.register('cert_pem')}
/>
</ResourceField>
<ResourceField
label="私钥 PEM"
error={manualForm.formState.errors.key_pem?.message}
>
<ResourceTextarea
placeholder="-----BEGIN PRIVATE KEY-----"
className="min-h-40 font-mono text-xs"
{...manualForm.register('key_pem')}
/>
</ResourceField>
<PrimaryButton
type="submit"
disabled={manualImportMutation.isPending}
>
{manualImportMutation.isPending ? '导入中...' : '导入证书'}
</PrimaryButton>
</form>
</AppCard>
) : (
<AppCard
title="文档导入"
description="上传证书文件和私钥文件,适合直接复用现有 PEM 文件。"
>
<form className="space-y-5" onSubmit={handleFileSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField label="证书名称">
<ResourceInput
value={fileForm.name}
onChange={(event) =>
setFileForm((current) => ({
...current,
name: event.target.value,
}))
}
placeholder="wildcard-example"
/>
</ResourceField>
<ResourceField
label="备注"
hint="可选,用于记录部署环境或域名范围。"
>
<ResourceInput
value={fileForm.remark}
onChange={(event) =>
setFileForm((current) => ({
...current,
remark: event.target.value,
}))
}
placeholder="例如:泛域名生产证书"
/>
</ResourceField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书文件"
hint={
certFile
? `已选择:${certFile.name}`
: '请选择 PEM/CRT 文件'
}
>
<ResourceInput
key={`cert-${fileInputNonce}`}
type="file"
accept=".pem,.crt,.cer"
onChange={(event) =>
setCertFile(event.target.files?.[0] ?? null)
}
/>
</ResourceField>
<ResourceField
label="私钥文件"
hint={
keyFile
? `已选择:${keyFile.name}`
: '请选择 KEY/PEM 文件'
}
>
<ResourceInput
key={`key-${fileInputNonce}`}
type="file"
accept=".key,.pem"
onChange={(event) =>
setKeyFile(event.target.files?.[0] ?? null)
}
/>
</ResourceField>
</div>
<div className="flex flex-wrap gap-3">
<PrimaryButton
type="submit"
disabled={fileImportMutation.isPending}
>
{fileImportMutation.isPending ? '上传中...' : '上传文件'}
</PrimaryButton>
<SecondaryButton
type="button"
onClick={() => {
setFileForm(defaultFileValues);
setCertFile(null);
setKeyFile(null);
setFileInputNonce((value) => value + 1);
}}
disabled={fileImportMutation.isPending}
>
清空文件
</SecondaryButton>
</div>
</form>
</AppCard>
)}
</div>
</AppModal>
</> </>
); );
} }
+6 -1
View File
@@ -18,6 +18,7 @@ export function getApiUrl(path: string) {
export async function apiRequest<T>(path: string, init?: RequestInit) { export async function apiRequest<T>(path: string, init?: RequestInit) {
const headers = new Headers(init?.headers ?? {}); const headers = new Headers(init?.headers ?? {});
const method = init?.method?.toUpperCase() ?? 'GET';
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) { if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
@@ -26,6 +27,7 @@ export async function apiRequest<T>(path: string, init?: RequestInit) {
const response = await fetch(getApiUrl(path), { const response = await fetch(getApiUrl(path), {
credentials: 'include', credentials: 'include',
headers, headers,
cache: method === 'GET' ? 'no-store' : init?.cache,
...init, ...init,
}); });
@@ -38,7 +40,10 @@ export async function apiRequest<T>(path: string, init?: RequestInit) {
} }
if (!response.ok) { if (!response.ok) {
throw new ApiError(payload?.message || `请求失败(${response.status})`, response.status); throw new ApiError(
payload?.message || `请求失败(${response.status})`,
response.status,
);
} }
if (!payload) { if (!payload) {
File diff suppressed because one or more lines are too long