[优化] 重构节点详情页面

This commit is contained in:
ryan
2026-06-01 14:39:33 +08:00
parent 054dc1a8a8
commit 6ddd2da2e8
6 changed files with 3019 additions and 407 deletions
+1 -1
View File
@@ -48,5 +48,5 @@ services:
- ./openflared/data/:/app/data - ./openflared/data/:/app/data
environment: environment:
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000" OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
OPENFLARE_TUNNEL_TOKEN: 4888482fd0e1e3331111e51a8db1333b OPENFLARE_TUNNEL_TOKEN: deb0783ac1e264a9d86440169aca0f09
@@ -32,8 +32,8 @@ import {
} from '@/features/nodes/api/nodes'; } from '@/features/nodes/api/nodes';
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
import type { import type {
NodeItem,
NodeAgentReleaseInfo, NodeAgentReleaseInfo,
NodeObservability,
} from '@/features/nodes/types'; } from '@/features/nodes/types';
import { import {
CodeBlock, CodeBlock,
@@ -53,8 +53,6 @@ import {
import { import {
buildNodeInstallCommand, buildNodeInstallCommand,
buildNodeDockerInstallCommand, buildNodeDockerInstallCommand,
buildRelayInstallCommand,
buildRelayDockerInstallCommand,
getApplyLabel, getApplyLabel,
getApplyVariant, getApplyVariant,
getNodeStatusLabel, getNodeStatusLabel,
@@ -66,183 +64,76 @@ import {
isMeaningfulTime, isMeaningfulTime,
} from '@/features/nodes/utils'; } from '@/features/nodes/utils';
import {
copyToClipboard,
formatUsageRatio,
formatUptime,
formatTrendHour,
aggregateTrafficBreakdown,
getErrorMessage,
getHealthEventLabel,
getHealthEventVariant,
FeedbackState,
HealthEventFilter,
NodeDetailTab,
MetricBar,
SummaryStat,
} from './node-shared';
import { RelayDetailPage } from './relay-detail-page';
import { TunnelDetailPage } from './tunnel-detail-page';
const nodesQueryKey = ['nodes']; const nodesQueryKey = ['nodes'];
type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
type HealthEventFilter = 'all' | 'active' | 'resolved';
type NodeDetailTab = 'dashboard' | 'info';
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
function formatUsageRatio(used?: number | null, total?: number | null) {
if (!used || !total || total <= 0) {
return null;
}
return Math.max(0, Math.min(100, (used / total) * 100));
}
function formatUptime(seconds?: number | null) {
if (!seconds || seconds <= 0) {
return '—';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} 天 ${hours} 小时`;
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`;
}
return `${minutes} 分钟`;
}
function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
function parseTrafficMap(value?: string | null) {
if (!value) {
return {} as Record<string, number>;
}
try {
const parsed = JSON.parse(value) as Record<string, number>;
return Object.entries(parsed).reduce<Record<string, number>>(
(result, [key, count]) => {
if (typeof count === 'number' && Number.isFinite(count)) {
result[key] = count;
}
return result;
},
{},
);
} catch {
return {} as Record<string, number>;
}
}
function aggregateTrafficBreakdown(
reports: NodeObservability['traffic_reports'],
field: 'status_codes_json' | 'top_domains_json',
) {
const summary = new Map<string, number>();
for (const report of reports) {
const parsed = parseTrafficMap(report[field]);
for (const [key, value] of Object.entries(parsed)) {
summary.set(key, (summary.get(key) ?? 0) + value);
}
}
return Array.from(summary.entries())
.sort((left, right) => {
if (right[1] === left[1]) {
return left[0].localeCompare(right[0]);
}
return right[1] - left[1];
})
.slice(0, 6)
.map(([label, value]) => ({ label, value }));
}
function getHealthEventVariant(
event: NodeObservability['health_events'][number],
): 'success' | 'warning' | 'danger' | 'info' {
if (event.status === 'resolved') {
return 'success';
}
if (event.severity === 'critical') {
return 'danger';
}
if (event.severity === 'warning') {
return 'warning';
}
return 'info';
}
function getHealthEventLabel(
event: NodeObservability['health_events'][number],
) {
return event.event_type.replaceAll('_', ' ');
}
function MetricBar({
label,
value,
progress,
hint,
}: {
label: string;
value: string;
progress?: number | null;
hint?: string;
}) {
return (
<div className="space-y-2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
{hint ? (
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
{hint}
</p>
) : null}
</div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{value}
</p>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--status-info-foreground)] transition-[width]"
style={{ width: `${progress ?? 0}%` }}
/>
</div>
</div>
);
}
function SummaryStat({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">{hint}</p>
</div>
);
}
export function NodeDetailPage({ nodeId }: { nodeId: string }) { export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
refetchInterval: 5000,
});
const node = useMemo(() => {
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
if (nodesQuery.isLoading) {
return <LoadingState />;
}
if (nodesQuery.isError) {
return (
<ErrorState
title="节点详情加载失败"
description={getErrorMessage(nodesQuery.error)}
/>
);
}
if (!node) {
return (
<EmptyState
title="节点不存在"
description="该节点可能已被删除,或当前 ID 无法匹配到节点记录。"
/>
);
}
if (node.node_type === 'tunnel_relay') {
return <RelayDetailPage node={node} />;
}
if (node.node_type === 'tunnel_client') {
return <TunnelDetailPage node={node} />;
}
return <EdgeNodeDetailPage node={node} />;
}
function EdgeNodeDetailPage({ node }: { node: NodeItem }) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const nodeId = String(node.id);
const [feedback, setFeedback] = useState<FeedbackState | null>(null); const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false);
@@ -258,12 +149,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] = const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] =
useState(false); useState(false);
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: getNodes,
refetchInterval: 5000,
});
const stableAgentReleaseQuery = useQuery({ const stableAgentReleaseQuery = useQuery({
queryKey: ['node-agent-release', nodeId, 'stable'], queryKey: ['node-agent-release', nodeId, 'stable'],
queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'), queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'),
@@ -276,21 +161,14 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
enabled: false, enabled: false,
}); });
const node = useMemo(() => {
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
const applyLogsQuery = useQuery({ const applyLogsQuery = useQuery({
queryKey: ['apply-logs', node?.node_id ?? '', 1, 10], queryKey: ['apply-logs', node.node_id, 1, 10],
queryFn: () => queryFn: () =>
getApplyLogs({ getApplyLogs({
node_id: node?.node_id, node_id: node.node_id,
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 10,
}), }),
enabled: Boolean(node?.node_id),
refetchInterval: 5000, refetchInterval: 5000,
}); });
@@ -304,7 +182,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
queryKey: ['node-observability', nodeId], queryKey: ['node-observability', nodeId],
queryFn: () => queryFn: () =>
getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }), getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }),
enabled: Boolean(nodeId),
refetchInterval: 10000, refetchInterval: 10000,
}); });
@@ -418,10 +295,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}); });
const handleDelete = () => { const handleDelete = () => {
if (!node) {
return;
}
if ( if (
!window.confirm( !window.confirm(
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
@@ -435,10 +308,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}; };
const handleRestartOpenresty = () => { const handleRestartOpenresty = () => {
if (!node) {
return;
}
if ( if (
!window.confirm( !window.confirm(
`确认向节点“${node.name}”下发 OpenResty 重启指令吗?该指令会在下一次心跳后执行。`, `确认向节点“${node.name}”下发 OpenResty 重启指令吗?该指令会在下一次心跳后执行。`,
@@ -534,40 +403,14 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
[], [],
); );
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 normalizedServerUrl = getServerUrl(serverUrl);
const nodeInstallCommand = const nodeInstallCommand =
normalizedServerUrl && node.agent_token normalizedServerUrl && node.agent_token
? node.node_type === 'tunnel_relay' ? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
? buildRelayInstallCommand(normalizedServerUrl, node.agent_token)
: buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
: ''; : '';
const nodeDockerInstallCommand = const nodeDockerInstallCommand =
normalizedServerUrl && node.agent_token normalizedServerUrl && node.agent_token
? node.node_type === 'tunnel_relay' ? buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token)
? buildRelayDockerInstallCommand(normalizedServerUrl, node.agent_token)
: buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token)
: ''; : '';
const updateMode = getUpdateMode(node); const updateMode = getUpdateMode(node);
const selectedAgentRelease = const selectedAgentRelease =
@@ -626,9 +469,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}; };
const isRefreshing = const isRefreshing =
nodesQuery.isFetching ||
applyLogsQuery.isFetching || applyLogsQuery.isFetching ||
observabilityQuery.isFetching; observabilityQuery.isFetching ||
configVersionsQuery.isFetching;
const handleRefresh = async () => { const handleRefresh = async () => {
setFeedback(null); setFeedback(null);
@@ -651,7 +494,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title={node.name} title={node.name}
description="节点详情" description="边缘代理节点详情 (edge_node)"
action={ action={
<> <>
<Link <Link
@@ -746,77 +589,39 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
: '当前没有活动中的健康事件' : '当前没有活动中的健康事件'
} }
/> />
{node.node_type === 'tunnel_relay' ? ( <SummaryStat
<> label="当前窗口请求"
<SummaryStat value={
label="隧道总数" trafficSummary
value={ ? trafficSummary.request_count.toLocaleString('zh-CN')
observability?.relay_dashboard : '—'
? `${observability.relay_dashboard.total_proxies}` }
: '—' hint={
} trafficSummary
hint={ ? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
observability?.relay_dashboard : '当前没有可展示的请求窗口摘要'
? `${observability.relay_dashboard.online_proxies} 个在线,${observability.relay_dashboard.offline_proxies} 个离线` }
: '暂无隧道统计' />
} <SummaryStat
/> label="容量压力"
<SummaryStat value={
label="当前连接数" healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'
value={ }
observability?.relay_dashboard hint={
? `${observability.relay_dashboard.total_connections}` latestMetricSnapshot
: '—' ? `CPU ${formatPercent(latestMetricSnapshot.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
} : '当前没有资源快照'
hint="隧道网络承载的活动连接总数" }
/> />
<SummaryStat <SummaryStat
label="活动客户端" label="来源信号"
value={ value={topSourceCountry?.key ?? '—'}
observability?.relay_dashboard hint={
? `${observability.relay_dashboard.client_counts}` topSourceCountry
: '—' ? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
} : '当前没有来源分布数据'
hint="已连接并活跃的客户端数量" }
/> />
</>
) : (
<>
<SummaryStat
label="当前窗口请求"
value={
trafficSummary
? trafficSummary.request_count.toLocaleString('zh-CN')
: '—'
}
hint={
trafficSummary
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
}
/>
<SummaryStat
label="容量压力"
value={
healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'
}
hint={
latestMetricSnapshot
? `CPU ${formatPercent(latestMetricSnapshot.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
: '当前没有资源快照'
}
/>
<SummaryStat
label="来源信号"
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
: '当前没有来源分布数据'
}
/>
</>
)}
</div> </div>
<div className="grid gap-6 xl:grid-cols-3"> <div className="grid gap-6 xl:grid-cols-3">
@@ -886,9 +691,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
总内存 总内存
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]"> <p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatBytes( {formatBytes(observability.profile.total_memory_bytes)}
observability.profile.total_memory_bytes,
)}
</p> </p>
</div> </div>
<div> <div>
@@ -932,9 +735,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<MetricBar <MetricBar
label="CPU" label="CPU"
value={formatPercent( value={formatPercent(latestMetricSnapshot.cpu_usage_percent)}
latestMetricSnapshot.cpu_usage_percent,
)}
progress={latestMetricSnapshot.cpu_usage_percent} progress={latestMetricSnapshot.cpu_usage_percent}
hint={ hint={
isMeaningfulTime(latestMetricSnapshot.captured_at) isMeaningfulTime(latestMetricSnapshot.captured_at)
@@ -953,9 +754,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
label="存储" label="存储"
value={`${formatBytes( value={`${formatBytes(
latestMetricSnapshot.storage_used_bytes, latestMetricSnapshot.storage_used_bytes,
)} / ${formatBytes( )} / ${formatBytes(latestMetricSnapshot.storage_total_bytes)}`}
latestMetricSnapshot.storage_total_bytes,
)}`}
progress={storageUsageRatio} progress={storageUsageRatio}
/> />
<MetricBar <MetricBar
@@ -978,8 +777,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
)} )}
</AppCard> </AppCard>
{node.node_type === 'edge_node' ? ( <AppCard title="网络流量">
<AppCard title="网络流量">
{observabilityQuery.isLoading ? ( {observabilityQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : observabilityQuery.isError ? ( ) : observabilityQuery.isError ? (
@@ -996,9 +794,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
/> />
<StatusBadge <StatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)} label={getOpenrestyStatusLabel(node.openresty_status)}
variant={getOpenrestyStatusVariant( variant={getOpenrestyStatusVariant(node.openresty_status)}
node.openresty_status,
)}
/> />
<StatusBadge <StatusBadge
label={ label={
@@ -1006,9 +802,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
? `${activeHealthEvents.length} 个活动异常` ? `${activeHealthEvents.length} 个活动异常`
: '无活动异常' : '无活动异常'
} }
variant={ variant={activeHealthEvents.length ? 'warning' : 'success'}
activeHealthEvents.length ? 'warning' : 'success'
}
/> />
</div> </div>
@@ -1058,9 +852,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</p> </p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]"> <p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{trafficSummary {trafficSummary
? trafficSummary.request_count.toLocaleString( ? trafficSummary.request_count.toLocaleString('zh-CN')
'zh-CN',
)
: '—'} : '—'}
</p> </p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]"> <p className="mt-2 text-sm text-[var(--foreground-secondary)]">
@@ -1093,12 +885,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
/> />
)} )}
</AppCard> </AppCard>
) : null}
</div> </div>
{node.node_type === 'edge_node' ? ( <div className="grid gap-6 xl:grid-cols-2">
<>
<div className="grid gap-6 xl:grid-cols-2">
<AppCard <AppCard
title="24 小时请求趋势" title="24 小时请求趋势"
description="按小时聚合该节点的请求量和错误量。" description="按小时聚合该节点的请求量和错误量。"
@@ -1244,64 +1033,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}, },
]} ]}
/> />
</AppCard> </AppCard>
</div> </div>
</>
) : null}
{node.node_type === 'tunnel_relay' ? (
<div className="grid gap-6 xl:grid-cols-1">
<AppCard title="隧道运行状态" description="当前中继节点上活跃的代理通道。">
{observability?.relay_dashboard?.proxies?.length ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-[var(--foreground-secondary)]">
<thead className="border-b border-[var(--border-default)]">
<tr>
<th className="px-4 py-3 font-medium">代理名称</th>
<th className="px-4 py-3 font-medium">类型</th>
<th className="px-4 py-3 font-medium">状态</th>
<th className="px-4 py-3 font-medium">客户端地址</th>
<th className="px-4 py-3 font-medium">最近连接</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{observability.relay_dashboard.proxies.map((proxy) => (
<tr key={proxy.name}>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{proxy.name}
</td>
<td className="px-4 py-3">{proxy.type}</td>
<td className="px-4 py-3">
<StatusBadge
label={proxy.status === 'online' ? '在线' : '离线'}
variant={proxy.status === 'online' ? 'success' : 'info'}
/>
</td>
<td className="px-4 py-3">{proxy.client_addr}</td>
<td className="px-4 py-3">
{isMeaningfulTime(proxy.last_start_time)
? formatRelativeTime(proxy.last_start_time)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState
title="暂无通道信息"
description="当前节点未上报映射通道状态,或尚未有客户端连接。"
/>
)}
</AppCard>
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]"> <div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
{node.node_type === 'edge_node' ? ( <AppCard
<AppCard title="请求结构分布"
title="请求结构分布"
description="聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。" description="聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。"
> >
<div className="mb-6 grid gap-4 md:grid-cols-3"> <div className="mb-6 grid gap-4 md:grid-cols-3">
@@ -1315,7 +1052,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<p className="mt-2 text-sm text-[var(--foreground-secondary)]"> <p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{dominantStatusCode {dominantStatusCode
? `${dominantStatusCode.value} 次` ? `${dominantStatusCode.value} 次`
: '暂无状态码聚合'} : '暂无状态码配置'}
</p> </p>
</div> </div>
<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">
@@ -1328,7 +1065,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<p className="mt-2 text-sm text-[var(--foreground-secondary)]"> <p className="mt-2 text-sm text-[var(--foreground-secondary)]">
{dominantDomain {dominantDomain
? `${dominantDomain.value} 次` ? `${dominantDomain.value} 次`
: '暂无域名聚合'} : '暂无域名配置'}
</p> </p>
</div> </div>
<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">
@@ -1367,11 +1104,10 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</div> </div>
</div> </div>
</AppCard> </AppCard>
) : null}
<AppCard <AppCard
title="健康事件时间线" title="健康事件时间线"
description="保留活动与已恢复事件,帮助判断问题是持续中、间歇性还是已经恢复。" description="保留活动与已恢复事件,帮助判断运行状态。"
action={ action={
<DangerButton <DangerButton
type="button" type="button"
@@ -1436,12 +1172,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
variant={getHealthEventVariant(event)} variant={getHealthEventVariant(event)}
/> />
<StatusBadge <StatusBadge
label={ label={event.status === 'active' ? '活动中' : '已恢复'}
event.status === 'active' ? '活动中' : '已恢复' variant={event.status === 'active' ? 'warning' : 'success'}
}
variant={
event.status === 'active' ? 'warning' : 'success'
}
/> />
</div> </div>
<p className="mt-3 text-sm text-[var(--foreground-secondary)]"> <p className="mt-3 text-sm text-[var(--foreground-secondary)]">
@@ -1507,17 +1239,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<AppCard title="版本信息"> <AppCard title="版本信息">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]"> <div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
{node.node_type === 'tunnel_relay' ? ( <p>Agent:{node.agent_version || 'unknown'}</p>
<> <p>Nginx:{node.nginx_version || 'unknown'}</p>
<p>Relay:{node.relay_version || 'unknown'}</p>
<p>frps:{node.relay_frp_version || 'unknown'}</p>
</>
) : (
<>
<p>Agent:{node.agent_version || 'unknown'}</p>
<p>Nginx:{node.nginx_version || 'unknown'}</p>
</>
)}
<p>当前配置:{node.current_version || '未应用'}</p> <p>当前配置:{node.current_version || '未应用'}</p>
</div> </div>
</AppCard> </AppCard>
@@ -1623,9 +1346,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
)} )}
</AppCard> </AppCard>
{node.node_type === 'edge_node' ? ( <AppCard
<AppCard title="OpenResty 健康与控制"
title="OpenResty 健康与控制"
description="OpenResty 当前健康状态。" description="OpenResty 当前健康状态。"
action={ action={
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -1674,7 +1396,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</div> </div>
</div> </div>
</AppCard> </AppCard>
) : null}
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard <AppCard
@@ -0,0 +1,177 @@
'use client';
import React from 'react';
import type { NodeObservability } from '@/features/nodes/types';
export type FeedbackState = {
tone: 'info' | 'success' | 'danger';
message: string;
};
export type HealthEventFilter = 'all' | 'active' | 'resolved';
export type NodeDetailTab = 'dashboard' | 'info';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export async function copyToClipboard(value: string) {
await navigator.clipboard.writeText(value);
}
export function formatUsageRatio(used?: number | null, total?: number | null) {
if (!used || !total || total <= 0) {
return null;
}
return Math.max(0, Math.min(100, (used / total) * 100));
}
export function formatUptime(seconds?: number | null) {
if (!seconds || seconds <= 0) {
return '—';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} 天 ${hours} 小时`;
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`;
}
return `${minutes} 分钟`;
}
export function formatTrendHour(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return `${date.getHours().toString().padStart(2, '0')}:00`;
}
export function parseTrafficMap(value?: string | null) {
if (!value) {
return {} as Record<string, number>;
}
try {
const parsed = JSON.parse(value) as Record<string, number>;
return Object.entries(parsed).reduce<Record<string, number>>(
(result, [key, count]) => {
if (typeof count === 'number' && Number.isFinite(count)) {
result[key] = count;
}
return result;
},
{},
);
} catch {
return {} as Record<string, number>;
}
}
export function aggregateTrafficBreakdown(
reports: NodeObservability['traffic_reports'],
field: 'status_codes_json' | 'top_domains_json',
) {
const summary = new Map<string, number>();
for (const report of reports) {
const parsed = parseTrafficMap(report[field]);
for (const [key, value] of Object.entries(parsed)) {
summary.set(key, (summary.get(key) ?? 0) + value);
}
}
return Array.from(summary.entries())
.sort((left, right) => {
if (right[1] === left[1]) {
return left[0].localeCompare(right[0]);
}
return right[1] - left[1];
})
.slice(0, 6)
.map(([label, value]) => ({ label, value }));
}
export function getHealthEventVariant(
event: NodeObservability['health_events'][number],
): 'success' | 'warning' | 'danger' | 'info' {
if (event.status === 'resolved') {
return 'success';
}
if (event.severity === 'critical') {
return 'danger';
}
if (event.severity === 'warning') {
return 'warning';
}
return 'info';
}
export function getHealthEventLabel(
event: NodeObservability['health_events'][number],
) {
return event.event_type.replaceAll('_', ' ');
}
export function MetricBar({
label,
value,
progress,
hint,
}: {
label: string;
value: string;
progress?: number | null;
hint?: string;
}) {
return (
<div className="space-y-2 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
{hint ? (
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
{hint}
</p>
) : null}
</div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
{value}
</p>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--status-info-foreground)] transition-[width]"
style={{ width: `${progress ?? 0}%` }}
/>
</div>
</div>
);
}
export function SummaryStat({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{label}
</p>
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
{value}
</p>
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">{hint}</p>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -243,3 +243,29 @@ export function buildRelayDockerInstallCommand(
` ${image}`, ` ${image}`,
].join('\n'); ].join('\n');
} }
export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string) {
const flaredInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh';
return [
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --tunnel-token ${tunnelToken}`,
].join('\n');
}
export function buildTunnelDockerInstallCommand(
serverUrl: string,
tunnelToken: string,
) {
const image = 'ghcr.io/rain-kl/openflare-flared:latest';
return [
`docker pull ${image}`,
`docker rm -f openflare-flared 2>/dev/null || true`,
`docker run -d --name openflare-flared --restart unless-stopped \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_TUNNEL_TOKEN=${tunnelToken} \\`,
` ${image}`,
].join('\n');
}