feat(frontend): Phase 5c-ii — live status cards on the dashboard

Adds the CPU / memory / swap / disk dashboard cards to IndexPage,
backed by a useStatus() composable that polls /panel/api/server/status
every 2 s and a Status / CurTotal model ported from the legacy inline
classes in index.html.

- models/status.js — Status & CurTotal classes (CurTotal exposes
  reactive .percent and .color computed-style getters; Status maps
  the API payload + xray state to color/message strings)
- composables/useStatus.js — 2s polling with shallowRef so each fetch
  swaps the whole Status object atomically. WebSocket integration
  intentionally deferred — the legacy panel falls back to this same
  2s polling when its websocket drops, so we ship the proven path
  first and add WS on top in a later sub-phase.
- pages/index/StatusCard.vue — four a-progress dashboard widgets in
  a 2x2 grid (mobile collapses to a 1x4). CPU widget exposes a
  history button; the modal it opens is part of 5c-iv.
- IndexPage now consumes both, plus useMediaQuery so the layout
  responds to viewport changes.

AD-Vue 4 changes: <a-icon type="area-chart"|"history"> dropped in
favor of explicit AreaChartOutlined / HistoryOutlined imports.
<a-tooltip slot="title"> → <template #title>.

i18n strings still hardcoded English (Phase 7 wires up vue-i18n).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 12:31:55 +02:00
parent e24e70dde2
commit c2fd5bc1da
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 252 additions and 28 deletions

View file

@ -0,0 +1,43 @@
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { HttpUtil } from '@/utils';
import { Status } from '@/models/status.js';
const POLL_INTERVAL_MS = 2000;
// Polls /panel/api/server/status and exposes a reactive Status object
// + a `fetched` flag so consumers can show a spinner before the first
// successful fetch.
//
// WebSocket integration is intentionally deferred to a later sub-phase.
// Polling at 2s is the same fallback the legacy panel falls back to
// when its websocket link drops, so we're shipping the proven path
// first and adding the websocket on top later.
export function useStatus() {
const status = shallowRef(new Status());
const fetched = ref(false);
let timer = null;
async function refresh() {
try {
const msg = await HttpUtil.get('/panel/api/server/status');
if (msg?.success) {
status.value = new Status(msg.obj);
if (!fetched.value) fetched.value = true;
}
} catch (e) {
console.error('Failed to get status:', e);
}
}
onMounted(() => {
refresh();
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
});
onBeforeUnmount(() => {
if (timer != null) window.clearInterval(timer);
});
return { status, fetched, refresh };
}

View file

@ -0,0 +1,76 @@
import { NumberFormatter } from '@/utils';
export class CurTotal {
constructor(current, total) {
this.current = current;
this.total = total;
}
get percent() {
if (this.total === 0) return 0;
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
}
get color() {
const p = this.percent;
if (p < 80) return '#008771'; // green
if (p < 90) return '#f37b24'; // orange
return '#cf3c3c'; // red
}
}
const XRAY_STATE_COLORS = {
running: 'green',
stop: 'orange',
error: 'red',
};
const XRAY_STATE_MESSAGES = {
running: 'Xray is running',
stop: 'Xray is stopped',
error: 'Xray error',
};
export class Status {
constructor(data) {
this.cpu = new CurTotal(0, 0);
this.cpuCores = 0;
this.logicalPro = 0;
this.cpuSpeedMhz = 0;
this.disk = new CurTotal(0, 0);
this.loads = [0, 0, 0];
this.mem = new CurTotal(0, 0);
this.netIO = { up: 0, down: 0 };
this.netTraffic = { sent: 0, recv: 0 };
this.publicIP = { ipv4: 0, ipv6: 0 };
this.swap = new CurTotal(0, 0);
this.tcpCount = 0;
this.udpCount = 0;
this.uptime = 0;
this.appUptime = 0;
this.appStats = { threads: 0, mem: 0, uptime: 0 };
this.xray = { state: 'stop', stateMsg: '', errorMsg: '', version: '', color: '' };
if (data == null) return;
this.cpu = new CurTotal(data.cpu, 100);
this.cpuCores = data.cpuCores;
this.logicalPro = data.logicalPro;
this.cpuSpeedMhz = data.cpuSpeedMhz;
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
this.netIO = data.netIO ?? this.netIO;
this.netTraffic = data.netTraffic ?? this.netTraffic;
this.publicIP = data.publicIP ?? this.publicIP;
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
this.tcpCount = data.tcpCount ?? 0;
this.udpCount = data.udpCount ?? 0;
this.uptime = data.uptime ?? 0;
this.appUptime = data.appUptime ?? 0;
this.appStats = data.appStats ?? this.appStats;
this.xray = { ...this.xray, ...(data.xray || {}) };
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
this.xray.stateMsg = XRAY_STATE_MESSAGES[this.xray.state] ?? 'Unknown';
}
}

View file

@ -1,26 +1,30 @@
<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { theme as antdTheme } from 'ant-design-vue';
import { theme as themeState } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import StatusCard from './StatusCard.vue';
// Drive AD-Vue 4's built-in dark algorithm from our reactive theme.
const antdThemeConfig = computed(() => ({
algorithm: themeState.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}));
// Phase 5c-i ships the page shell only sidebar, layout, theme.
// Real content (CPU/mem/swap/disk cards, Xray status card, panel
// update modal, logs, custom-geo section) follows in 5c-ii through
// 5c-iv. Loading state is currently a placeholder true so the shell
// renders; it will be wired to the real /server/status fetch later.
const fetched = ref(true);
const { status, fetched } = useStatus();
const { isMobile } = useMediaQuery();
// In production the Go panel injects basePath + requestUri into the
// served HTML; during `npm run dev` we infer them from window.location.
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
function onOpenCpuHistory() {
// CPU-history modal is part of Phase 5c-iv. Leaving the emit wired
// so the button isn't dead-clickable; no-op until then.
}
</script>
<template>
@ -33,18 +37,23 @@ const requestUri = window.location.pathname;
<a-spin :spinning="!fetched" :delay="200" size="large">
<div v-if="!fetched" class="loading-spacer" />
<div v-else class="page-body">
<a-card hoverable>
<a-space direction="vertical" :size="12" style="width: 100%">
<h2 style="margin: 0">Dashboard (vue3-migration shell)</h2>
<p style="margin: 0; opacity: 0.7">
Phase 5c-i: layout, sidebar, and theme switching wired up.
Status cards, xray controls, and custom-geo arrive in
follow-up commits.
</p>
</a-space>
</a-card>
</div>
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<a-col :span="24">
<StatusCard :status="status" :is-mobile="isMobile" @open-cpu-history="onOpenCpuHistory" />
</a-col>
<a-col :span="24">
<a-card hoverable>
<a-space direction="vertical" :size="8" style="width: 100%">
<h3 style="margin: 0">Dashboard scaffold</h3>
<p style="margin: 0; opacity: 0.7">
Phase 5c-ii adds the live status cards above (CPU / memory / swap / disk).
Xray status, panel update modal, logs, and the custom-geo section
arrive in 5c-iii through 5c-v.
</p>
</a-space>
</a-card>
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
@ -54,7 +63,6 @@ const requestUri = window.location.pathname;
<style scoped>
.index-page {
/* Same legacy palette source as the login page. */
--bg-page: #f0f2f5;
--bg-card: #ffffff;
@ -63,13 +71,13 @@ const requestUri = window.location.pathname;
}
.index-page.is-dark {
--bg-page: #0a1222; /* legacy --dark-color-background */
--bg-card: #151f31; /* legacy --dark-color-surface-100 */
--bg-page: #0a1222;
--bg-card: #151f31;
}
.index-page.is-dark.is-ultra {
--bg-page: #21242a; /* legacy ultra --dark-color-background */
--bg-card: #0c0e12; /* legacy ultra surface-100 */
--bg-page: #21242a;
--bg-card: #0c0e12;
}
.index-page :deep(.ant-layout),
@ -88,8 +96,4 @@ const requestUri = window.location.pathname;
.loading-spacer {
min-height: calc(100vh - 120px);
}
.page-body :deep(.ant-card) {
background: var(--bg-card);
}
</style>

View file

@ -0,0 +1,101 @@
<script setup>
import { AreaChartOutlined, HistoryOutlined } from '@ant-design/icons-vue';
import { CPUFormatter, SizeFormatter } from '@/utils';
defineProps({
status: { type: Object, required: true },
isMobile: { type: Boolean, default: false },
});
defineEmits(['open-cpu-history']);
</script>
<template>
<a-card hoverable>
<a-row :gutter="[0, isMobile ? 16 : 0]">
<!-- CPU + Memory -->
<a-col :sm="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.cpu.color"
:percent="status.cpu.percent"
/>
<div>
<b>CPU:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
<a-tooltip>
<template #title>
<div><b>Logical processors:</b> {{ status.logicalPro }}</div>
<div><b>Frequency:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}</div>
</template>
<AreaChartOutlined />
</a-tooltip>
<a-tooltip>
<template #title>CPU history</template>
<a-button size="small" shape="circle" class="ml-8" @click="$emit('open-cpu-history')">
<template #icon><HistoryOutlined /></template>
</a-button>
</a-tooltip>
</div>
</a-col>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.mem.color"
:percent="status.mem.percent"
/>
<div>
<b>Memory:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
{{ SizeFormatter.sizeFormat(status.mem.total) }}
</div>
</a-col>
</a-row>
</a-col>
<!-- Swap + Disk -->
<a-col :sm="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.swap.color"
:percent="status.swap.percent"
/>
<div>
<b>Swap:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
{{ SizeFormatter.sizeFormat(status.swap.total) }}
</div>
</a-col>
<a-col :span="12" class="text-center">
<a-progress
type="dashboard"
status="normal"
:stroke-color="status.disk.color"
:percent="status.disk.percent"
/>
<div>
<b>Storage:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
{{ SizeFormatter.sizeFormat(status.disk.total) }}
</div>
</a-col>
</a-row>
</a-col>
</a-row>
</a-card>
</template>
<style scoped>
.text-center {
text-align: center;
}
.ml-8 {
margin-left: 8px;
}
</style>