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
|
- ./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');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user