mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
[优化] 重构节点详情页面
This commit is contained in:
+1
-1
@@ -48,5 +48,5 @@ services:
|
||||
- ./openflared/data/:/app/data
|
||||
environment:
|
||||
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';
|
||||
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
|
||||
import type {
|
||||
NodeItem,
|
||||
NodeAgentReleaseInfo,
|
||||
NodeObservability,
|
||||
} from '@/features/nodes/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
@@ -53,8 +53,6 @@ import {
|
||||
import {
|
||||
buildNodeInstallCommand,
|
||||
buildNodeDockerInstallCommand,
|
||||
buildRelayInstallCommand,
|
||||
buildRelayDockerInstallCommand,
|
||||
getApplyLabel,
|
||||
getApplyVariant,
|
||||
getNodeStatusLabel,
|
||||
@@ -66,183 +64,76 @@ import {
|
||||
isMeaningfulTime,
|
||||
} 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'];
|
||||
|
||||
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 }) {
|
||||
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 queryClient = useQueryClient();
|
||||
const nodeId = String(node.id);
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false);
|
||||
@@ -258,12 +149,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const nodesQuery = useQuery({
|
||||
queryKey: nodesQueryKey,
|
||||
queryFn: getNodes,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const stableAgentReleaseQuery = useQuery({
|
||||
queryKey: ['node-agent-release', nodeId, 'stable'],
|
||||
queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'),
|
||||
@@ -276,21 +161,14 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
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 ?? '', 1, 10],
|
||||
queryKey: ['apply-logs', node.node_id, 1, 10],
|
||||
queryFn: () =>
|
||||
getApplyLogs({
|
||||
node_id: node?.node_id,
|
||||
node_id: node.node_id,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
}),
|
||||
enabled: Boolean(node?.node_id),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
@@ -304,7 +182,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
queryKey: ['node-observability', nodeId],
|
||||
queryFn: () =>
|
||||
getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }),
|
||||
enabled: Boolean(nodeId),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
@@ -418,10 +295,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
|
||||
@@ -435,10 +308,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
};
|
||||
|
||||
const handleRestartOpenresty = () => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认向节点“${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 nodeInstallCommand =
|
||||
normalizedServerUrl && node.agent_token
|
||||
? node.node_type === 'tunnel_relay'
|
||||
? buildRelayInstallCommand(normalizedServerUrl, node.agent_token)
|
||||
: buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
|
||||
? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
|
||||
: '';
|
||||
const nodeDockerInstallCommand =
|
||||
normalizedServerUrl && node.agent_token
|
||||
? node.node_type === 'tunnel_relay'
|
||||
? buildRelayDockerInstallCommand(normalizedServerUrl, node.agent_token)
|
||||
: buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token)
|
||||
? buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token)
|
||||
: '';
|
||||
const updateMode = getUpdateMode(node);
|
||||
const selectedAgentRelease =
|
||||
@@ -626,9 +469,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
};
|
||||
|
||||
const isRefreshing =
|
||||
nodesQuery.isFetching ||
|
||||
applyLogsQuery.isFetching ||
|
||||
observabilityQuery.isFetching;
|
||||
observabilityQuery.isFetching ||
|
||||
configVersionsQuery.isFetching;
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setFeedback(null);
|
||||
@@ -651,7 +494,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={node.name}
|
||||
description="节点详情"
|
||||
description="边缘代理节点详情 (edge_node)"
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
@@ -746,77 +589,39 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
: '当前没有活动中的健康事件'
|
||||
}
|
||||
/>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<>
|
||||
<SummaryStat
|
||||
label="隧道总数"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.total_proxies}`
|
||||
: '—'
|
||||
}
|
||||
hint={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.online_proxies} 个在线,${observability.relay_dashboard.offline_proxies} 个离线`
|
||||
: '暂无隧道统计'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="当前连接数"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.total_connections}`
|
||||
: '—'
|
||||
}
|
||||
hint="隧道网络承载的活动连接总数"
|
||||
/>
|
||||
<SummaryStat
|
||||
label="活动客户端"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.client_counts}`
|
||||
: '—'
|
||||
}
|
||||
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')} 次请求`
|
||||
: '当前没有来源分布数据'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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 className="grid gap-6 xl:grid-cols-3">
|
||||
@@ -886,9 +691,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
总内存
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatBytes(
|
||||
observability.profile.total_memory_bytes,
|
||||
)}
|
||||
{formatBytes(observability.profile.total_memory_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -932,9 +735,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<MetricBar
|
||||
label="CPU"
|
||||
value={formatPercent(
|
||||
latestMetricSnapshot.cpu_usage_percent,
|
||||
)}
|
||||
value={formatPercent(latestMetricSnapshot.cpu_usage_percent)}
|
||||
progress={latestMetricSnapshot.cpu_usage_percent}
|
||||
hint={
|
||||
isMeaningfulTime(latestMetricSnapshot.captured_at)
|
||||
@@ -953,9 +754,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
label="存储"
|
||||
value={`${formatBytes(
|
||||
latestMetricSnapshot.storage_used_bytes,
|
||||
)} / ${formatBytes(
|
||||
latestMetricSnapshot.storage_total_bytes,
|
||||
)}`}
|
||||
)} / ${formatBytes(latestMetricSnapshot.storage_total_bytes)}`}
|
||||
progress={storageUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
@@ -978,8 +777,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
{node.node_type === 'edge_node' ? (
|
||||
<AppCard title="网络流量">
|
||||
<AppCard title="网络流量">
|
||||
{observabilityQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : observabilityQuery.isError ? (
|
||||
@@ -996,9 +794,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
/>
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
variant={getOpenrestyStatusVariant(
|
||||
node.openresty_status,
|
||||
)}
|
||||
variant={getOpenrestyStatusVariant(node.openresty_status)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={
|
||||
@@ -1006,9 +802,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
: '无活动异常'
|
||||
}
|
||||
variant={
|
||||
activeHealthEvents.length ? 'warning' : 'success'
|
||||
}
|
||||
variant={activeHealthEvents.length ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1058,9 +852,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||
{trafficSummary
|
||||
? trafficSummary.request_count.toLocaleString(
|
||||
'zh-CN',
|
||||
)
|
||||
? trafficSummary.request_count.toLocaleString('zh-CN')
|
||||
: '—'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-secondary)]">
|
||||
@@ -1093,12 +885,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
) : null}
|
||||
</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
|
||||
title="24 小时请求趋势"
|
||||
description="按小时聚合该节点的请求量和错误量。"
|
||||
@@ -1244,64 +1033,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</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}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
{node.node_type === 'edge_node' ? (
|
||||
<AppCard
|
||||
title="请求结构分布"
|
||||
<AppCard
|
||||
title="请求结构分布"
|
||||
description="聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。"
|
||||
>
|
||||
<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)]">
|
||||
{dominantStatusCode
|
||||
? `${dominantStatusCode.value} 次`
|
||||
: '暂无状态码聚合'}
|
||||
: '暂无状态码配置'}
|
||||
</p>
|
||||
</div>
|
||||
<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)]">
|
||||
{dominantDomain
|
||||
? `${dominantDomain.value} 次`
|
||||
: '暂无域名聚合'}
|
||||
: '暂无域名配置'}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="健康事件时间线"
|
||||
description="保留活动与已恢复事件,帮助判断问题是持续中、间歇性还是已经恢复。"
|
||||
description="保留活动与已恢复事件,帮助判断运行状态。"
|
||||
action={
|
||||
<DangerButton
|
||||
type="button"
|
||||
@@ -1436,12 +1172,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
variant={getHealthEventVariant(event)}
|
||||
/>
|
||||
<StatusBadge
|
||||
label={
|
||||
event.status === 'active' ? '活动中' : '已恢复'
|
||||
}
|
||||
variant={
|
||||
event.status === 'active' ? 'warning' : 'success'
|
||||
}
|
||||
label={event.status === 'active' ? '活动中' : '已恢复'}
|
||||
variant={event.status === 'active' ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
|
||||
@@ -1507,17 +1239,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
|
||||
<AppCard title="版本信息">
|
||||
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<>
|
||||
<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>Agent:{node.agent_version || 'unknown'}</p>
|
||||
<p>Nginx:{node.nginx_version || 'unknown'}</p>
|
||||
<p>当前配置:{node.current_version || '未应用'}</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
@@ -1623,9 +1346,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
{node.node_type === 'edge_node' ? (
|
||||
<AppCard
|
||||
title="OpenResty 健康与控制"
|
||||
<AppCard
|
||||
title="OpenResty 健康与控制"
|
||||
description="OpenResty 当前健康状态。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -1674,7 +1396,6 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<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}`,
|
||||
].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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user