feat(frontend): Phase 5c-iv (b) — cpu-history / xray-logs / xray-version modals

Wires up the three remaining dashboard buttons that were stubbed in
5c-iv (a): the CPU history button on StatusCard, the xray-logs button
in XrayStatusCard's error popover and ipLimitEnable action, and the
"Switch xray" button in XrayStatusCard's action footer.

- Sparkline.vue: shared SVG line chart (composition-API port of the
  inline Vue 2 component). Per-instance gradient id avoids defs
  collisions between sparklines on the same page.
- CpuHistoryModal.vue: bucket dropdown (2m/30m/1h/2h/3h/5h) drives
  GET /panel/api/server/cpuHistory/{bucket}; renders via Sparkline.
- XrayLogModal.vue: rows + filter + direct/blocked/proxy checkboxes;
  POST /panel/api/server/xraylogs/{rows} returns access-log entries
  rendered as a colored HTML table; download button serializes to text.
- VersionModal.vue: collapse with Xray panel (radio list of versions
  from getXrayVersion, install via installXray/{version}) and Geofiles
  panel (per-file reload + Update all). CustomGeo collapse panel is
  Phase 5c-v.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei
2026-05-08 12:56:08 +02:00
parent 76f627ac65
commit c44f25ec1f
5 changed files with 675 additions and 5 deletions
@@ -0,0 +1,100 @@
<script setup>
import { ref, watch } from 'vue';
import { HttpUtil } from '@/utils';
import Sparkline from '@/components/Sparkline.vue';
const props = defineProps({
open: { type: Boolean, default: false },
status: { type: Object, required: true },
});
const emit = defineEmits(['update:open']);
// Bucket size in seconds per data point — matches legacy options.
const bucket = ref(2);
const points = ref([]);
const labels = ref([]);
async function fetchBucket() {
try {
const msg = await HttpUtil.get(`/panel/api/server/cpuHistory/${bucket.value}`);
if (msg?.success && Array.isArray(msg.obj)) {
const vals = [];
const labs = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
vals.push(Math.max(0, Math.min(100, p.cpu)));
}
labels.value = labs;
points.value = vals;
}
} catch (e) {
console.error('Failed to fetch bucketed cpu history', e);
}
}
function close() {
emit('update:open', false);
}
watch(() => props.open, (next) => { if (next) fetchBucket(); });
watch(bucket, () => { if (props.open) fetchBucket(); });
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" width="900px" @cancel="close">
<template #title>
CPU history
<a-select v-model:value="bucket" size="small" class="bucket-select">
<a-select-option :value="2">2m</a-select-option>
<a-select-option :value="30">30m</a-select-option>
<a-select-option :value="60">1h</a-select-option>
<a-select-option :value="120">2h</a-select-option>
<a-select-option :value="180">3h</a-select-option>
<a-select-option :value="300">5h</a-select-option>
</a-select>
</template>
<div class="cpu-chart-wrap">
<Sparkline
:data="points"
:labels="labels"
:vb-width="840"
:height="220"
:stroke="status?.cpu?.color || '#008771'"
:stroke-width="2.2"
:show-grid="true"
:show-axes="true"
:tick-count-x="5"
:max-points="points.length || 1"
:fill-opacity="0.18"
:marker-radius="3.2"
:show-tooltip="true"
/>
<div class="cpu-chart-meta">
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
</div>
</div>
</a-modal>
</template>
<style scoped>
.bucket-select {
width: 80px;
margin-left: 10px;
}
.cpu-chart-wrap {
padding: 16px;
}
.cpu-chart-meta {
margin-top: 4px;
font-size: 11px;
opacity: 0.65;
}
</style>
+12 -5
View File
@@ -13,6 +13,9 @@ import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue';
import LogModal from './LogModal.vue';
import BackupModal from './BackupModal.vue';
import CpuHistoryModal from './CpuHistoryModal.vue';
import XrayLogModal from './XrayLogModal.vue';
import VersionModal from './VersionModal.vue';
// Drive AD-Vue 4's built-in dark algorithm from our reactive theme.
const antdThemeConfig = computed(() => ({
@@ -45,6 +48,9 @@ const requestUri = window.location.pathname;
const logsOpen = ref(false);
const backupOpen = ref(false);
const panelUpdateOpen = ref(false);
const cpuHistoryOpen = ref(false);
const xrayLogsOpen = ref(false);
const versionOpen = ref(false);
// Page-level loading overlay; modals can request it via @busy.
const loading = ref(false);
@@ -64,11 +70,9 @@ async function restartXray() {
await refresh();
}
// Modal-button stubs that aren't ported yet — keep wired so buttons
// don't appear broken; full implementations come in 5c-iv-b / -v.
function openCpuHistory() { /* CPU history sparkline — 5c-iv-b */ }
function openXrayLogs() { /* xray-logs viewer — 5c-iv-b */ }
function openVersionSwitch() { /* xray version picker — 5c-iv-b */ }
function openCpuHistory() { cpuHistoryOpen.value = true; }
function openXrayLogs() { xrayLogsOpen.value = true; }
function openVersionSwitch() { versionOpen.value = true; }
</script>
<template>
@@ -144,6 +148,9 @@ function openVersionSwitch() { /* xray version picker — 5c-iv-b */ }
:base-path="basePath"
@busy="setBusy"
/>
<CpuHistoryModal v-model:open="cpuHistoryOpen" :status="status" />
<XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
</a-layout>
</a-config-provider>
</template>
+137
View File
@@ -0,0 +1,137 @@
<script setup>
import { ref, watch } from 'vue';
import { Modal } from 'ant-design-vue';
import { ReloadOutlined } from '@ant-design/icons-vue';
import { HttpUtil } from '@/utils';
const props = defineProps({
open: { type: Boolean, default: false },
status: { type: Object, required: true },
});
const emit = defineEmits(['update:open', 'busy']);
const activeKey = ref('1');
const versions = ref([]);
const loading = ref(false);
// Geofiles list is hardcoded in the legacy panel — same set of files
// served from /panel/api/server/updateGeofile/{name}.
const GEOFILES = ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat'];
async function fetchVersions() {
loading.value = true;
try {
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
if (msg?.success) versions.value = msg.obj || [];
} finally {
loading.value = false;
}
}
function close() {
emit('update:open', false);
}
function switchXrayVersion(version) {
Modal.confirm({
title: 'Switch xray version',
content: `Are you sure you want to install ${version}? This will restart xray.`,
okText: 'Confirm',
cancelText: 'Cancel',
onOk: async () => {
close();
emit('busy', { busy: true, tip: `Installing ${version}` });
try {
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
} finally {
emit('busy', { busy: false });
}
},
});
}
function updateGeofile(fileName) {
const isSingle = !!fileName;
Modal.confirm({
title: 'Update geofile',
content: isSingle
? `Update ${fileName}? Xray will restart after the file is replaced.`
: 'Update all geofiles? Xray will restart after the files are replaced.',
okText: 'Confirm',
cancelText: 'Cancel',
onOk: async () => {
close();
emit('busy', { busy: true, tip: 'Updating geofiles…' });
const url = isSingle
? `/panel/api/server/updateGeofile/${fileName}`
: '/panel/api/server/updateGeofile';
try {
await HttpUtil.post(url);
} finally {
emit('busy', { busy: false });
}
},
});
}
watch(() => props.open, (next) => { if (next) fetchVersions(); });
</script>
<template>
<a-modal :open="open" title="Xray updates" :closable="true" :footer="null" @cancel="close">
<a-spin :spinning="loading">
<a-collapse v-model:active-key="activeKey" accordion>
<a-collapse-panel key="1" header="Xray">
<a-alert
type="warning"
class="mb-12"
message="Click a version to install it. Xray will restart automatically."
show-icon
/>
<a-list bordered class="version-list">
<a-list-item v-for="(version, index) in versions" :key="version" class="version-list-item">
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ version }}</a-tag>
<a-radio
:checked="version === `v${status?.xray?.version}`"
@click="switchXrayVersion(version)"
/>
</a-list-item>
</a-list>
</a-collapse-panel>
<a-collapse-panel key="2" header="Geofiles">
<a-list bordered class="version-list">
<a-list-item v-for="(file, index) in GEOFILES" :key="file" class="version-list-item">
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ file }}</a-tag>
<a-tooltip title="Update this file">
<ReloadOutlined class="reload-icon" @click="updateGeofile(file)" />
</a-tooltip>
</a-list-item>
</a-list>
<div class="actions-row">
<a-button @click="updateGeofile('')">Update all</a-button>
</div>
</a-collapse-panel>
</a-collapse>
</a-spin>
</a-modal>
</template>
<style scoped>
.mb-12 { margin-bottom: 12px; }
.version-list { width: 100%; }
.version-list-item { display: flex; justify-content: space-between; align-items: center; }
.reload-icon {
cursor: pointer;
font-size: 16px;
margin-right: 8px;
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
const props = defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open']);
const rows = ref('20');
const filter = ref('');
const showDirect = ref(true);
const showBlocked = ref(true);
const showProxy = ref(true);
const loading = ref(false);
const logs = ref([]);
function escapeHtml(value) {
if (value == null) return '';
return String(value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Renders a `<table>` with one row per log entry. Event 1 = blocked
// (red); Event 2 = proxy (blue); Event 0 = direct.
function formatLogs(lines) {
let out = '<table class="xraylog-table"><tr>'
+ '<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>'
+ '</tr>';
// Reverse a copy — the legacy code mutated state with `.reverse()`.
[...lines].reverse().forEach((log) => {
let rowStyle = '';
if (log.Event === 1) rowStyle = ' style="color: #e04141;"';
else if (log.Event === 2) rowStyle = ' style="color: #3c89e8;"';
const emailCell = log.Email ? `<td>${escapeHtml(log.Email)}</td>` : '<td></td>';
out += `<tr${rowStyle}>`
+ `<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime))}</b></td>`
+ `<td>${escapeHtml(log.FromAddress)}</td>`
+ `<td>${escapeHtml(log.ToAddress)}</td>`
+ `<td>${escapeHtml(log.Inbound)}</td>`
+ `<td>${escapeHtml(log.Outbound)}</td>`
+ emailCell
+ '</tr>';
});
return out + '</table>';
}
const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
async function refresh() {
loading.value = true;
try {
const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows.value}`, {
filter: filter.value,
showDirect: showDirect.value,
showBlocked: showBlocked.value,
showProxy: showProxy.value,
});
if (msg?.success) logs.value = msg.obj || [];
await PromiseUtil.sleep(300);
} finally {
loading.value = false;
}
}
function close() {
emit('update:open', false);
}
function download() {
if (!Array.isArray(logs.value) || logs.value.length === 0) {
FileManager.downloadTextFile('', 'x-ui.log');
return;
}
const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
const lines = logs.value.map((l) => {
try {
const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
const eventText = eventMap[l.Event] || String(l.Event ?? '');
const emailPart = l.Email ? ` Email=${l.Email}` : '';
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
} catch (_e) {
return JSON.stringify(l);
}
}).join('\n');
FileManager.downloadTextFile(lines, 'x-ui.log');
}
watch(() => props.open, (next) => { if (next) refresh(); });
watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" width="80vw" @cancel="close">
<template #title>
Xray logs
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
</template>
<a-form layout="inline">
<a-form-item>
<a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
<a-select-option value="10">10</a-select-option>
<a-select-option value="20">20</a-select-option>
<a-select-option value="50">50</a-select-option>
<a-select-option value="100">100</a-select-option>
<a-select-option value="500">500</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Filter">
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="showDirect">Direct</a-checkbox>
<a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
<a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
</a-form-item>
<a-form-item style="margin-left: auto">
<a-button type="primary" @click="download">
<template #icon><DownloadOutlined /></template>
</a-button>
</a-form-item>
</a-form>
<div class="log-container" v-html="formattedLogs" />
</a-modal>
</template>
<style scoped>
.reload-icon {
cursor: pointer;
vertical-align: middle;
margin-left: 10px;
}
.log-container {
margin-top: 12px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
max-height: 60vh;
overflow: auto;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
}
:global(body.dark) .log-container {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
</style>
<style>
/* Global so the v-html'd table picks up these styles. */
.xraylog-table {
border-collapse: collapse;
width: auto;
}
.xraylog-table td,
.xraylog-table th {
padding: 2px 15px;
}
</style>