2026-09-23 09:40:59 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<section class="hero-banner">
|
|
|
|
|
|
<div class="hero-content">
|
|
|
|
|
|
<h1 class="hero-banner__title">{{ greetingText }}</h1>
|
|
|
|
|
|
<p class="hero-banner__desc">Fonu 正在为你的 NAS 提供稳定、安全的访问服务。</p>
|
|
|
|
|
|
<div class="hero-banner__badges">
|
|
|
|
|
|
<span class="hero-banner__badge"><n-icon :component="CheckmarkCircleOutline" /> 简单易用</span>
|
|
|
|
|
|
<span class="hero-banner__badge"><n-icon :component="FlashOutline" /> 安全稳定</span>
|
|
|
|
|
|
<span class="hero-banner__badge"><n-icon :component="GlobeOutline" /> 随时随地访问</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<img class="hero-art" :src="bannerImg" alt="" aria-hidden="true" />
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<n-spin :show="loading && !status">
|
|
|
|
|
|
<div class="stats-row">
|
|
|
|
|
|
<StatCard label="公网 IP" tone="blue">
|
|
|
|
|
|
<template #icon><n-icon :component="GlobeOutline" /></template>
|
|
|
|
|
|
<template #extra><StatusBadge v-if="publicIPv4Label !== '-'" value="ok" text="正常" /></template>
|
|
|
|
|
|
<template #value>
|
|
|
|
|
|
<span class="stat-primary mono">{{ publicIPv4Label }}</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<p class="stat-desc mono">{{ publicIPv6Label }}</p>
|
|
|
|
|
|
<div class="stat-foot">
|
|
|
|
|
|
<div class="stat-foot__line">{{ publicIPSourceLabel }}</div>
|
|
|
|
|
|
<div class="stat-foot__muted">上次更新 {{ formatRelativeTime(status?.ddns_last_updated) || '刚刚' }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</StatCard>
|
|
|
|
|
|
|
|
|
|
|
|
<StatCard label="域名" tone="green">
|
|
|
|
|
|
<template #icon><n-icon :component="WifiOutline" /></template>
|
2026-09-24 17:06:34 +08:00
|
|
|
|
<template #extra><StatusBadge v-if="status?.ddns_status" :value="status.ddns_status" :text="statusLabel(status.ddns_status)" /></template>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
<template #value>{{ domainCountLabel }}</template>
|
|
|
|
|
|
<p class="stat-desc">{{ domainSubLabel }}</p>
|
|
|
|
|
|
<div class="stat-foot">
|
|
|
|
|
|
<div class="stat-foot__line">{{ domainExamplesLabel }}</div>
|
|
|
|
|
|
<div class="stat-foot__muted">上次检查 {{ formatRelativeTime(status?.ddns_last_updated) || '刚刚' }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</StatCard>
|
|
|
|
|
|
|
|
|
|
|
|
<StatCard label="HTTPS 证书" tone="blue">
|
|
|
|
|
|
<template #icon><n-icon :component="ShieldCheckmarkOutline" /></template>
|
2026-09-24 17:06:34 +08:00
|
|
|
|
<template #extra><StatusBadge v-if="status?.certificate_status" :value="status.certificate_status" :text="statusLabel(status.certificate_status)" /></template>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
<template #value>{{ certCountLabel }}</template>
|
|
|
|
|
|
<p class="stat-desc">{{ certSubLabel }}</p>
|
|
|
|
|
|
<div v-if="primaryCert" class="stat-foot">
|
|
|
|
|
|
<div class="stat-foot__line mono">{{ certDisplayName }}</div>
|
|
|
|
|
|
<div class="stat-foot__muted">
|
|
|
|
|
|
到期 {{ formatDate(primaryCert.expires_at) }} · 剩余 {{ primaryCert.days_left }} 天
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</StatCard>
|
|
|
|
|
|
|
|
|
|
|
|
<StatCard label="服务" tone="teal">
|
|
|
|
|
|
<template #icon><n-icon :component="GitNetworkOutline" /></template>
|
2026-09-23 19:10:43 +08:00
|
|
|
|
<template #extra><StatusBadge :value="serviceHealth.badgeValue" :text="serviceHealth.badgeText" /></template>
|
|
|
|
|
|
<template #value>{{ serviceHealth.value }}</template>
|
|
|
|
|
|
<p class="stat-desc">{{ serviceHealth.desc }}</p>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
<div class="stat-foot">
|
2026-09-25 13:05:58 +08:00
|
|
|
|
<div class="stat-foot__line">Fonu · Nginx · DDNS · 证书</div>
|
|
|
|
|
|
<div class="stat-foot__muted">
|
|
|
|
|
|
运行 {{ formatUptime(status?.uptime_seconds ?? 0) }} · 上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}
|
|
|
|
|
|
</div>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</StatCard>
|
2026-09-23 19:10:43 +08:00
|
|
|
|
|
|
|
|
|
|
<StatCard label="公网穿透" tone="brand">
|
|
|
|
|
|
<template #icon><n-icon :component="SwapVerticalOutline" /></template>
|
|
|
|
|
|
<template #extra><StatusBadge :value="frpHealth.badgeValue" :text="frpHealth.badgeText" /></template>
|
|
|
|
|
|
<template #value>{{ frpHealth.value }}</template>
|
|
|
|
|
|
<p class="stat-desc">{{ frpHealth.desc }}</p>
|
|
|
|
|
|
<div class="stat-foot">
|
|
|
|
|
|
<div class="stat-foot__line mono">{{ frpHealth.version }}</div>
|
|
|
|
|
|
<div class="stat-foot__muted">上次应用 {{ frpHealth.applied }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</StatCard>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="metrics-row">
|
|
|
|
|
|
<FonuCard class="panel requests-panel">
|
|
|
|
|
|
<div class="panel__head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<n-icon :component="FlashOutline" class="panel-title__icon panel-title__icon--amber" />
|
|
|
|
|
|
<span>今日请求</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel__body requests-panel__body">
|
|
|
|
|
|
<div class="metric-hero">
|
|
|
|
|
|
<span class="metric-hero__value">{{ (status?.request_today ?? 0).toLocaleString() }}</span>
|
|
|
|
|
|
<n-tag v-if="requestTrend !== null" size="small" :bordered="false" :type="requestTrend >= 0 ? 'success' : 'warning'">
|
|
|
|
|
|
较昨日 {{ requestTrend >= 0 ? '+' : '' }}{{ requestTrend }}%
|
|
|
|
|
|
</n-tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel-chart">
|
|
|
|
|
|
<MiniBarChart :values="hourlyBars.values" :labels="hourlyBars.labels" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
|
|
|
|
|
|
<FonuCard class="panel traffic-panel">
|
|
|
|
|
|
<div class="panel__head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<n-icon :component="SwapVerticalOutline" class="panel-title__icon panel-title__icon--teal" />
|
|
|
|
|
|
<span>实时流量</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<n-tag size="small" :bordered="false" class="range-tag">最近 5 分钟</n-tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel__body traffic-panel__body">
|
|
|
|
|
|
<div class="traffic-rates">
|
|
|
|
|
|
<div class="traffic-rate traffic-rate--up">
|
|
|
|
|
|
<span class="traffic-rate__dot" />
|
|
|
|
|
|
<span class="traffic-rate__label">上传</span>
|
|
|
|
|
|
<span class="traffic-rate__value">{{ formatRate(trafficTotals.uploadRate) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="traffic-rate traffic-rate--down">
|
|
|
|
|
|
<span class="traffic-rate__dot" />
|
|
|
|
|
|
<span class="traffic-rate__label">下载</span>
|
|
|
|
|
|
<span class="traffic-rate__value">{{ formatRate(trafficTotals.downloadRate) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel-chart">
|
|
|
|
|
|
<MiniTrafficChart
|
|
|
|
|
|
:labels="trafficChart.labels"
|
|
|
|
|
|
:upload="trafficChart.upload"
|
|
|
|
|
|
:download="trafficChart.download"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-09-23 19:10:43 +08:00
|
|
|
|
<div v-if="trafficTop.length > 0" class="traffic-top">
|
|
|
|
|
|
<span class="traffic-top__title">流量 Top {{ trafficTop.length }}</span>
|
|
|
|
|
|
<ul class="traffic-top__list">
|
|
|
|
|
|
<li v-for="item in trafficTop" :key="item.name" class="traffic-top__item">
|
|
|
|
|
|
<span class="traffic-top__name" :title="item.name">{{ item.name }}</span>
|
|
|
|
|
|
<span class="traffic-top__rate">{{ item.rate }}</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
|
2026-09-25 13:05:58 +08:00
|
|
|
|
<FonuCard class="panel trend-card">
|
2026-09-23 09:40:59 +08:00
|
|
|
|
<div class="panel__head">
|
|
|
|
|
|
<div class="panel-title">
|
2026-09-25 13:05:58 +08:00
|
|
|
|
<n-icon :component="StatsChartOutline" class="panel-title__icon panel-title__icon--teal" />
|
|
|
|
|
|
<span>资源与流量趋势</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="trend-range">
|
|
|
|
|
|
<n-button
|
|
|
|
|
|
v-for="item in METRICS_RANGES"
|
|
|
|
|
|
:key="item.hours"
|
|
|
|
|
|
size="tiny"
|
|
|
|
|
|
quaternary
|
|
|
|
|
|
:type="trendHours === item.hours ? 'primary' : 'default'"
|
|
|
|
|
|
@click="trendHours = item.hours"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.label }}
|
|
|
|
|
|
</n-button>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-25 13:05:58 +08:00
|
|
|
|
<div class="panel__body">
|
|
|
|
|
|
<MetricsTrendPanel :hours="trendHours" />
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
2026-09-23 19:10:43 +08:00
|
|
|
|
|
2026-09-25 13:05:58 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ops-row">
|
|
|
|
|
|
<FonuCard class="panel perf-panel">
|
2026-09-23 19:10:43 +08:00
|
|
|
|
<div class="panel__head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<n-icon :component="FlashOutline" class="panel-title__icon panel-title__icon--amber" />
|
|
|
|
|
|
<span>性能</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel__body perf-list">
|
|
|
|
|
|
<div class="health-item">
|
|
|
|
|
|
<div class="health-item__icon health-item__icon--blue">
|
|
|
|
|
|
<n-icon :component="PulseOutline" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="health-item__main">
|
|
|
|
|
|
<span class="health-item__name">平均响应</span>
|
|
|
|
|
|
<span class="health-item__meta">今日访问平均耗时</span>
|
|
|
|
|
|
<span class="health-item__detail">{{ avgResponseMeta }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="perf-value">
|
|
|
|
|
|
{{ avgResponseLabel }}<small v-if="requestToday > 0">ms</small>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="health-item">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="health-item__icon"
|
|
|
|
|
|
:class="errorToday > 0 ? 'health-item__icon--error' : 'health-item__icon--green'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<n-icon :component="AlertCircleOutline" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="health-item__main">
|
|
|
|
|
|
<span class="health-item__name">今日异常</span>
|
|
|
|
|
|
<span class="health-item__meta">状态码 ≥ 400 的请求</span>
|
|
|
|
|
|
<span class="health-item__detail">{{ errorRateLabel }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="perf-value" :class="{ 'perf-value--error': errorToday > 0 }">{{ errorTodayLabel }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
|
|
|
|
|
|
<FonuCard class="panel">
|
|
|
|
|
|
<div class="panel__head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<n-icon :component="ServerOutline" class="panel-title__icon panel-title__icon--teal" />
|
|
|
|
|
|
<span>系统资源</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel__body perf-list">
|
|
|
|
|
|
<div v-for="item in resourceItems" :key="item.name" class="health-item">
|
|
|
|
|
|
<div class="health-item__icon" :class="`health-item__icon--${item.tone}`">
|
|
|
|
|
|
<n-icon :component="item.icon" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="health-item__main">
|
|
|
|
|
|
<span class="health-item__name">{{ item.name }}</span>
|
|
|
|
|
|
<span class="health-item__meta">{{ item.detail }}</span>
|
|
|
|
|
|
<span v-if="item.available" class="res-bar" :class="`res-bar--${item.barTone}`">
|
|
|
|
|
|
<span class="res-bar__fill" :style="{ width: item.barWidth }" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="perf-value">{{ item.value }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
|
|
|
|
|
|
<FonuCard class="panel">
|
|
|
|
|
|
<div class="panel__head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<n-icon :component="GlobeOutline" class="panel-title__icon panel-title__icon--teal" />
|
|
|
|
|
|
<span>中国 IP 段</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="head-actions">
|
|
|
|
|
|
<StatusBadge :value="cidrBadge.value" :text="cidrBadge.text" />
|
|
|
|
|
|
<n-button size="tiny" quaternary :loading="refreshingCIDR" @click="refreshCIDR">刷新</n-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel__body perf-list">
|
|
|
|
|
|
<div v-for="item in cidrItems" :key="item.name" class="health-item">
|
|
|
|
|
|
<div class="health-item__icon" :class="`health-item__icon--${item.tone}`">
|
|
|
|
|
|
<n-icon :component="item.icon" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="health-item__main">
|
|
|
|
|
|
<span class="health-item__name">{{ item.name }}</span>
|
|
|
|
|
|
<span class="health-item__meta">{{ item.detail }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="perf-value">{{ item.value }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
|
|
|
|
|
|
<FonuCard class="panel">
|
|
|
|
|
|
<div class="panel__head">
|
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
|
<n-icon :component="ShieldCheckmarkOutline" class="panel-title__icon panel-title__icon--teal" />
|
|
|
|
|
|
<span>通知通道</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="head-actions">
|
|
|
|
|
|
<StatusBadge :value="notifyChannel.badge" :text="notifyChannel.badgeText" />
|
|
|
|
|
|
<router-link :to="{ name: 'settings' }" class="card-link">
|
|
|
|
|
|
去设置
|
|
|
|
|
|
<n-icon :component="ChevronForwardOutline" />
|
|
|
|
|
|
</router-link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="panel__body perf-list">
|
|
|
|
|
|
<div v-for="row in notifyChannel.rows" :key="row.name" class="health-item">
|
|
|
|
|
|
<div class="health-item__icon" :class="`health-item__icon--${row.tone}`">
|
|
|
|
|
|
<n-icon :component="row.icon" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="health-item__main">
|
|
|
|
|
|
<span class="health-item__name">{{ row.name }}</span>
|
|
|
|
|
|
<span class="health-item__meta">{{ row.detail }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="perf-value">{{ row.value }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="bottom-row">
|
|
|
|
|
|
<FonuCard flush class="bottom-card">
|
|
|
|
|
|
<template #title>
|
|
|
|
|
|
<n-icon :component="ListOutline" class="card-title-icon" />
|
|
|
|
|
|
<span>最近访问</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<router-link :to="{ name: 'logs', query: { tab: 'access' } }" class="card-link">
|
|
|
|
|
|
查看全部
|
|
|
|
|
|
<n-icon :component="ChevronForwardOutline" />
|
|
|
|
|
|
</router-link>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<div class="bottom-card__body">
|
|
|
|
|
|
<n-data-table
|
|
|
|
|
|
v-if="accessLogs.length > 0"
|
|
|
|
|
|
class="bottom-table"
|
|
|
|
|
|
:columns="accessColumns"
|
|
|
|
|
|
:data="accessLogs"
|
|
|
|
|
|
:bordered="false"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<EmptyState v-else title="暂无访问记录" description="产生访问后这里会显示最近请求。" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
|
|
|
|
|
|
<FonuCard flush class="bottom-card">
|
|
|
|
|
|
<template #title>
|
|
|
|
|
|
<n-icon :component="DocumentTextOutline" class="card-title-icon" />
|
|
|
|
|
|
<span>最新日志</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<router-link :to="{ name: 'logs', query: { tab: 'system' } }" class="card-link">
|
|
|
|
|
|
查看全部
|
|
|
|
|
|
<n-icon :component="ChevronForwardOutline" />
|
|
|
|
|
|
</router-link>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<div class="bottom-card__body">
|
|
|
|
|
|
<n-data-table
|
|
|
|
|
|
v-if="systemLogs.length > 0"
|
|
|
|
|
|
class="bottom-table"
|
|
|
|
|
|
:columns="systemColumns"
|
|
|
|
|
|
:data="systemLogs"
|
|
|
|
|
|
:bordered="false"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<EmptyState v-else title="暂无系统日志" description="应用运行后会产生日志。" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</FonuCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</n-spin>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
|
2026-09-23 19:10:43 +08:00
|
|
|
|
import { NButton, NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
2026-09-23 09:40:59 +08:00
|
|
|
|
import {
|
2026-09-23 19:10:43 +08:00
|
|
|
|
AlertCircleOutline,
|
2026-09-23 09:40:59 +08:00
|
|
|
|
AppsOutline,
|
|
|
|
|
|
CheckmarkCircleOutline,
|
|
|
|
|
|
ChevronForwardOutline,
|
|
|
|
|
|
CloudOutline,
|
|
|
|
|
|
DocumentTextOutline,
|
|
|
|
|
|
FlashOutline,
|
|
|
|
|
|
GitNetworkOutline,
|
|
|
|
|
|
GlobeOutline,
|
|
|
|
|
|
ListOutline,
|
|
|
|
|
|
PulseOutline,
|
|
|
|
|
|
ServerOutline,
|
|
|
|
|
|
ShieldCheckmarkOutline,
|
2026-09-25 13:05:58 +08:00
|
|
|
|
StatsChartOutline,
|
2026-09-23 09:40:59 +08:00
|
|
|
|
SwapVerticalOutline,
|
|
|
|
|
|
WifiOutline,
|
|
|
|
|
|
} from '@vicons/ionicons5'
|
|
|
|
|
|
import bannerImg from '../assets/brand/banner.png'
|
|
|
|
|
|
import { api, asList } from '../api/client'
|
|
|
|
|
|
import type {
|
|
|
|
|
|
AccessLogEntry,
|
|
|
|
|
|
CertificateRecord,
|
2026-09-23 19:10:43 +08:00
|
|
|
|
ChinaCIDRStatus,
|
2026-09-23 09:40:59 +08:00
|
|
|
|
DashboardStatus,
|
|
|
|
|
|
DDNSConfig,
|
2026-09-23 19:10:43 +08:00
|
|
|
|
FrpRouteHealthSummary,
|
|
|
|
|
|
FrpStatus,
|
|
|
|
|
|
NotifyEmailConfig,
|
|
|
|
|
|
NotifyTelegramConfig,
|
|
|
|
|
|
NotifyType,
|
|
|
|
|
|
NotifyWebhookConfig,
|
2026-09-23 09:40:59 +08:00
|
|
|
|
ProxyRule,
|
|
|
|
|
|
ProxyTraffic,
|
2026-09-23 19:10:43 +08:00
|
|
|
|
SettingsMap,
|
2026-09-23 09:40:59 +08:00
|
|
|
|
SystemLogEntry,
|
|
|
|
|
|
} from '../api/types'
|
|
|
|
|
|
import EmptyState from '../components/EmptyState.vue'
|
|
|
|
|
|
import FonuCard from '../components/FonuCard.vue'
|
|
|
|
|
|
import LoadError from '../components/LoadError.vue'
|
|
|
|
|
|
import MiniBarChart from '../components/MiniBarChart.vue'
|
|
|
|
|
|
import MiniTrafficChart from '../components/MiniTrafficChart.vue'
|
2026-09-25 13:05:58 +08:00
|
|
|
|
import MetricsTrendPanel from '../components/MetricsTrendPanel.vue'
|
|
|
|
|
|
import { METRICS_RANGES } from '../constants/metrics'
|
|
|
|
|
|
|
|
|
|
|
|
// 「资源与流量趋势」的时间档:切换即让子组件按新窗口重新取数
|
|
|
|
|
|
const trendHours = ref(24)
|
2026-09-23 09:40:59 +08:00
|
|
|
|
import StatCard from '../components/StatCard.vue'
|
|
|
|
|
|
import StatusBadge from '../components/StatusBadge.vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
accessServiceTooltip,
|
|
|
|
|
|
buildProxyBindingIndex,
|
|
|
|
|
|
resolveAccessServiceLabel,
|
|
|
|
|
|
} from '../utils/accessService'
|
2026-09-23 19:10:43 +08:00
|
|
|
|
import {
|
|
|
|
|
|
formatBytes,
|
|
|
|
|
|
formatDate,
|
|
|
|
|
|
formatLogTime,
|
|
|
|
|
|
formatRate,
|
|
|
|
|
|
formatRelativeTime,
|
|
|
|
|
|
formatUptime,
|
|
|
|
|
|
} from '../utils/format'
|
2026-09-23 09:40:59 +08:00
|
|
|
|
import { displaySystemLog } from '../utils/logDisplay'
|
2026-09-24 17:06:34 +08:00
|
|
|
|
import { httpStatusKind, statusLabel } from '../utils/status'
|
2026-09-23 09:40:59 +08:00
|
|
|
|
|
|
|
|
|
|
const message = useMessage()
|
|
|
|
|
|
const status = ref<DashboardStatus | null>(null)
|
|
|
|
|
|
const ddnsConfigs = ref<DDNSConfig[]>([])
|
|
|
|
|
|
const certificates = ref<CertificateRecord[]>([])
|
|
|
|
|
|
const accessLogs = ref<AccessLogEntry[]>([])
|
|
|
|
|
|
const proxyRules = ref<ProxyRule[]>([])
|
|
|
|
|
|
const systemLogs = ref<SystemLogEntry[]>([])
|
2026-09-23 19:10:43 +08:00
|
|
|
|
const frpStatus = ref<FrpStatus | null>(null)
|
|
|
|
|
|
const routeHealth = ref<FrpRouteHealthSummary | null>(null)
|
|
|
|
|
|
const loadingRemoteHealth = ref(false)
|
|
|
|
|
|
const chinaCIDR = ref<ChinaCIDRStatus | null>(null)
|
|
|
|
|
|
const refreshingCIDR = ref(false)
|
|
|
|
|
|
const settings = ref<SettingsMap | null>(null)
|
2026-09-23 09:40:59 +08:00
|
|
|
|
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const loadError = ref('')
|
|
|
|
|
|
const trafficByRule = ref<Record<number, ProxyTraffic>>({})
|
|
|
|
|
|
type RatePoint = { at: number; upload: number; download: number }
|
|
|
|
|
|
const rateHistory = ref<RatePoint[]>([])
|
|
|
|
|
|
const RATE_HISTORY_MS = 5 * 60 * 1000
|
|
|
|
|
|
let trafficTimer: ReturnType<typeof setInterval> | null = null
|
2026-09-23 19:10:43 +08:00
|
|
|
|
let statusTimer: ReturnType<typeof setInterval> | null = null
|
2026-09-23 09:40:59 +08:00
|
|
|
|
|
|
|
|
|
|
function timeGreeting(hour: number): string {
|
|
|
|
|
|
if (hour >= 5 && hour < 12) return '上午好'
|
|
|
|
|
|
if (hour >= 12 && hour < 18) return '下午好'
|
|
|
|
|
|
return '晚上好'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const greetingText = computed(() => `${timeGreeting(new Date().getHours())},管理员`)
|
|
|
|
|
|
|
|
|
|
|
|
const primaryCert = computed(() => {
|
|
|
|
|
|
if (certificates.value.length === 0) return null
|
|
|
|
|
|
return [...certificates.value].sort((a, b) => a.days_left - b.days_left)[0]
|
|
|
|
|
|
})
|
|
|
|
|
|
const certWildcard = computed(() => {
|
|
|
|
|
|
const domains = primaryCert.value?.domains ?? []
|
|
|
|
|
|
return domains.find((d) => d.startsWith('*.')) ?? (primaryCert.value?.wildcard ? `*.${primaryCert.value.domain}` : '')
|
|
|
|
|
|
})
|
|
|
|
|
|
const certDisplayName = computed(() => certWildcard.value || primaryCert.value?.domain || '')
|
|
|
|
|
|
|
|
|
|
|
|
const publicIPv4Label = computed(() => status.value?.public_ipv4 || '-')
|
|
|
|
|
|
const publicIPv6Label = computed(() => {
|
|
|
|
|
|
if (status.value?.public_ip_source === 'ddns' && !status.value?.public_ipv6) return '未获取'
|
|
|
|
|
|
return status.value?.public_ipv6 || '-'
|
|
|
|
|
|
})
|
|
|
|
|
|
const publicIPSourceLabel = computed(() => {
|
|
|
|
|
|
switch (status.value?.public_ip_source) {
|
|
|
|
|
|
case 'ddns':
|
|
|
|
|
|
return status.value?.public_ipv4 || status.value?.public_ipv6
|
|
|
|
|
|
? '来源:DDNS 已同步记录'
|
|
|
|
|
|
: '来源:DDNS 已配置,等待同步'
|
|
|
|
|
|
case 'detect':
|
|
|
|
|
|
return '来源:出口 IP 探测(未配置 DDNS)'
|
|
|
|
|
|
default:
|
|
|
|
|
|
return '来源:暂无'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const domainCountLabel = computed(() => {
|
|
|
|
|
|
const count = status.value?.ddns_count ?? ddnsConfigs.value.length
|
|
|
|
|
|
if (count === 0) return '未配置'
|
|
|
|
|
|
return `${count} 个`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const domainSubLabel = computed(() => {
|
|
|
|
|
|
const count = status.value?.ddns_count ?? ddnsConfigs.value.length
|
|
|
|
|
|
if (count === 0) return '尚未配置域名解析'
|
|
|
|
|
|
return '已配置并正常解析的域名'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const domainExamplesLabel = computed(() => {
|
|
|
|
|
|
const examples: string[] = []
|
|
|
|
|
|
for (const cfg of ddnsConfigs.value.filter((c) => c.enabled)) {
|
|
|
|
|
|
const root = cfg.root_domain
|
|
|
|
|
|
const names = cfg.record_names?.length ? cfg.record_names : [cfg.record_name || '@']
|
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
|
if (!name || name === '@') examples.push(root)
|
|
|
|
|
|
else if (name === '*') examples.push(`*.${root}`)
|
|
|
|
|
|
else if (name.includes('.')) examples.push(name)
|
|
|
|
|
|
else examples.push(`${name}.${root}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return examples.length > 0 ? examples.join(', ') : '暂无域名记录'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const certCountLabel = computed(() => {
|
|
|
|
|
|
const count = status.value?.certificate_count ?? certificates.value.length
|
|
|
|
|
|
if (count === 0) return '未申请'
|
|
|
|
|
|
return `${count} 个`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const certSubLabel = computed(() => {
|
|
|
|
|
|
if (!primaryCert.value) return '尚未申请 HTTPS 证书'
|
|
|
|
|
|
if (status.value?.certificate_status === 'ok') return '证书有效,自动续期中'
|
|
|
|
|
|
return '证书需要关注'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
// 「服务」卡按四项实际健康状态聚合:任一项非 running/ok 都降级并点名,避免写死「正常」
|
|
|
|
|
|
const serviceHealth = computed(() => {
|
|
|
|
|
|
const items = healthItems.value
|
|
|
|
|
|
const bad = items.filter((item) => item.status !== 'running' && item.status !== 'ok')
|
|
|
|
|
|
if (bad.length === 0) {
|
|
|
|
|
|
return { value: `${items.length} 个`, desc: '核心服务运行正常', badgeValue: 'ok', badgeText: '正常' }
|
|
|
|
|
|
}
|
|
|
|
|
|
const severe = bad.some((item) => item.status === 'error' || item.status === 'stopped')
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: `${items.length - bad.length}/${items.length} 正常`,
|
|
|
|
|
|
desc: `异常:${bad.map((item) => item.name).join('、')}`,
|
|
|
|
|
|
badgeValue: severe ? 'error' : 'warning',
|
|
|
|
|
|
badgeText: '需关注',
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 今日异常与平均响应:/api/status 早已返回,此前未展示
|
|
|
|
|
|
const requestToday = computed(() => status.value?.request_today ?? 0)
|
|
|
|
|
|
const errorToday = computed(() => status.value?.error_today ?? 0)
|
|
|
|
|
|
// 今日无请求时不显示 0ms / 0(看起来像真实测量值),统一显示为 —
|
|
|
|
|
|
const avgResponseLabel = computed(() =>
|
|
|
|
|
|
requestToday.value > 0 ? String(Math.round(status.value?.avg_response_ms ?? 0)) : '—',
|
|
|
|
|
|
)
|
|
|
|
|
|
const avgResponseMeta = computed(() =>
|
|
|
|
|
|
requestToday.value > 0 ? `共 ${requestToday.value.toLocaleString()} 次请求` : '今日暂无请求',
|
|
|
|
|
|
)
|
|
|
|
|
|
const errorTodayLabel = computed(() => (requestToday.value > 0 ? errorToday.value.toLocaleString() : '—'))
|
|
|
|
|
|
const errorRateLabel = computed(() => {
|
|
|
|
|
|
if (requestToday.value <= 0) return '今日暂无请求'
|
|
|
|
|
|
return `占今日请求 ${((errorToday.value / requestToday.value) * 100).toFixed(1)}%`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 公网穿透(FRP):frpc 客户端侧状态
|
|
|
|
|
|
const frpHealth = computed(() => {
|
|
|
|
|
|
const frp = frpStatus.value
|
|
|
|
|
|
if (!frp) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: '未检测',
|
|
|
|
|
|
desc: '未获取到 frpc 状态',
|
|
|
|
|
|
badgeValue: 'none',
|
|
|
|
|
|
badgeText: '未检测',
|
|
|
|
|
|
version: 'frpc 未检测',
|
|
|
|
|
|
applied: '暂无',
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!frp.configured) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: '未配置',
|
|
|
|
|
|
desc: '尚未配置 FRP 服务端',
|
|
|
|
|
|
badgeValue: 'disabled',
|
|
|
|
|
|
badgeText: '未配置',
|
|
|
|
|
|
version: 'frpc 未安装',
|
|
|
|
|
|
applied: '暂无',
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const running = frp.status === 'running'
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: `${frp.enabled_proxies} 条`,
|
|
|
|
|
|
desc: running ? 'frpc 已连接服务端' : frp.last_error ? `frpc 异常:${frp.last_error}` : 'frpc 已停止',
|
|
|
|
|
|
badgeValue: running ? 'running' : frp.status === 'stopped' ? 'stopped' : 'warning',
|
|
|
|
|
|
badgeText: running ? '运行中' : frp.status === 'stopped' ? '已停止' : '未检测',
|
|
|
|
|
|
version: frp.frpc_version ? `frpc ${frp.frpc_version}` : 'frpc 版本未知',
|
|
|
|
|
|
applied: formatRelativeTime(frp.last_reloaded_at) || '暂无',
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 公网反代部署健康:逐台服务端联网采集,单独请求(见 loadRouteHealth)
|
|
|
|
|
|
const remoteHealth = computed(() => {
|
|
|
|
|
|
if (loadingRemoteHealth.value) return { status: 'none', label: '检查中' }
|
|
|
|
|
|
const summary = routeHealth.value
|
|
|
|
|
|
if (!summary) return { status: 'none', label: '未获取' }
|
|
|
|
|
|
if (summary.servers === 0) return { status: 'disabled', label: '未配置' }
|
|
|
|
|
|
if (summary.routes === 0) return { status: 'none', label: '暂无路由' }
|
|
|
|
|
|
const allHealthy = summary.healthy === summary.routes && !summary.failed_servers?.length
|
|
|
|
|
|
return { status: allHealthy ? 'ok' : 'warning', label: allHealthy ? '全部健康' : '需关注' }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const remoteHealthMeta = computed(() => {
|
|
|
|
|
|
if (loadingRemoteHealth.value) return '正在采集公网侧状态…'
|
|
|
|
|
|
const summary = routeHealth.value
|
|
|
|
|
|
if (!summary) return '未能获取公网侧状态'
|
|
|
|
|
|
if (summary.servers === 0) return '尚未配置 FRP 服务端'
|
|
|
|
|
|
if (summary.routes === 0) return '尚无已配置域名的穿透规则'
|
|
|
|
|
|
const issues: string[] = []
|
|
|
|
|
|
if (summary.dns_failed) issues.push(`DNS ${summary.dns_failed}`)
|
|
|
|
|
|
if (summary.cert_missing) issues.push(`证书 ${summary.cert_missing}`)
|
|
|
|
|
|
if (summary.tunnel_down) issues.push(`隧道 ${summary.tunnel_down}`)
|
2026-09-25 13:05:58 +08:00
|
|
|
|
if (summary.tunnel_unknown) issues.push(`隧道无法判定 ${summary.tunnel_unknown}`)
|
2026-09-23 19:10:43 +08:00
|
|
|
|
if (summary.service_down) issues.push(`服务 ${summary.service_down}`)
|
|
|
|
|
|
const parts = [`${summary.healthy}/${summary.routes} 条健康`]
|
|
|
|
|
|
if (issues.length > 0) parts.push(issues.join(' · '))
|
|
|
|
|
|
if (summary.failed_servers?.length) parts.push(`采集失败 ${summary.failed_servers.join('、')}`)
|
|
|
|
|
|
return parts.join('|')
|
|
|
|
|
|
})
|
2026-09-23 09:40:59 +08:00
|
|
|
|
|
|
|
|
|
|
const trafficTotals = computed(() => {
|
|
|
|
|
|
let uploadRate = 0
|
|
|
|
|
|
let downloadRate = 0
|
|
|
|
|
|
for (const stats of Object.values(trafficByRule.value)) {
|
|
|
|
|
|
uploadRate += stats.upload_rate
|
|
|
|
|
|
downloadRate += stats.download_rate
|
|
|
|
|
|
}
|
|
|
|
|
|
return { uploadRate, downloadRate }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const trafficChart = computed(() => {
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
const labels: string[] = []
|
|
|
|
|
|
const upload: number[] = []
|
|
|
|
|
|
const download: number[] = []
|
|
|
|
|
|
for (let i = 5; i >= 0; i--) {
|
|
|
|
|
|
const bucketEnd = now - i * 60 * 1000
|
|
|
|
|
|
const bucketStart = bucketEnd - 60 * 1000
|
|
|
|
|
|
const points = rateHistory.value.filter((p) => p.at > bucketStart && p.at <= bucketEnd)
|
|
|
|
|
|
const avg = (key: 'upload' | 'download') =>
|
|
|
|
|
|
points.length ? points.reduce((sum, p) => sum + p[key], 0) / points.length : 0
|
|
|
|
|
|
upload.push(avg('upload'))
|
|
|
|
|
|
download.push(avg('download'))
|
|
|
|
|
|
const d = new Date(bucketEnd)
|
|
|
|
|
|
labels.push(
|
|
|
|
|
|
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return { labels, upload, download }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
// 当前流量 Top 5:直接复用已有的逐规则流量轮询,无需额外接口
|
|
|
|
|
|
const trafficTop = computed(() =>
|
|
|
|
|
|
Object.entries(trafficByRule.value)
|
|
|
|
|
|
.map(([key, stats]) => {
|
|
|
|
|
|
const id = Number(key)
|
|
|
|
|
|
const rule = proxyRules.value.find((item) => item.id === id)
|
|
|
|
|
|
const name = rule ? rule.name?.trim() || rule.domain || `规则 #${id}` : `规则 #${id}`
|
|
|
|
|
|
return { name, rate: stats.upload_rate + stats.download_rate }
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter((row) => row.rate > 0)
|
|
|
|
|
|
.sort((a, b) => b.rate - a.rate)
|
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
|
.map((row) => ({ name: row.name, rate: formatRate(row.rate) })),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
const calendarHourLabels = Array.from({ length: 12 }, (_, i) =>
|
|
|
|
|
|
`${String(i * 2).padStart(2, '0')}:00`,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const hourlyBars = computed(() => {
|
|
|
|
|
|
const hourly = status.value?.requests_hourly
|
|
|
|
|
|
const apiLabels = status.value?.requests_hourly_labels
|
|
|
|
|
|
if (hourly?.length === 12 && apiLabels?.length === 12) {
|
|
|
|
|
|
return { labels: apiLabels, values: hourly }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hourly?.length === 12) {
|
|
|
|
|
|
return { labels: calendarHourLabels, values: hourly }
|
|
|
|
|
|
}
|
|
|
|
|
|
return { labels: calendarHourLabels, values: Array(12).fill(0) }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const requestTrend = computed(() => {
|
|
|
|
|
|
const trend = status.value?.request_trend
|
|
|
|
|
|
return trend == null ? null : trend
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const healthItems = computed(() => [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'Fonu',
|
|
|
|
|
|
desc: '应用核心服务',
|
|
|
|
|
|
meta: `运行 ${formatUptime(status.value?.uptime_seconds ?? 0)}`,
|
|
|
|
|
|
status: 'running',
|
|
|
|
|
|
label: '运行中',
|
|
|
|
|
|
icon: AppsOutline,
|
|
|
|
|
|
tone: 'brand',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'Nginx',
|
|
|
|
|
|
desc: '反向代理服务',
|
|
|
|
|
|
meta: status.value?.nginx_status === 'running' ? `运行 ${formatUptime(status.value?.uptime_seconds ?? 0)}` : '已停止',
|
|
|
|
|
|
status: status.value?.nginx_status,
|
|
|
|
|
|
label: status.value?.nginx_status === 'running' ? '运行中' : '已停止',
|
|
|
|
|
|
icon: ServerOutline,
|
|
|
|
|
|
tone: 'blue',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'DDNS',
|
|
|
|
|
|
desc: '域名动态解析',
|
|
|
|
|
|
meta: formatRelativeTime(status.value?.ddns_last_updated) || '未更新',
|
|
|
|
|
|
status: status.value?.ddns_status,
|
|
|
|
|
|
label: status.value?.ddns_status === 'ok' ? '运行中' : '异常',
|
|
|
|
|
|
icon: CloudOutline,
|
|
|
|
|
|
tone: 'teal',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'HTTPS 证书',
|
|
|
|
|
|
desc: '自动续期管理',
|
|
|
|
|
|
meta: primaryCert.value ? `${primaryCert.value.days_left} 天后到期` : '未配置',
|
|
|
|
|
|
status: status.value?.certificate_status,
|
|
|
|
|
|
label: status.value?.certificate_status === 'ok' ? '运行中' : '需关注',
|
|
|
|
|
|
icon: ShieldCheckmarkOutline,
|
|
|
|
|
|
tone: 'green',
|
|
|
|
|
|
},
|
2026-09-23 19:10:43 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: '公网反代',
|
|
|
|
|
|
desc: '公网侧部署与健康',
|
|
|
|
|
|
meta: remoteHealthMeta.value,
|
|
|
|
|
|
status: remoteHealth.value.status,
|
|
|
|
|
|
label: remoteHealth.value.label,
|
|
|
|
|
|
icon: GlobeOutline,
|
|
|
|
|
|
tone: 'green',
|
|
|
|
|
|
},
|
2026-09-23 09:40:59 +08:00
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
|
|
|
|
|
|
{ title: '时间', key: 'time', width: 168, render: (r) => formatLogTime(r.time) },
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '服务',
|
|
|
|
|
|
key: 'service',
|
|
|
|
|
|
minWidth: 100,
|
|
|
|
|
|
ellipsis: { tooltip: true },
|
|
|
|
|
|
render: (r) =>
|
|
|
|
|
|
h(
|
|
|
|
|
|
'span',
|
|
|
|
|
|
{ title: accessServiceTooltip(r, proxyBindingIndex.value) },
|
|
|
|
|
|
resolveAccessServiceLabel(r, proxyBindingIndex.value),
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '方法',
|
|
|
|
|
|
key: 'method',
|
|
|
|
|
|
width: 68,
|
|
|
|
|
|
render: (r) =>
|
|
|
|
|
|
h(
|
|
|
|
|
|
NTag,
|
|
|
|
|
|
{ size: 'tiny', bordered: false, type: r.method === 'GET' ? 'info' : r.method === 'POST' ? 'success' : 'warning' },
|
|
|
|
|
|
() => r.method,
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '状态',
|
|
|
|
|
|
key: 'status',
|
|
|
|
|
|
width: 68,
|
|
|
|
|
|
render: (r) => {
|
|
|
|
|
|
const kind = httpStatusKind(r.status)
|
|
|
|
|
|
const type = kind === 'success' ? 'success' : kind === 'warning' ? 'warning' : 'error'
|
|
|
|
|
|
return h(NTag, { size: 'tiny', bordered: false, type }, () => String(r.status))
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{ title: '延迟(ms)', key: 'response_time', width: 80, render: (r) => Math.round(r.response_time * 1000) },
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '来源 IP',
|
|
|
|
|
|
key: 'client_ip',
|
|
|
|
|
|
minWidth: 120,
|
|
|
|
|
|
render: (r) => h('span', { class: 'mono ip-cell' }, r.client_ip),
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
const systemColumns: DataTableColumns<SystemLogEntry> = [
|
|
|
|
|
|
{ title: '时间', key: 'time', width: 168, render: (r) => formatLogTime(r.time) },
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '级别',
|
|
|
|
|
|
key: 'level',
|
|
|
|
|
|
width: 72,
|
|
|
|
|
|
render: (r) => {
|
|
|
|
|
|
const display = displaySystemLog(r)
|
|
|
|
|
|
return h(
|
|
|
|
|
|
NTag,
|
|
|
|
|
|
{ size: 'tiny', bordered: false, type: levelTag(display.displayLevel) },
|
|
|
|
|
|
() => display.displayLevel,
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '模块',
|
|
|
|
|
|
key: 'module',
|
|
|
|
|
|
width: 108,
|
|
|
|
|
|
render: (r) => h('span', { class: 'nowrap-cell' }, displaySystemLog(r).displayModule),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '内容',
|
|
|
|
|
|
key: 'message',
|
|
|
|
|
|
ellipsis: { tooltip: true },
|
|
|
|
|
|
render: (r) => displaySystemLog(r).message,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function levelTag(level: string) {
|
|
|
|
|
|
if (level === '错误' || level === 'ERROR') return 'error'
|
|
|
|
|
|
if (level === '警告' || level === 'WARN') return 'warning'
|
|
|
|
|
|
return 'info'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function recordRateSample() {
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
rateHistory.value.push({
|
|
|
|
|
|
at: now,
|
|
|
|
|
|
upload: trafficTotals.value.uploadRate,
|
|
|
|
|
|
download: trafficTotals.value.downloadRate,
|
|
|
|
|
|
})
|
|
|
|
|
|
const cutoff = now - RATE_HISTORY_MS
|
|
|
|
|
|
rateHistory.value = rateHistory.value.filter((p) => p.at >= cutoff)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 19:03:05 +08:00
|
|
|
|
let trafficInFlight = false
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
async function refreshTraffic() {
|
2026-09-24 19:03:05 +08:00
|
|
|
|
// 2 秒一轮且链路慢时会堆积并发请求:上一轮没回来就跳过这一轮
|
|
|
|
|
|
if (trafficInFlight) return
|
|
|
|
|
|
trafficInFlight = true
|
2026-09-23 09:40:59 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const rows = asList(await api.getProxyTraffic())
|
|
|
|
|
|
const next: Record<number, ProxyTraffic> = {}
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
|
next[row.rule_id] = row
|
|
|
|
|
|
}
|
|
|
|
|
|
trafficByRule.value = next
|
|
|
|
|
|
recordRateSample()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore polling errors
|
2026-09-24 19:03:05 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
trafficInFlight = false
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startTrafficPoll() {
|
|
|
|
|
|
stopTrafficPoll()
|
|
|
|
|
|
trafficTimer = setInterval(refreshTraffic, 2000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopTrafficPoll() {
|
|
|
|
|
|
if (trafficTimer) {
|
|
|
|
|
|
clearInterval(trafficTimer)
|
|
|
|
|
|
trafficTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
// CPU 占用率需要两次采样才有意义,因此 /api/status 每 15 秒刷新一次
|
|
|
|
|
|
async function refreshStatus() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
status.value = await api.getStatus()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 静默失败:保留上一次快照
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startStatusPoll() {
|
|
|
|
|
|
stopStatusPoll()
|
|
|
|
|
|
statusTimer = setInterval(refreshStatus, 15000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopStatusPoll() {
|
|
|
|
|
|
if (statusTimer) {
|
|
|
|
|
|
clearInterval(statusTimer)
|
|
|
|
|
|
statusTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 页面隐藏时暂停轮询,避免后台标签页持续请求
|
|
|
|
|
|
function handleVisibilityChange() {
|
|
|
|
|
|
if (document.hidden) {
|
|
|
|
|
|
stopTrafficPoll()
|
|
|
|
|
|
stopStatusPoll()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
startTrafficPoll()
|
|
|
|
|
|
startStatusPoll()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 系统资源:/api/status 返回的宿主机 CPU / 内存 / 磁盘(容器内为宿主机视角)
|
|
|
|
|
|
const resourcesAvailable = computed(
|
|
|
|
|
|
() => (status.value?.mem_total_bytes ?? 0) > 0 || (status.value?.disk_total_bytes ?? 0) > 0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
function usagePercent(used?: number, total?: number): number {
|
|
|
|
|
|
if (!used || !total || total <= 0) return 0
|
|
|
|
|
|
return Math.min(100, (used / total) * 100)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resourceItems = computed(() => {
|
|
|
|
|
|
const data = status.value
|
|
|
|
|
|
const rows = [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'CPU',
|
|
|
|
|
|
icon: AppsOutline,
|
|
|
|
|
|
tone: 'blue',
|
|
|
|
|
|
detail: '整机 CPU 占用率',
|
|
|
|
|
|
percent: Math.min(100, Math.max(0, data?.cpu_percent ?? 0)),
|
|
|
|
|
|
available: resourcesAvailable.value,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '内存',
|
|
|
|
|
|
icon: ServerOutline,
|
|
|
|
|
|
tone: 'green',
|
|
|
|
|
|
detail: `${formatBytes(data?.mem_used_bytes ?? 0)} / ${formatBytes(data?.mem_total_bytes ?? 0)}`,
|
|
|
|
|
|
percent: usagePercent(data?.mem_used_bytes, data?.mem_total_bytes),
|
|
|
|
|
|
available: (data?.mem_total_bytes ?? 0) > 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '磁盘',
|
|
|
|
|
|
icon: ListOutline,
|
|
|
|
|
|
tone: 'teal',
|
|
|
|
|
|
detail: `${formatBytes(data?.disk_used_bytes ?? 0)} / ${formatBytes(data?.disk_total_bytes ?? 0)}`,
|
|
|
|
|
|
percent: usagePercent(data?.disk_used_bytes, data?.disk_total_bytes),
|
|
|
|
|
|
available: (data?.disk_total_bytes ?? 0) > 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
return rows.map((row) => ({
|
|
|
|
|
|
...row,
|
|
|
|
|
|
value: row.available ? `${Math.round(row.percent)}%` : '—',
|
|
|
|
|
|
barWidth: `${row.percent.toFixed(1)}%`,
|
|
|
|
|
|
barTone: row.percent >= 90 ? 'danger' : row.percent >= 75 ? 'warn' : 'ok',
|
|
|
|
|
|
}))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 中国 IP 段:安全模块(china_only)依赖的国内地址库
|
|
|
|
|
|
const cidrBadge = computed(() => {
|
|
|
|
|
|
const data = chinaCIDR.value
|
|
|
|
|
|
if (!data) return { value: 'none', text: '未获取' }
|
|
|
|
|
|
if (data.last_error) return { value: 'error', text: '更新失败' }
|
|
|
|
|
|
if (!data.ready) return { value: 'warning', text: '未就绪' }
|
|
|
|
|
|
return { value: 'ok', text: '就绪' }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const cidrItems = computed(() => {
|
|
|
|
|
|
const data = chinaCIDR.value
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'IPv4 条目',
|
|
|
|
|
|
icon: GlobeOutline,
|
|
|
|
|
|
tone: 'blue',
|
|
|
|
|
|
detail: '国内 IPv4 地址段',
|
|
|
|
|
|
value: (data?.entry_count_v4 ?? 0).toLocaleString(),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'IPv6 条目',
|
|
|
|
|
|
icon: WifiOutline,
|
|
|
|
|
|
tone: 'teal',
|
|
|
|
|
|
detail: '国内 IPv6 地址段',
|
|
|
|
|
|
value: (data?.entry_count_v6 ?? 0).toLocaleString(),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '上次更新',
|
|
|
|
|
|
icon: DocumentTextOutline,
|
|
|
|
|
|
tone: 'green',
|
|
|
|
|
|
detail: data?.last_error ? `上次错误:${data.last_error}` : '按设置周期自动更新',
|
|
|
|
|
|
value: formatRelativeTime(data?.updated_at) || '从未更新',
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function loadChinaCIDR() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
chinaCIDR.value = await api.getChinaCIDRStatus()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
chinaCIDR.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 中国 IP 段更新是后端后台任务:刷新后按 2 秒轮询直到就绪或结束
|
|
|
|
|
|
let cidrPollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
|
|
|
|
|
|
|
function stopCIDRPoll() {
|
|
|
|
|
|
if (cidrPollTimer) {
|
|
|
|
|
|
clearInterval(cidrPollTimer)
|
|
|
|
|
|
cidrPollTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pollCIDR() {
|
|
|
|
|
|
await loadChinaCIDR()
|
|
|
|
|
|
const data = chinaCIDR.value
|
|
|
|
|
|
if (!data || data.ready || !data.updating) {
|
|
|
|
|
|
stopCIDRPoll()
|
|
|
|
|
|
if (data?.ready) message.success('中国 IP 段已更新')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshCIDR() {
|
|
|
|
|
|
refreshingCIDR.value = true
|
|
|
|
|
|
stopCIDRPoll()
|
|
|
|
|
|
try {
|
|
|
|
|
|
chinaCIDR.value = await api.refreshChinaCIDR()
|
|
|
|
|
|
if (chinaCIDR.value.ready) {
|
|
|
|
|
|
message.success(
|
|
|
|
|
|
`中国 IP 段已更新:IPv4 ${chinaCIDR.value.entry_count_v4} 条,IPv6 ${chinaCIDR.value.entry_count_v6} 条`,
|
|
|
|
|
|
)
|
|
|
|
|
|
} else if (chinaCIDR.value.updating) {
|
|
|
|
|
|
cidrPollTimer = setInterval(() => void pollCIDR(), 2000)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.warning('更新完成,但数据尚未就绪,请查看错误信息')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(error instanceof Error ? error.message : '更新中国 IP 段失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
refreshingCIDR.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 通知通道:设置里的 notify_type + 各通道 JSON(has_* 标记由后端保存时写入)
|
|
|
|
|
|
function parseSettingsJSON<T>(raw?: string): T | null {
|
|
|
|
|
|
if (!raw) return null
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(raw) as T
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const notifyChannel = computed(() => {
|
|
|
|
|
|
const map = settings.value ?? {}
|
|
|
|
|
|
const type = (map.notify_type ?? '') as NotifyType
|
|
|
|
|
|
const telegram = parseSettingsJSON<NotifyTelegramConfig>(map.notify_telegram_json)
|
|
|
|
|
|
const webhook = parseSettingsJSON<NotifyWebhookConfig>(map.notify_webhook_json)
|
|
|
|
|
|
const email = parseSettingsJSON<NotifyEmailConfig>(map.notify_email_json)
|
|
|
|
|
|
const events = Object.entries(map).filter(
|
|
|
|
|
|
([key, value]) => key.startsWith('notify_on_') && (value === '1' || value === 'true'),
|
|
|
|
|
|
).length
|
|
|
|
|
|
|
|
|
|
|
|
let name = '未启用'
|
|
|
|
|
|
let secret = '通知凭据'
|
|
|
|
|
|
let configured = false
|
|
|
|
|
|
if (type === 'telegram') {
|
|
|
|
|
|
name = 'Telegram'
|
|
|
|
|
|
secret = 'Bot Token'
|
|
|
|
|
|
configured = Boolean(telegram?.has_bot_token)
|
|
|
|
|
|
} else if (type === 'webhook') {
|
|
|
|
|
|
name = `Webhook(${webhook?.provider || '未选择服务商'})`
|
|
|
|
|
|
secret = 'Webhook 密钥'
|
|
|
|
|
|
configured = Boolean(webhook?.has_secret)
|
|
|
|
|
|
} else if (type === 'email') {
|
|
|
|
|
|
name = '邮件 SMTP'
|
|
|
|
|
|
secret = 'SMTP 密码'
|
|
|
|
|
|
configured = Boolean(email?.has_password)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
badge: type ? (configured ? 'ok' : 'warning') : 'disabled',
|
|
|
|
|
|
badgeText: type ? (configured ? '已启用' : '待补全') : '未启用',
|
|
|
|
|
|
rows: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '当前通道',
|
|
|
|
|
|
icon: CheckmarkCircleOutline,
|
|
|
|
|
|
tone: 'blue',
|
|
|
|
|
|
detail: type ? '告警通知的发送方式' : '尚未选择通知方式',
|
|
|
|
|
|
value: name,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '密钥凭据',
|
|
|
|
|
|
icon: ShieldCheckmarkOutline,
|
|
|
|
|
|
tone: 'green',
|
|
|
|
|
|
detail: secret,
|
|
|
|
|
|
value: configured ? '已保存' : '未保存',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '已开启告警',
|
|
|
|
|
|
icon: PulseOutline,
|
|
|
|
|
|
tone: 'teal',
|
|
|
|
|
|
detail: 'DDNS、证书、登录失败等事件',
|
|
|
|
|
|
value: `${events} 项`,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 公网侧健康需逐台服务端联网采集,单独请求,避免拖慢整页加载
|
|
|
|
|
|
async function loadRouteHealth() {
|
|
|
|
|
|
loadingRemoteHealth.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
routeHealth.value = await api.getFrpRouteHealthSummary()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
routeHealth.value = null
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingRemoteHealth.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = ''
|
|
|
|
|
|
try {
|
2026-09-23 19:10:43 +08:00
|
|
|
|
const [s, access, system, certs, rules, frp, cidr, config] = await Promise.all([
|
2026-09-23 09:40:59 +08:00
|
|
|
|
api.getStatus(),
|
|
|
|
|
|
api.getAccessLogs({ limit: 10 }),
|
|
|
|
|
|
api.getSystemLogs({ limit: 10 }),
|
|
|
|
|
|
api.listCertificates(),
|
|
|
|
|
|
api.listProxies().catch((): ProxyRule[] => []),
|
2026-09-23 19:10:43 +08:00
|
|
|
|
api.getFrpStatus().catch((): FrpStatus | null => null),
|
|
|
|
|
|
api.getChinaCIDRStatus().catch((): ChinaCIDRStatus | null => null),
|
|
|
|
|
|
api.getSettings().catch((): SettingsMap | null => null),
|
2026-09-23 09:40:59 +08:00
|
|
|
|
])
|
|
|
|
|
|
status.value = s
|
|
|
|
|
|
accessLogs.value = asList(access)
|
|
|
|
|
|
proxyRules.value = asList(rules)
|
|
|
|
|
|
systemLogs.value = asList(system)
|
|
|
|
|
|
certificates.value = asList(certs)
|
2026-09-23 19:10:43 +08:00
|
|
|
|
frpStatus.value = frp
|
|
|
|
|
|
chinaCIDR.value = cidr
|
|
|
|
|
|
settings.value = config
|
|
|
|
|
|
void loadRouteHealth()
|
2026-09-23 09:40:59 +08:00
|
|
|
|
api.listDDNSLite()
|
|
|
|
|
|
.then((ddns) => {
|
|
|
|
|
|
ddnsConfigs.value = asList(ddns)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
|
|
|
|
|
|
message.error('加载仪表盘失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
load()
|
|
|
|
|
|
refreshTraffic()
|
|
|
|
|
|
startTrafficPoll()
|
2026-09-23 19:10:43 +08:00
|
|
|
|
startStatusPoll()
|
|
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
2026-09-23 09:40:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
stopTrafficPoll()
|
|
|
|
|
|
stopStatusPoll()
|
|
|
|
|
|
stopCIDRPoll()
|
|
|
|
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
|
|
|
|
})
|
2026-09-23 09:40:59 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.hero-banner {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
margin-bottom: var(--fonu-space-4);
|
|
|
|
|
|
min-height: 208px;
|
|
|
|
|
|
height: 220px;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
box-shadow: var(--fonu-shadow);
|
|
|
|
|
|
border: 1px solid rgba(16, 185, 129, 0.08);
|
|
|
|
|
|
background: linear-gradient(100deg, #f4fffc 0%, #f1fbff 55%, #eef9ff 100%);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html[data-theme='dark'] .hero-banner,
|
|
|
|
|
|
html.dark .hero-banner {
|
|
|
|
|
|
background: linear-gradient(100deg, #0f172a 0%, #134e4a 100%);
|
|
|
|
|
|
border-color: rgba(16, 185, 129, 0.15);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-content {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
z-index: 2;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 46%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
padding: 28px 32px;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-art {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
z-index: 1;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
width: auto;
|
|
|
|
|
|
max-width: none;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
object-position: right bottom;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html[data-theme='dark'] .hero-art,
|
|
|
|
|
|
html.dark .hero-art {
|
|
|
|
|
|
opacity: 0.9;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner__title {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 30px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
letter-spacing: -0.03em;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner__desc {
|
|
|
|
|
|
margin: 10px 0 0;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
max-width: 520px;
|
|
|
|
|
|
line-height: 1.65;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner__badges {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner__badge {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
padding: 6px 12px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
background: var(--fonu-surface);
|
|
|
|
|
|
border: 1px solid rgba(16, 185, 129, 0.14);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner__badge .n-icon {
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
color: var(--fonu-brand);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.hero-content {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 22px 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner {
|
|
|
|
|
|
height: auto;
|
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-art {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
opacity: 0.28;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html[data-theme='dark'] .hero-art,
|
|
|
|
|
|
html.dark .hero-art {
|
|
|
|
|
|
opacity: 0.22;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner__title {
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hero-banner__badges {
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats-row {
|
|
|
|
|
|
display: grid;
|
2026-09-23 19:10:43 +08:00
|
|
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
2026-09-23 09:40:59 +08:00
|
|
|
|
gap: var(--fonu-space-4);
|
|
|
|
|
|
margin-bottom: var(--fonu-space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-primary {
|
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-desc {
|
|
|
|
|
|
margin: 8px 0 0;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-foot {
|
|
|
|
|
|
margin-top: var(--fonu-space-3);
|
|
|
|
|
|
padding-top: var(--fonu-space-3);
|
|
|
|
|
|
border-top: 1px solid var(--fonu-border);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-foot__line {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-foot__muted {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-panel {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-panel :deep(.fonu-card__body) {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.requests-panel__body,
|
|
|
|
|
|
.traffic-panel__body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-chart {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 240px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.requests-panel .panel-chart :deep(.chart),
|
|
|
|
|
|
.traffic-panel .panel-chart :deep(.chart) {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
min-height: 240px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-rates {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--fonu-space-4);
|
|
|
|
|
|
margin-bottom: var(--fonu-space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-rate {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-rate__dot {
|
|
|
|
|
|
width: 8px;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-rate--up .traffic-rate__dot {
|
|
|
|
|
|
background: #10b981;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-rate--down .traffic-rate__dot {
|
|
|
|
|
|
background: #3b82f6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-rate__label {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-rate__value {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.traffic-top {
|
|
|
|
|
|
margin-top: var(--fonu-space-3);
|
|
|
|
|
|
padding-top: var(--fonu-space-3);
|
|
|
|
|
|
border-top: 1px solid var(--fonu-border);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-top__title {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-top__list {
|
|
|
|
|
|
margin: 6px 0 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-top__item {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
|
|
|
|
gap: var(--fonu-space-3);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-top__name {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.traffic-top__rate {
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ops-row {
|
|
|
|
|
|
display: grid;
|
2026-09-25 13:05:58 +08:00
|
|
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
2026-09-23 19:10:43 +08:00
|
|
|
|
gap: var(--fonu-space-4);
|
|
|
|
|
|
margin-bottom: var(--fonu-space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
.metrics-row {
|
|
|
|
|
|
display: grid;
|
2026-09-23 19:10:43 +08:00
|
|
|
|
grid-template-columns: 3fr 3fr 2fr 2fr;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
gap: var(--fonu-space-4);
|
|
|
|
|
|
margin-bottom: var(--fonu-space-4);
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 13:05:58 +08:00
|
|
|
|
/* 趋势卡片占两列:这样「性能」才能跟「系统资源」等一起排到下一行的四列里 */
|
|
|
|
|
|
.trend-card { grid-column: span 2; }
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
.metrics-row > .fonu-card {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel :deep(.fonu-card__body) {
|
|
|
|
|
|
padding-top: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel__head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
padding: var(--fonu-space-5) var(--fonu-space-5) 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel__body {
|
|
|
|
|
|
padding: var(--fonu-space-3) var(--fonu-space-5) var(--fonu-space-5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-title {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-title__icon {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
color: var(--fonu-brand);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-title__icon--amber {
|
|
|
|
|
|
color: #f59e0b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panel-title__icon--teal {
|
|
|
|
|
|
color: #14b8a6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-25 13:05:58 +08:00
|
|
|
|
/* 趋势卡片表头里的时间档切换(对齐「实时流量」的 range-tag 位置) */
|
|
|
|
|
|
.trend-range { display: flex; gap: 2px; }
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
.range-tag {
|
|
|
|
|
|
background: var(--fonu-bg) !important;
|
|
|
|
|
|
color: var(--fonu-text-secondary) !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.metric-hero {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
|
gap: var(--fonu-space-3);
|
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.metric-hero__value {
|
|
|
|
|
|
font-size: 34px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
letter-spacing: -0.03em;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
line-height: 1.1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.requests-panel {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.requests-panel :deep(.fonu-card__body) {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-panel {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-panel :deep(.fonu-card__body) {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
/* 性能面板的行沿用「运行状态」的 .health-item,但不随面板高度被拉高(该面板只有两项指标) */
|
|
|
|
|
|
.perf-list {
|
2026-09-23 09:40:59 +08:00
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
justify-content: center;
|
2026-09-23 19:10:43 +08:00
|
|
|
|
gap: 8px;
|
|
|
|
|
|
min-height: 0;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.perf-list .health-item {
|
|
|
|
|
|
flex: 0 0 auto;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.perf-value {
|
|
|
|
|
|
justify-self: end;
|
|
|
|
|
|
font-size: 16px;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--fonu-text);
|
2026-09-23 19:10:43 +08:00
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
white-space: nowrap;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.perf-value small {
|
|
|
|
|
|
margin-left: 2px;
|
|
|
|
|
|
font-size: 12px;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.perf-value--error {
|
2026-09-23 09:40:59 +08:00
|
|
|
|
color: var(--fonu-error);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.head-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--fonu-space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.res-bar {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
height: 4px;
|
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
|
border-radius: 2px;
|
2026-09-23 09:40:59 +08:00
|
|
|
|
background: var(--fonu-border);
|
2026-09-23 19:10:43 +08:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.res-bar__fill {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
background: var(--fonu-brand);
|
|
|
|
|
|
transition: width 0.3s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.res-bar--warn .res-bar__fill {
|
|
|
|
|
|
background: var(--fonu-warning);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.res-bar--danger .res-bar__fill {
|
|
|
|
|
|
background: var(--fonu-error);
|
2026-09-23 09:40:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-panel .panel__head {
|
|
|
|
|
|
padding: var(--fonu-space-3) var(--fonu-space-4) 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-panel .panel__body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
padding: var(--fonu-space-2) var(--fonu-space-4) var(--fonu-space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 36px minmax(0, 1fr) 76px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 64px;
|
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
|
border-radius: var(--fonu-radius-sm);
|
|
|
|
|
|
background: var(--fonu-bg);
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__icon {
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 17px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__icon--brand {
|
|
|
|
|
|
background: var(--fonu-brand-soft);
|
|
|
|
|
|
color: var(--fonu-brand);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__icon--blue {
|
|
|
|
|
|
background: rgba(59, 130, 246, 0.12);
|
|
|
|
|
|
color: #3b82f6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__icon--teal {
|
|
|
|
|
|
background: rgba(20, 184, 166, 0.12);
|
|
|
|
|
|
color: #14b8a6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__icon--green {
|
|
|
|
|
|
background: rgba(16, 185, 129, 0.12);
|
|
|
|
|
|
color: #10b981;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.health-item__icon--error {
|
|
|
|
|
|
background: color-mix(in srgb, var(--fonu-error) 12%, transparent);
|
|
|
|
|
|
color: var(--fonu-error);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 09:40:59 +08:00
|
|
|
|
.health-item__main {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item :deep(.status-badge) {
|
|
|
|
|
|
justify-self: end;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__name {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--fonu-text);
|
|
|
|
|
|
line-height: 1.2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__meta {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.health-item__detail {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: var(--fonu-text-muted);
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ip-cell {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-row {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
gap: var(--fonu-space-4);
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card :deep(.fonu-card__body) {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
padding-left: 0;
|
|
|
|
|
|
padding-right: 0;
|
|
|
|
|
|
padding-bottom: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card :deep(.fonu-card__header) {
|
|
|
|
|
|
padding-bottom: var(--fonu-space-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card__body {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
height: 392px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card__body :deep(.bottom-table) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-wrapper) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-base-table) {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
table-layout: fixed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-th),
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-td) {
|
|
|
|
|
|
padding-left: 12px;
|
|
|
|
|
|
padding-right: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-th:first-child),
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-td:first-child) {
|
|
|
|
|
|
padding-left: var(--fonu-space-5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-th:last-child),
|
|
|
|
|
|
.bottom-card__body :deep(.n-data-table-td:last-child) {
|
|
|
|
|
|
padding-right: var(--fonu-space-5);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nowrap-cell {
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-title-icon {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
color: var(--fonu-brand);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-link {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--fonu-text-secondary);
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-link:hover {
|
|
|
|
|
|
color: var(--fonu-brand);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.mono {
|
|
|
|
|
|
font-family: var(--fonu-mono);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1199px) {
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
2026-09-23 09:40:59 +08:00
|
|
|
|
.metrics-row { grid-template-columns: 1fr 1fr; }
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.ops-row { grid-template-columns: 1fr 1fr; }
|
2026-09-23 09:40:59 +08:00
|
|
|
|
.bottom-row { grid-template-columns: 1fr; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 767px) {
|
|
|
|
|
|
.stats-row { grid-template-columns: 1fr; }
|
|
|
|
|
|
.metrics-row { grid-template-columns: 1fr; }
|
2026-09-23 19:10:43 +08:00
|
|
|
|
.ops-row { grid-template-columns: 1fr; }
|
2026-09-23 09:40:59 +08:00
|
|
|
|
.health-panel { grid-column: auto; }
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|