From 13cc880f677a2f2c97b0d7dd160cead2f79510e4 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 11 Mar 2026 14:53:45 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=87=8D=E6=9E=84]=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=94=B9=E9=80=A0v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- atsf_server/docs/docs.go | 4 +- atsf_server/web/.eslintrc.json | 1 + .../app/(dashboard)/config-versions/page.tsx | 11 +- .../web/app/(dashboard)/nodes/page.tsx | 11 +- atsf_server/web/app/(dashboard)/page.tsx | 4 +- .../web/app/(dashboard)/proxy-routes/page.tsx | 11 +- .../config-versions/api/config-versions.ts | 31 + .../components/config-versions-page.tsx | 417 +++++++++++ .../web/features/config-versions/types.ts | 31 + .../components/dashboard-overview.tsx | 26 +- atsf_server/web/features/nodes/api/nodes.ts | 47 ++ .../features/nodes/components/nodes-page.tsx | 633 ++++++++++++++++ atsf_server/web/features/nodes/types.ts | 30 + .../features/proxy-routes/api/proxy-routes.ts | 41 ++ .../components/proxy-routes-page.tsx | 693 ++++++++++++++++++ .../web/features/proxy-routes/types.ts | 50 ++ .../shared/components/resource-primitives.tsx | 179 +++++ atsf_server/web/lib/constants/navigation.ts | 6 +- atsf_server/web/lib/utils/date.ts | 71 ++ 19 files changed, 2253 insertions(+), 44 deletions(-) create mode 100644 atsf_server/web/features/config-versions/api/config-versions.ts create mode 100644 atsf_server/web/features/config-versions/components/config-versions-page.tsx create mode 100644 atsf_server/web/features/config-versions/types.ts create mode 100644 atsf_server/web/features/nodes/api/nodes.ts create mode 100644 atsf_server/web/features/nodes/components/nodes-page.tsx create mode 100644 atsf_server/web/features/nodes/types.ts create mode 100644 atsf_server/web/features/proxy-routes/api/proxy-routes.ts create mode 100644 atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx create mode 100644 atsf_server/web/features/proxy-routes/types.ts create mode 100644 atsf_server/web/features/shared/components/resource-primitives.tsx create mode 100644 atsf_server/web/lib/utils/date.ts diff --git a/atsf_server/docs/docs.go b/atsf_server/docs/docs.go index b89c9179..3757386a 100644 --- a/atsf_server/docs/docs.go +++ b/atsf_server/docs/docs.go @@ -1429,8 +1429,8 @@ var SwaggerInfo = &swag.Spec{ Description: "ATSFlare Server 管理端与 Agent API 文档。", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", + //LeftDelim: "{{", + //RightDelim: "}}", } func init() { diff --git a/atsf_server/web/.eslintrc.json b/atsf_server/web/.eslintrc.json index 818cad86..5ffd7078 100644 --- a/atsf_server/web/.eslintrc.json +++ b/atsf_server/web/.eslintrc.json @@ -3,6 +3,7 @@ "ignorePatterns": [ ".next", "build", + "out", "coverage", "playwright-report", "test-results", diff --git a/atsf_server/web/app/(dashboard)/config-versions/page.tsx b/atsf_server/web/app/(dashboard)/config-versions/page.tsx index 0922483d..0ced55fd 100644 --- a/atsf_server/web/app/(dashboard)/config-versions/page.tsx +++ b/atsf_server/web/app/(dashboard)/config-versions/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; +import { ConfigVersionsPage } from '@/features/config-versions/components/config-versions-page'; -export default function ConfigVersionsPage() { - return ( - - ); +export default function ConfigVersionsRoute() { + return ; } diff --git a/atsf_server/web/app/(dashboard)/nodes/page.tsx b/atsf_server/web/app/(dashboard)/nodes/page.tsx index ab824c04..9665570b 100644 --- a/atsf_server/web/app/(dashboard)/nodes/page.tsx +++ b/atsf_server/web/app/(dashboard)/nodes/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; +import { NodesPage } from '@/features/nodes/components/nodes-page'; -export default function NodesPage() { - return ( - - ); +export default function NodesRoute() { + return ; } diff --git a/atsf_server/web/app/(dashboard)/page.tsx b/atsf_server/web/app/(dashboard)/page.tsx index 62f329cc..7e1a0bd6 100644 --- a/atsf_server/web/app/(dashboard)/page.tsx +++ b/atsf_server/web/app/(dashboard)/page.tsx @@ -5,8 +5,8 @@ export default function DashboardPage() { return (
diff --git a/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx b/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx index 5d08b2dc..86758892 100644 --- a/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx +++ b/atsf_server/web/app/(dashboard)/proxy-routes/page.tsx @@ -1,10 +1,5 @@ -import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton'; +import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page'; -export default function ProxyRoutesPage() { - return ( - - ); +export default function ProxyRoutesRoute() { + return ; } diff --git a/atsf_server/web/features/config-versions/api/config-versions.ts b/atsf_server/web/features/config-versions/api/config-versions.ts new file mode 100644 index 00000000..88f7eb59 --- /dev/null +++ b/atsf_server/web/features/config-versions/api/config-versions.ts @@ -0,0 +1,31 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + ConfigDiffResult, + ConfigPreviewResult, + ConfigVersionItem, +} from '@/features/config-versions/types'; + +export function getConfigVersions() { + return apiRequest('/config-versions/'); +} + +export function getConfigVersionPreview() { + return apiRequest('/config-versions/preview'); +} + +export function getConfigVersionDiff() { + return apiRequest('/config-versions/diff'); +} + +export function publishConfigVersion() { + return apiRequest('/config-versions/publish', { + method: 'POST', + }); +} + +export function activateConfigVersion(id: number) { + return apiRequest(`/config-versions/${id}/activate`, { + method: 'PUT', + }); +} diff --git a/atsf_server/web/features/config-versions/components/config-versions-page.tsx b/atsf_server/web/features/config-versions/components/config-versions-page.tsx new file mode 100644 index 00000000..ce600d52 --- /dev/null +++ b/atsf_server/web/features/config-versions/components/config-versions-page.tsx @@ -0,0 +1,417 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { + activateConfigVersion, + getConfigVersionDiff, + getConfigVersionPreview, + getConfigVersions, + publishConfigVersion, +} from '@/features/config-versions/api/config-versions'; +import type { + ConfigDiffResult, + ConfigPreviewResult, + ConfigVersionItem, + SupportFile, +} from '@/features/config-versions/types'; +import { + CodeBlock, + PrimaryButton, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +const versionsQueryKey = ['config-versions']; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function truncateChecksum(checksum: string) { + if (!checksum) { + return '—'; + } + + return checksum.length > 16 ? `${checksum.slice(0, 16)}...` : checksum; +} + +function DiffList({ title, items }: { title: string; items: string[] }) { + return ( +
+
+

{title}

+ 0 ? 'info' : 'warning'} /> +
+ {items.length > 0 ? ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ) : ( +

当前无相关变更。

+ )} +
+ ); +} + +function SupportFilesList({ files }: { files: SupportFile[] }) { + if (files.length === 0) { + return

当前发布不需要额外支持文件。

; + } + + return ( +
+ {files.map((file) => ( +
+ + {file.path} + + {file.content} +
+ ))} +
+ ); +} + +function VersionDetailCard({ + version, +}: { + version: ConfigVersionItem; +}) { + return ( + + } + > +
+
+

Checksum

+

{version.checksum}

+
+
+

创建人

+

{version.created_by || '系统'}

+
+
+

创建时间

+

{formatDateTime(version.created_at)}

+
+
+ +
+
+

快照 JSON

+ {version.snapshot_json} +
+
+

渲染结果

+ {version.rendered_config} +
+
+
+ ); +} + +function PublishPreviewCard({ + preview, + diff, + isPublishing, + onConfirm, + onCancel, +}: { + preview: ConfigPreviewResult; + diff: ConfigDiffResult; + isPublishing: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + } + > +
+
+
+

当前激活版本

+

{diff.active_version || '无'}

+
+
+

新增域名

+

{diff.added_domains.length}

+
+
+

删除域名

+

{diff.removed_domains.length}

+
+
+

修改域名

+

{diff.modified_domains.length}

+
+
+ +
+ + + +
+ +
+
+

渲染结果

+

Checksum:{preview.checksum}

+
+ {preview.rendered_config} +
+ +
+

支持文件

+ +
+ +
+ + {isPublishing ? '发布中...' : '确认发布'} + + + 取消预览 + +
+
+
+ ); +} + +export function ConfigVersionsPage() { + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState(null); + const [selectedVersion, setSelectedVersion] = useState(null); + const [publishPreview, setPublishPreview] = useState<{ + preview: ConfigPreviewResult; + diff: ConfigDiffResult; + } | null>(null); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + + const versionsQuery = useQuery({ + queryKey: versionsQueryKey, + queryFn: getConfigVersions, + }); + + const publishMutation = useMutation({ + mutationFn: publishConfigVersion, + onSuccess: async (version) => { + setFeedback({ tone: 'success', message: `发布成功,版本 ${version.version}` }); + setPublishPreview(null); + setSelectedVersion(version); + await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const activateMutation = useMutation({ + mutationFn: activateConfigVersion, + onSuccess: async (version) => { + setFeedback({ tone: 'success', message: `已激活版本 ${version.version}` }); + setSelectedVersion(version); + await queryClient.invalidateQueries({ queryKey: versionsQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const summary = useMemo(() => { + const versions = versionsQuery.data || []; + const activeCount = versions.filter((item) => item.is_active).length; + + return [ + { label: '版本总数', value: versions.length }, + { label: '激活版本', value: activeCount }, + { + label: '最近创建时间', + value: versions[0]?.created_at ? formatDateTime(versions[0].created_at) : '—', + }, + ]; + }, [versionsQuery.data]); + + const handleOpenPublishPreview = async () => { + setFeedback(null); + setIsPreviewLoading(true); + + try { + const [preview, diff] = await Promise.all([getConfigVersionPreview(), getConfigVersionDiff()]); + setPublishPreview({ preview, diff }); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } finally { + setIsPreviewLoading(false); + } + }; + + const handleActivate = (version: ConfigVersionItem) => { + if (version.is_active) { + return; + } + + if (!window.confirm(`确认激活版本 ${version.version} 吗?`)) { + return; + } + + setFeedback(null); + activateMutation.mutate(version.id); + }; + + const versions = versionsQuery.data || []; + + return ( +
+ + + {feedback ? : null} + +
+ + {isPreviewLoading ? '加载预览中...' : '预览并发布'} + + } + > +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ + {publishPreview ? ( + publishMutation.mutate()} + onCancel={() => setPublishPreview(null)} + /> + ) : ( + +
+

1. 使用“预览并发布”查看新增、删除和修改的域名清单。

+

2. 确认渲染出的 Nginx 配置与支持文件内容符合预期。

+

3. 若需要回滚,可在下方历史版本列表中重新激活旧版本。

+
+
+ )} +
+ + + {versionsQuery.isLoading ? ( + + ) : versionsQuery.isError ? ( + + ) : versions.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {versions.map((version) => ( + + + + + + + + + ))} + +
版本号状态创建人Checksum创建时间操作
{version.version} + + {version.created_by || '系统'} + {truncateChecksum(version.checksum)} + + {formatDateTime(version.created_at)} + +
+ setSelectedVersion(version)} + className='px-3 py-2 text-xs' + > + 查看快照 + + {!version.is_active ? ( + handleActivate(version)} + disabled={activateMutation.isPending} + className='px-3 py-2 text-xs' + > + 重新激活 + + ) : null} +
+
+
+ )} +
+ + {selectedVersion ? : null} +
+ ); +} diff --git a/atsf_server/web/features/config-versions/types.ts b/atsf_server/web/features/config-versions/types.ts new file mode 100644 index 00000000..bac83205 --- /dev/null +++ b/atsf_server/web/features/config-versions/types.ts @@ -0,0 +1,31 @@ +export interface ConfigVersionItem { + id: number; + version: string; + snapshot_json: string; + rendered_config: string; + support_files_json: string; + checksum: string; + is_active: boolean; + created_by: string; + created_at: string; +} + +export interface SupportFile { + path: string; + content: string; +} + +export interface ConfigPreviewResult { + snapshot_json: string; + rendered_config: string; + support_files: SupportFile[]; + checksum: string; + route_count: number; +} + +export interface ConfigDiffResult { + active_version?: string; + added_domains: string[]; + removed_domains: string[]; + modified_domains: string[]; +} diff --git a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx index 619e048e..c1c39cdd 100644 --- a/atsf_server/web/features/dashboard/components/dashboard-overview.tsx +++ b/atsf_server/web/features/dashboard/components/dashboard-overview.tsx @@ -7,15 +7,15 @@ import { dashboardNavigation } from '@/lib/constants/navigation'; const readinessItems = [ { title: '工程底座', - description: 'Next.js App Router、TypeScript strict、Tailwind CSS 与静态导出链路已建立。', + description: 'Next.js App Router、TypeScript strict、Tailwind CSS 与静态导出链路已稳定运行。', }, { - title: '质量工具', - description: 'ESLint、Prettier、Vitest、Playwright 配置已就位,可继续补充模块测试。', + title: '认证骨架', + description: '登录、注册、重置密码、鉴权守卫与后台布局已迁移到新前端。', }, { - title: '目录分层', - description: '已拆分 app、components、features、lib、store、tests 等基础结构。', + title: '核心模块', + description: '阶段 3 已接入反代规则、配置版本与节点页面,其余核心模块可继续按优先级接入。', }, ]; @@ -23,9 +23,9 @@ export function DashboardOverview() { return (
} + title='阶段 3 进行中' + description='当前已完成新版管理端基础工程与认证骨架,可继续推进核心业务模块迁移。' + action={} >
{readinessItems.map((item) => ( @@ -43,7 +43,7 @@ export function DashboardOverview() {
- +
{dashboardNavigation.slice(1).map((item) => ( - +
    -
  1. 1. 继续完善认证页细节与第三方登录体验。
  2. -
  3. 2. 在业务模块中逐步接入 Query 与 API 资源层。
  4. -
  5. 3. 为主链路页面补齐真实列表、表单与动作反馈。
  6. +
  7. 1. 继续迁移节点、域名、证书与应用记录页面。
  8. +
  9. 2. 为核心动作补齐确认、反馈与错误提示的一致体验。
  10. +
  11. 3. 补充模块测试与静态构建回归验证。
diff --git a/atsf_server/web/features/nodes/api/nodes.ts b/atsf_server/web/features/nodes/api/nodes.ts new file mode 100644 index 00000000..4fd2090d --- /dev/null +++ b/atsf_server/web/features/nodes/api/nodes.ts @@ -0,0 +1,47 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + NodeBootstrapToken, + NodeItem, + NodeMutationPayload, +} from '@/features/nodes/types'; + +export function getNodes() { + return apiRequest('/nodes/'); +} + +export function createNode(payload: NodeMutationPayload) { + return apiRequest('/nodes/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateNode(id: number, payload: NodeMutationPayload) { + return apiRequest(`/nodes/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export function deleteNode(id: number) { + return apiRequest(`/nodes/${id}`, { + method: 'DELETE', + }); +} + +export function getNodeBootstrapToken() { + return apiRequest('/nodes/bootstrap-token'); +} + +export function rotateNodeBootstrapToken() { + return apiRequest('/nodes/bootstrap-token/rotate', { + method: 'POST', + }); +} + +export function requestNodeAgentUpdate(id: number) { + return apiRequest(`/nodes/${id}/agent-update`, { + method: 'POST', + }); +} diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx new file mode 100644 index 00000000..deca4cfa --- /dev/null +++ b/atsf_server/web/features/nodes/components/nodes-page.tsx @@ -0,0 +1,633 @@ +'use client'; + +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 { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { + createNode, + deleteNode, + getNodeBootstrapToken, + getNodes, + requestNodeAgentUpdate, + rotateNodeBootstrapToken, + updateNode, +} from '@/features/nodes/api/nodes'; +import type { NodeItem, 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'; + +const nodesQueryKey = ['nodes']; +const bootstrapQueryKey = ['nodes', 'bootstrap-token']; +const installerScriptUrl = + 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh'; + +const nodeSchema = z.object({ + name: z.string().trim().min(1, '请输入节点名').max(128, '节点名不能超过 128 个字符'), + auto_update_enabled: z.boolean(), +}); + +type NodeFormValues = z.infer; + +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, + }; +} + +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 getServerUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function buildDiscoveryInstallCommand(serverUrl: string, discoveryToken: string) { + return [ + `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, + ` --server-url ${serverUrl} \\`, + ` --discovery-token ${discoveryToken}`, + ].join('\n'); +} + +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() { + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState(null); + const [editingNodeId, setEditingNodeId] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); + const [serverUrl, setServerUrl] = useState(''); + const [, setRefreshTick] = useState(0); + + const form = useForm({ + resolver: zodResolver(nodeSchema), + defaultValues, + }); + + const watchedAutoUpdate = useWatch({ + control: form.control, + name: 'auto_update_enabled', + }); + + const nodesQuery = useQuery({ + queryKey: nodesQueryKey, + queryFn: getNodes, + }); + + const bootstrapQuery = useQuery({ + queryKey: bootstrapQueryKey, + queryFn: getNodeBootstrapToken, + }); + + 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({ + mutationFn: async (values: NodeFormValues) => { + const payload = toPayload(values); + return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload); + }, + onSuccess: async (node) => { + setFeedback({ + tone: 'success', + message: editingNodeId ? '节点已更新。' : '节点已创建。', + }); + setEditingNodeId(null); + setSelectedNode(node); + form.reset(defaultValues); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: nodesQueryKey }), + queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }), + ]); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const rotateMutation = useMutation({ + mutationFn: rotateNodeBootstrapToken, + onSuccess: async () => { + setFeedback({ tone: 'success', message: '全局 Discovery Token 已重新生成。' }); + await queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + 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({ + mutationFn: deleteNode, + onSuccess: async () => { + setFeedback({ tone: 'success', message: '节点已删除。' }); + setEditingNodeId(null); + setSelectedNode(null); + form.reset(defaultValues); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); + const bootstrapToken = bootstrapQuery.data?.discovery_token ?? ''; + const normalizedServerUrl = getServerUrl(serverUrl); + + const selectedNodeFromList = useMemo(() => { + if (!selectedNode) { + return null; + } + + return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode; + }, [nodes, selectedNode]); + + const summary = useMemo(() => { + return [ + { label: '节点总数', value: nodes.length }, + { label: '在线节点', value: nodes.filter((item) => item.status === 'online').length }, + { label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length }, + { label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length }, + ]; + }, [nodes]); + + const handleReset = () => { + setFeedback(null); + setEditingNodeId(null); + form.reset(defaultValues); + }; + + const handleEdit = (node: NodeItem) => { + setFeedback(null); + setEditingNodeId(node.id); + setSelectedNode(node); + form.reset({ + name: node.name, + auto_update_enabled: node.auto_update_enabled, + }); + }; + + const handleDelete = (node: NodeItem) => { + if ( + !window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`) + ) { + return; + } + + setFeedback(null); + deleteMutation.mutate(node.id); + }; + + const handleRotateToken = () => { + if (!window.confirm('确认重新生成全局 Discovery Token 吗?现有批量部署命令需要同步更新。')) { + return; + } + + setFeedback(null); + rotateMutation.mutate(); + }; + + 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) => { + setFeedback(null); + saveMutation.mutate(values); + }); + + const discoveryCommand = + normalizedServerUrl && bootstrapToken + ? buildDiscoveryInstallCommand(normalizedServerUrl, bootstrapToken) + : ''; + + const nodeInstallCommand = + normalizedServerUrl && selectedNodeFromList?.agent_token + ? buildNodeInstallCommand(normalizedServerUrl, selectedNodeFromList.agent_token) + : ''; + + return ( +
+ + + {feedback ? : null} + +
+ void queryClient.invalidateQueries({ queryKey: nodesQueryKey })} + > + 刷新列表 + + } + > +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ + +
+ + + + + + form.setValue('auto_update_enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + +
+ + {saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'} + + {editingNodeId ? ( + + 取消编辑 + + ) : null} +
+ +
+
+ +
+ + + {rotateMutation.isPending ? '生成中...' : '重新生成 Token'} + + {discoveryCommand ? ( + void handleCopy(discoveryCommand, 'Discovery 部署命令已复制。')} + > + 复制命令 + + ) : null} +
+ } + > + {bootstrapQuery.isLoading ? ( + + ) : bootstrapQuery.isError ? ( + + ) : ( +
+ + setServerUrl(event.target.value)} /> + + +
+
+

Discovery Token

+

+ {bootstrapToken || '暂无'} +

+
+
+ 创建节点后也可以使用节点专属 Token 安装,这种方式适合预分配节点名与更新策略。 +
+
+ + {discoveryCommand ? {discoveryCommand} : null} +
+ )} + + + void handleCopy(nodeInstallCommand, '节点专属部署命令已复制。')} + > + 复制命令 + + ) : null + } + > + {selectedNodeFromList ? ( +
+
+
+
+

{selectedNodeFromList.name}

+ +
+

Node ID:{selectedNodeFromList.node_id}

+
+
+

Agent Token

+

+ {selectedNodeFromList.agent_token || '暂无'} +

+
+
+ + {nodeInstallCommand ? {nodeInstallCommand} : null} +
+ ) : ( + + )} +
+
+ + + {nodesQuery.isLoading ? ( + + ) : nodesQuery.isError ? ( + + ) : nodes.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + + {nodes.map((node) => { + const updateMode = getUpdateMode(node); + + return ( + + + + + + + + + + + + ); + })} + +
节点状态更新模式Agent / Nginx当前版本最近应用最近心跳错误操作
+
+

{node.name}

+

Node ID:{node.node_id}

+

+ Token:{node.agent_token || '暂无'} +

+

IP:{node.ip || '暂无'}

+
+
+
+ + +
+
+ + + {node.agent_version || 'unknown'} / {node.nginx_version || 'unknown'} + + {node.current_version || '未应用'} + +
+ +

+ {node.latest_apply_message || '暂无记录'} +

+

+ {isMeaningfulTime(node.latest_apply_at) + ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}` + : '暂无'} +

+
+
+ {isMeaningfulTime(node.last_seen_at) + ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` + : '暂无'} + +

+ {node.last_error || '无'} +

+
+
+ updateAgentMutation.mutate(node.id)} + disabled={updateAgentMutation.isPending || node.update_requested} + className='px-3 py-2 text-xs' + > + 立即更新 + + setSelectedNode(node)} + className='px-3 py-2 text-xs' + > + 部署命令 + + handleEdit(node)} + className='px-3 py-2 text-xs' + > + 编辑 + + handleDelete(node)} + disabled={deleteMutation.isPending} + className='px-3 py-2 text-xs' + > + 删除 + +
+
+
+ )} +
+
+ ); +} diff --git a/atsf_server/web/features/nodes/types.ts b/atsf_server/web/features/nodes/types.ts new file mode 100644 index 00000000..b6acc69e --- /dev/null +++ b/atsf_server/web/features/nodes/types.ts @@ -0,0 +1,30 @@ +export interface NodeItem { + id: number; + node_id: string; + name: string; + ip: string; + agent_token: string; + pending: boolean; + auto_update_enabled: boolean; + update_requested: boolean; + agent_version: string; + nginx_version: string; + status: 'online' | 'offline' | 'pending'; + current_version: string; + last_seen_at: string; + last_error: string; + latest_apply_result: 'success' | 'failed' | ''; + latest_apply_message: string; + latest_apply_at?: string | null; + created_at: string; + updated_at: string; +} + +export interface NodeBootstrapToken { + discovery_token: string; +} + +export interface NodeMutationPayload { + name: string; + auto_update_enabled: boolean; +} diff --git a/atsf_server/web/features/proxy-routes/api/proxy-routes.ts b/atsf_server/web/features/proxy-routes/api/proxy-routes.ts new file mode 100644 index 00000000..99f9413c --- /dev/null +++ b/atsf_server/web/features/proxy-routes/api/proxy-routes.ts @@ -0,0 +1,41 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + ManagedDomainMatchResult, + ProxyRouteItem, + ProxyRouteMutationPayload, + TlsCertificateItem, +} from '@/features/proxy-routes/types'; + +export function getProxyRoutes() { + return apiRequest('/proxy-routes/'); +} + +export function createProxyRoute(payload: ProxyRouteMutationPayload) { + return apiRequest('/proxy-routes/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateProxyRoute(id: number, payload: ProxyRouteMutationPayload) { + return apiRequest(`/proxy-routes/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export function deleteProxyRoute(id: number) { + return apiRequest(`/proxy-routes/${id}`, { + method: 'DELETE', + }); +} + +export function getTlsCertificates() { + return apiRequest('/tls-certificates/'); +} + +export function matchManagedDomainCertificate(domain: string) { + const searchParams = new URLSearchParams({ domain }); + return apiRequest(`/managed-domains/match?${searchParams.toString()}`); +} diff --git a/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx new file mode 100644 index 00000000..e7fea2d0 --- /dev/null +++ b/atsf_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -0,0 +1,693 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { useFieldArray, 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 { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { publishConfigVersion } from '@/features/config-versions/api/config-versions'; +import { + createProxyRoute, + deleteProxyRoute, + getProxyRoutes, + getTlsCertificates, + matchManagedDomainCertificate, + updateProxyRoute, +} from '@/features/proxy-routes/api/proxy-routes'; +import type { + ManagedDomainMatchResult, + ProxyRouteCustomHeader, + ProxyRouteItem, + ProxyRouteMutationPayload, + TlsCertificateItem, +} from '@/features/proxy-routes/types'; +import { + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + ResourceSelect, + ResourceTextarea, + SecondaryButton, + ToggleField, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +const customHeaderSchema = z.object({ + key: z.string(), + value: z.string(), +}); + +const proxyRouteSchema = z + .object({ + domain: z.string().trim().min(1, '请输入域名'), + origin_url: z + .string() + .trim() + .min(1, '请输入源站地址') + .refine((value) => /^https?:\/\//.test(value), '源站地址必须以 http:// 或 https:// 开头') + .refine((value) => { + try { + new URL(value); + return true; + } catch { + return false; + } + }, '请输入合法的源站地址'), + enabled: z.boolean(), + enable_https: z.boolean(), + cert_id: z.string(), + redirect_http: z.boolean(), + custom_headers: z.array(customHeaderSchema).min(1), + remark: z.string().max(255, '备注不能超过 255 个字符'), + }) + .superRefine((value, context) => { + if (value.enable_https && !value.cert_id) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['cert_id'], + message: '启用 HTTPS 时必须选择证书', + }); + } + + value.custom_headers.forEach((header, index) => { + const key = header.key.trim(); + const headerValue = header.value.trim(); + + if (!key && !headerValue) { + return; + } + + if (!key) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['custom_headers', index, 'key'], + message: '请求头名称不能为空', + }); + } + + if (key && !/^[A-Za-z0-9_-]+$/.test(key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['custom_headers', index, 'key'], + message: '请求头名称仅支持字母、数字、下划线和中划线', + }); + } + + if (/\r|\n/.test(key) || /\r|\n/.test(headerValue)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['custom_headers', index, 'value'], + message: '请求头不能包含换行', + }); + } + }); + }); + +type ProxyRouteFormValues = z.infer; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +const defaultValues: ProxyRouteFormValues = { + domain: '', + origin_url: '', + enabled: true, + enable_https: false, + cert_id: '', + redirect_http: false, + custom_headers: [{ key: '', value: '' }], + remark: '', +}; + +const routesQueryKey = ['proxy-routes']; +const certificatesQueryKey = ['tls-certificates']; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function parseCustomHeaders(rawValue: string) { + if (!rawValue) { + return [] as ProxyRouteCustomHeader[]; + } + + try { + const parsed = JSON.parse(rawValue) as ProxyRouteCustomHeader[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function buildCertificateLabel(certificate: TlsCertificateItem) { + return certificate.not_after + ? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})` + : certificate.name; +} + +function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload { + return { + domain: values.domain.trim(), + origin_url: values.origin_url.trim(), + enabled: values.enabled, + enable_https: values.enable_https, + cert_id: values.enable_https && values.cert_id ? Number(values.cert_id) : null, + redirect_http: values.enable_https ? values.redirect_http : false, + custom_headers: values.custom_headers + .map((item) => ({ key: item.key.trim(), value: item.value.trim() })) + .filter((item) => item.key || item.value), + remark: values.remark.trim(), + }; +} + +function toFormValues(route: ProxyRouteItem): ProxyRouteFormValues { + const headers = parseCustomHeaders(route.custom_headers); + + return { + domain: route.domain, + origin_url: route.origin_url, + enabled: route.enabled, + enable_https: route.enable_https, + cert_id: route.cert_id ? String(route.cert_id) : '', + redirect_http: route.redirect_http, + custom_headers: headers.length > 0 ? headers : [{ key: '', value: '' }], + remark: route.remark || '', + }; +} + +function getMatchMessage( + matchResult: ManagedDomainMatchResult | null, + isMatching: boolean, + domain: string, + enabled: boolean, +) { + if (!enabled) { + return '启用 HTTPS 后,系统会根据输入域名尝试自动匹配托管证书。'; + } + + if (isMatching) { + return '正在按域名自动匹配托管证书...'; + } + + if (!domain.trim()) { + return '输入域名后会自动匹配证书,并优先推荐精确匹配规则。'; + } + + if (matchResult?.matched && matchResult.candidate) { + return `已匹配${matchResult.candidate.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate.domain},推荐证书:${matchResult.candidate.certificate_name}`; + } + + return '未找到匹配证书,可继续手动选择。'; +} + +export function ProxyRoutesPage() { + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState(null); + const [editingRouteId, setEditingRouteId] = useState(null); + const [matchResult, setMatchResult] = useState(null); + const [isMatching, setIsMatching] = useState(false); + + const form = useForm({ + resolver: zodResolver(proxyRouteSchema), + defaultValues, + }); + + const { fields, append, remove, replace } = useFieldArray({ + control: form.control, + name: 'custom_headers', + }); + + const watchedDomain = useWatch({ control: form.control, name: 'domain' }); + const watchedEnabled = useWatch({ control: form.control, name: 'enabled' }); + const watchedEnableHttps = useWatch({ 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 routesQuery = useQuery({ + queryKey: routesQueryKey, + queryFn: getProxyRoutes, + }); + + const certificatesQuery = useQuery({ + queryKey: certificatesQueryKey, + queryFn: getTlsCertificates, + }); + + const saveMutation = useMutation({ + mutationFn: async (values: ProxyRouteFormValues) => { + const payload = toPayload(values); + return editingRouteId + ? updateProxyRoute(editingRouteId, payload) + : createProxyRoute(payload); + }, + onSuccess: async () => { + setFeedback({ tone: 'success', message: editingRouteId ? '规则已更新。' : '规则已创建。' }); + setEditingRouteId(null); + setMatchResult(null); + form.reset(defaultValues); + await queryClient.invalidateQueries({ queryKey: routesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteProxyRoute, + onSuccess: async () => { + setFeedback({ tone: 'success', message: '规则已删除。' }); + await queryClient.invalidateQueries({ queryKey: routesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const publishMutation = useMutation({ + mutationFn: publishConfigVersion, + onSuccess: (version) => { + setFeedback({ tone: 'success', message: `发布成功,版本 ${version.version}` }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + useEffect(() => { + if (!watchedEnableHttps) { + setMatchResult(null); + setIsMatching(false); + return; + } + + const normalizedDomain = watchedDomain.trim().toLowerCase(); + if (!normalizedDomain) { + setMatchResult(null); + return; + } + + let cancelled = false; + const timer = window.setTimeout(async () => { + try { + setIsMatching(true); + const result = await matchManagedDomainCertificate(normalizedDomain); + if (cancelled) { + return; + } + setMatchResult(result); + if (result.candidate?.certificate_id && !form.getValues('cert_id')) { + form.setValue('cert_id', String(result.candidate.certificate_id), { + shouldDirty: true, + shouldValidate: true, + }); + } + } catch (error) { + if (!cancelled) { + setMatchResult(null); + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + } finally { + if (!cancelled) { + setIsMatching(false); + } + } + }, 400); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [watchedDomain, watchedEnableHttps, form]); + + const certificates = useMemo(() => certificatesQuery.data ?? [], [certificatesQuery.data]); + const certificateMap = useMemo( + () => new Map(certificates.map((item) => [item.id, item])), + [certificates], + ); + + const summary = useMemo(() => { + const routes = routesQuery.data || []; + + return [ + { label: '规则总数', value: routes.length }, + { label: '已启用', value: routes.filter((item) => item.enabled).length }, + { label: 'HTTPS 规则', value: routes.filter((item) => item.enable_https).length }, + { + label: '自定义请求头', + value: routes.reduce((count, route) => count + parseCustomHeaders(route.custom_headers).length, 0), + }, + ]; + }, [routesQuery.data]); + + const handleReset = () => { + setFeedback(null); + setEditingRouteId(null); + setMatchResult(null); + form.reset(defaultValues); + }; + + const handleSubmit = form.handleSubmit((values) => { + setFeedback(null); + saveMutation.mutate(values); + }); + + const handleEdit = (route: ProxyRouteItem) => { + setFeedback(null); + setEditingRouteId(route.id); + setMatchResult(null); + form.reset(toFormValues(route)); + }; + + const handleDelete = (route: ProxyRouteItem) => { + if (!window.confirm(`确认删除规则 ${route.domain} 吗?`)) { + return; + } + + setFeedback(null); + deleteMutation.mutate(route.id); + }; + + const handleRemoveHeader = (index: number) => { + if (fields.length === 1) { + replace([{ key: '', value: '' }]); + return; + } + + remove(index); + }; + + const routes = routesQuery.data || []; + + return ( +
+ + + {feedback ? : null} + +
+ +
+
+ + + + + + +
+ +
+ form.setValue('enabled', checked, { shouldDirty: true })} + /> + { + 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 }); + } + }} + /> +
+ +
+ + + form.setValue('cert_id', event.target.value, { + shouldDirty: true, + shouldValidate: true, + }) + } + > + + {certificates.map((certificate) => ( + + ))} + + + + form.setValue('redirect_http', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> +
+ +
+
+
+

自定义请求头

+

+ 可为空。若填写,请保证 Header 名称合法且不包含换行。 +

+
+ append({ key: '', value: '' })} + className='px-3 py-2 text-xs' + > + 添加请求头 + +
+ +
+ {fields.map((field, index) => ( +
+ + + + + + +
+ handleRemoveHeader(index)} + className='w-full px-3 py-3 text-xs md:w-auto' + > + 删除 + +
+
+ ))} +
+
+ + + + + +
+ + {saveMutation.isPending ? '保存中...' : editingRouteId ? '保存修改' : '新增规则'} + + {editingRouteId ? ( + + 取消编辑 + + ) : null} +
+
+
+ +
+ publishMutation.mutate()} disabled={publishMutation.isPending}> + {publishMutation.isPending ? '发布中...' : '发布当前规则'} + + } + > +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

{item.value}

+
+ ))} +
+
+ + +
+

+ {getMatchMessage(matchResult, isMatching, watchedDomain, watchedEnableHttps)} +

+ {matchResult?.matched && matchResult.candidates.length > 1 ? ( +
+ {matchResult.candidates.map((candidate) => ( + + ))} +
+ ) : null} +
+ 当前可选证书:{certificatesQuery.isLoading ? '加载中...' : `${certificates.length} 张`} +
+
+
+
+
+ + + {routesQuery.isLoading ? ( + + ) : routesQuery.isError ? ( + + ) : routes.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + {routes.map((route) => { + const headers = parseCustomHeaders(route.custom_headers); + const certificate = route.cert_id ? certificateMap.get(route.cert_id) : null; + + return ( + + + + + + + + + + + ); + })} + +
域名源站地址HTTPS请求头状态备注更新时间操作
{route.domain}{route.origin_url} + {route.enable_https ? ( +
+ +

+ {certificate ? certificate.name : '证书未加载'} +

+
+ ) : ( + + )} +
+ 0 ? `${headers.length} 条` : '无'} + variant={headers.length > 0 ? 'success' : 'warning'} + /> + + + {route.remark || '—'} + {formatDateTime(route.updated_at)} + +
+ handleEdit(route)} + className='px-3 py-2 text-xs' + > + 编辑 + + handleDelete(route)} + disabled={deleteMutation.isPending} + className='px-3 py-2 text-xs' + > + 删除 + +
+
+
+ )} +
+
+ ); +} diff --git a/atsf_server/web/features/proxy-routes/types.ts b/atsf_server/web/features/proxy-routes/types.ts new file mode 100644 index 00000000..c08d4a14 --- /dev/null +++ b/atsf_server/web/features/proxy-routes/types.ts @@ -0,0 +1,50 @@ +export interface ProxyRouteCustomHeader { + key: string; + value: string; +} + +export interface ProxyRouteItem { + id: number; + domain: string; + origin_url: string; + enabled: boolean; + enable_https: boolean; + cert_id: number | null; + redirect_http: boolean; + custom_headers: string; + remark: string; + created_at: string; + updated_at: string; +} + +export interface ProxyRouteMutationPayload { + domain: string; + origin_url: string; + enabled: boolean; + enable_https: boolean; + cert_id: number | null; + redirect_http: boolean; + custom_headers: ProxyRouteCustomHeader[]; + remark: string; +} + +export interface TlsCertificateItem { + id: number; + name: string; + not_after?: string | null; +} + +export interface ManagedDomainMatchCandidate { + managed_domain_id: number; + domain: string; + match_type: 'exact' | 'wildcard'; + certificate_id: number; + certificate_name: string; +} + +export interface ManagedDomainMatchResult { + domain: string; + matched: boolean; + candidate?: ManagedDomainMatchCandidate; + candidates: ManagedDomainMatchCandidate[]; +} diff --git a/atsf_server/web/features/shared/components/resource-primitives.tsx b/atsf_server/web/features/shared/components/resource-primitives.tsx new file mode 100644 index 00000000..777f766c --- /dev/null +++ b/atsf_server/web/features/shared/components/resource-primitives.tsx @@ -0,0 +1,179 @@ +import type { + ButtonHTMLAttributes, + InputHTMLAttributes, + ReactNode, + SelectHTMLAttributes, + TextareaHTMLAttributes, +} from 'react'; + +import { cn } from '@/lib/utils/cn'; + +interface ResourceFieldProps { + label: string; + hint?: string; + error?: string; + className?: string; + children: ReactNode; +} + +export function ResourceField({ + label, + hint, + error, + className, + children, +}: ResourceFieldProps) { + return ( + + ); +} + +export function ResourceInput(props: InputHTMLAttributes) { + return ( + + ); +} + +export function ResourceTextarea(props: TextareaHTMLAttributes) { + return ( +