mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
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:
parent
e24e70dde2
commit
c2fd5bc1da
4 changed files with 252 additions and 28 deletions
43
frontend/src/composables/useStatus.js
Normal file
43
frontend/src/composables/useStatus.js
Normal 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 };
|
||||
}
|
||||
76
frontend/src/models/status.js
Normal file
76
frontend/src/models/status.js
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
101
frontend/src/pages/index/StatusCard.vue
Normal file
101
frontend/src/pages/index/StatusCard.vue
Normal 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>
|
||||
Loading…
Reference in a new issue