feat(frontend): Phase 5c-iv (a) — panel update / logs / backup modals

Adds three of the six dashboard modals plus a Quick Actions card
that surfaces them. The remaining three (xray logs, version picker,
CPU history sparkline) ship in 5c-iv-b.

- PanelUpdateModal.vue — current vs latest version, "update now"
  button. Confirm dialog → POST /panel/api/server/updatePanel,
  then poll /server/status for up to 90s until the new panel
  answers, then reload.
- LogModal.vue — panel logs viewer. Filters: rows (10-500), level
  (debug/info/notice/warning/error), syslog toggle. Auto-fetches
  on open and on every filter change. Color-coded timestamps and
  levels via inline span styles. Download button writes the raw
  log to x-ui.log via FileManager.downloadTextFile.
- BackupModal.vue — db export (window.location to /getDb) and
  import (FormData upload to /importDB, then panel restart + reload).
- Quick Actions card surfaces Logs / Backup / Update buttons and
  shows an orange update badge (extra slot) when an update is
  available.

Modal-busy pattern: long-running operations (update, import) emit
a `busy` event with a tip; IndexPage flips its a-spin overlay so the
user sees a loading message while the panel is restarting.

AD-Vue 4 changes:
- v-model on <a-modal> renamed to v-model:open
- v-model on <a-input>/<a-select>/<a-checkbox> uses the named
  v-model:value / v-model:checked pattern
- <a-icon type="..."> dropped — explicit Ant icon imports
  (BarsOutlined, CloudServerOutlined, CloudDownloadOutlined,
  DownloadOutlined, UploadOutlined, SyncOutlined)
- Modal.confirm() replaces this.$confirm() since setup() has no `this`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 12:45:30 +02:00
parent c3293bca82
commit 76f627ac65
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
4 changed files with 442 additions and 56 deletions

View file

@ -0,0 +1,87 @@
<script setup>
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { HttpUtil, PromiseUtil } from '@/utils';
defineProps({
open: { type: Boolean, default: false },
basePath: { type: String, default: '' },
});
const emit = defineEmits(['update:open', 'busy']);
function close() {
emit('update:open', false);
}
function exportDb() {
// The Go endpoint streams x-ui.db as a download. Setting
// window.location triggers a browser download without leaving
// the page (the Go side responds with Content-Disposition: attachment).
window.location = '/panel/api/server/getDb';
}
function importDb() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.db';
fileInput.addEventListener('change', async (e) => {
const dbFile = e.target.files?.[0];
if (!dbFile) return;
const formData = new FormData();
formData.append('db', dbFile);
close();
emit('busy', { busy: true, tip: 'Importing database…' });
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (!upload?.success) {
emit('busy', { busy: false });
return;
}
emit('busy', { busy: true, tip: 'Restarting panel…' });
const restart = await HttpUtil.post('/panel/setting/restartPanel');
if (restart?.success) {
await PromiseUtil.sleep(5000);
window.location.reload();
} else {
emit('busy', { busy: false });
}
});
fileInput.click();
}
</script>
<template>
<a-modal :open="open" title="Database backup & restore" :closable="true" :footer="null" @cancel="close">
<a-list bordered class="backup-list">
<a-list-item class="backup-item">
<a-list-item-meta>
<template #title>Back up</template>
<template #description>Click to download a .db file containing a backup of your current database to your device.</template>
</a-list-item-meta>
<a-button type="primary" @click="exportDb">
<template #icon><DownloadOutlined /></template>
</a-button>
</a-list-item>
<a-list-item class="backup-item">
<a-list-item-meta>
<template #title>Restore</template>
<template #description>Click to upload a .db file. The panel restarts after restore your session will reconnect automatically.</template>
</a-list-item-meta>
<a-button type="primary" @click="importDb">
<template #icon><UploadOutlined /></template>
</a-button>
</a-list-item>
</a-list>
</a-modal>
</template>
<style scoped>
.backup-list { width: 100%; }
.backup-item { display: flex; align-items: center; gap: 16px; }
</style>

View file

@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { theme as antdTheme } from 'ant-design-vue';
import { BarsOutlined, CloudServerOutlined, CloudDownloadOutlined } from '@ant-design/icons-vue';
import { HttpUtil } from '@/utils';
import { theme as themeState } from '@/composables/useTheme.js';
@ -9,6 +10,9 @@ import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import StatusCard from './StatusCard.vue';
import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue';
import LogModal from './LogModal.vue';
import BackupModal from './BackupModal.vue';
// Drive AD-Vue 4's built-in dark algorithm from our reactive theme.
const antdThemeConfig = computed(() => ({
@ -25,49 +29,46 @@ HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
if (msg?.success && msg.obj) ipLimitEnable.value = !!msg.obj.ipLimitEnable;
});
// In production the Go panel injects basePath + requestUri into the
// served HTML; during `npm run dev` we infer them from window.location.
// Panel-update info fetched once on mount, drives both the badge
// in QuickActions and the contents of PanelUpdateModal.
const panelUpdateInfo = ref({ currentVersion: '', latestVersion: '', updateAvailable: false });
onMounted(() => {
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) panelUpdateInfo.value = msg.obj;
});
});
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
const busy = ref(false);
// Modal open state.
const logsOpen = ref(false);
const backupOpen = ref(false);
const panelUpdateOpen = ref(false);
// Page-level loading overlay; modals can request it via @busy.
const loading = ref(false);
const loadingTip = ref('Loading…');
function setBusy({ busy, tip }) {
loading.value = busy;
if (tip) loadingTip.value = tip;
}
// Xray controls
async function stopXray() {
busy.value = true;
try {
await HttpUtil.post('/panel/api/server/stopXrayService');
await refresh();
} finally {
busy.value = false;
}
await HttpUtil.post('/panel/api/server/stopXrayService');
await refresh();
}
async function restartXray() {
busy.value = true;
try {
await HttpUtil.post('/panel/api/server/restartXrayService');
await refresh();
} finally {
busy.value = false;
}
await HttpUtil.post('/panel/api/server/restartXrayService');
await refresh();
}
function onOpenCpuHistory() {
// CPU-history modal is part of Phase 5c-iv. Wired emit so the
// button isn't dead-clickable; no-op until that phase ships.
}
function onOpenLogs() {
// Panel-logs modal Phase 5c-iv.
}
function onOpenXrayLogs() {
// Xray-logs modal Phase 5c-iv.
}
function onOpenVersionSwitch() {
// Xray version-picker modal Phase 5c-iv.
}
// 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 */ }
</script>
<template>
@ -77,13 +78,14 @@ function onOpenVersionSwitch() {
<a-layout class="content-shell">
<a-layout-content class="content-area">
<a-spin :spinning="!fetched" :delay="200" size="large">
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : 'Loading…'" size="large">
<div v-if="!fetched" class="loading-spacer" />
<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" />
<StatusCard :status="status" :is-mobile="isMobile" @open-cpu-history="openCpuHistory" />
</a-col>
<a-col :sm="24" :lg="12">
<XrayStatusCard
:status="status"
@ -91,27 +93,57 @@ function onOpenVersionSwitch() {
:ip-limit-enable="ipLimitEnable"
@stop-xray="stopXray"
@restart-xray="restartXray"
@open-xray-logs="onOpenXrayLogs"
@open-logs="onOpenLogs"
@open-version-switch="onOpenVersionSwitch"
@open-xray-logs="openXrayLogs"
@open-logs="logsOpen = true"
@open-version-switch="openVersionSwitch"
/>
</a-col>
<a-col :sm="24" :lg="12">
<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-iii wires the xray status card on the left.
Panel update modal, logs / xray-logs / backup, and the
custom-geo section arrive in 5c-iv and 5c-v.
</p>
</a-space>
<a-card title="Quick actions" hoverable>
<template v-if="panelUpdateInfo.updateAvailable" #extra>
<a-tooltip :title="`Update panel: ${panelUpdateInfo.latestVersion}`">
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
<CloudDownloadOutlined />
{{ panelUpdateInfo.latestVersion }}
<span v-if="!isMobile">Update</span>
</a-tag>
</a-tooltip>
</template>
<template #actions>
<a-space class="action" @click="logsOpen = true">
<BarsOutlined />
<span v-if="!isMobile">Logs</span>
</a-space>
<a-space class="action" @click="backupOpen = true">
<CloudServerOutlined />
<span v-if="!isMobile">Backup</span>
</a-space>
<a-space class="action" @click="panelUpdateOpen = true">
<CloudDownloadOutlined />
<span v-if="!isMobile">
{{ panelUpdateInfo.updateAvailable ? `Update → ${panelUpdateInfo.latestVersion}` : 'Up to date' }}
</span>
</a-space>
</template>
</a-card>
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<PanelUpdateModal
v-model:open="panelUpdateOpen"
:info="panelUpdateInfo"
@busy="setBusy"
/>
<LogModal v-model:open="logsOpen" />
<BackupModal
v-model:open="backupOpen"
:base-path="basePath"
@busy="setBusy"
/>
</a-layout>
</a-config-provider>
</template>
@ -140,15 +172,23 @@ function onOpenVersionSwitch() {
background: transparent;
}
.content-shell {
background: transparent;
}
.content-area {
padding: 24px;
}
.content-shell { background: transparent; }
.content-area { padding: 24px; }
.loading-spacer {
min-height: calc(100vh - 120px);
}
.action {
cursor: pointer;
justify-content: center;
}
.update-tag {
cursor: pointer;
margin: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>

View file

@ -0,0 +1,160 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
const props = defineProps({
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open']);
const rows = ref('20');
const level = ref('info');
const syslog = ref(false);
const loading = ref(false);
const logs = ref([]);
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
const LEVEL_COLORS = ['#3c89e8', '#008771', '#008771', '#f37b24', '#e04141', '#bcbcbc'];
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;');
}
function formatLogs(lines) {
// Each line: "YYYY-MM-DD HH:MM:SS LEVEL - message"
// Color the timestamp + level prefix and bold the originating service.
let out = '';
lines.forEach((log, idx) => {
const [data, message] = log.split(' - ', 2);
const parts = data.split(' ');
if (idx > 0) out += '<br>';
if (parts.length === 3) {
const d = escapeHtml(parts[0]);
const t = escapeHtml(parts[1]);
const levelRaw = parts[2];
const li = LEVELS.indexOf(levelRaw);
const levelIndex = li >= 0 ? li : 5;
out += `<span style="color: ${LEVEL_COLORS[0]};">${d} ${t}</span> `;
out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(levelRaw)}</span>`;
} else {
const li = LEVELS.indexOf(data);
const levelIndex = li >= 0 ? li : 5;
out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(data)}</span>`;
}
if (message) {
const prefix = message.startsWith('XRAY:') ? '<b>XRAY: </b>' : '<b>X-UI: </b>';
const tail = message.startsWith('XRAY:') ? message.substring(5) : message;
out += ' - ' + prefix + escapeHtml(tail);
}
});
return out;
}
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/logs/${rows.value}`, {
level: level.value,
syslog: syslog.value,
});
if (msg?.success) {
logs.value = msg.obj || [];
}
// Keep the spinner visible long enough that rapid filter changes
// feel intentional rather than flickery.
await PromiseUtil.sleep(300);
} finally {
loading.value = false;
}
}
function close() {
emit('update:open', false);
}
function download() {
FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
}
// Re-fetch whenever the modal opens or any filter changes.
watch(() => props.open, (next) => { if (next) refresh(); });
watch([rows, level, syslog], () => { if (props.open) refresh(); });
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" width="800px" @cancel="close">
<template #title>
Logs
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
</template>
<a-form layout="inline">
<a-form-item>
<a-input-group compact>
<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-select v-model:value="level" size="small" :style="{ width: '95px' }">
<a-select-option value="debug">Debug</a-select-option>
<a-select-option value="info">Info</a-select-option>
<a-select-option value="notice">Notice</a-select-option>
<a-select-option value="warning">Warning</a-select-option>
<a-select-option value="err">Error</a-select-option>
</a-select>
</a-input-group>
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="syslog">SysLog</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;
white-space: pre-wrap;
word-break: break-word;
max-height: 60vh;
overflow-y: 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>

View file

@ -0,0 +1,99 @@
<script setup>
import { Modal, message } from 'ant-design-vue';
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
import { HttpUtil, PromiseUtil } from '@/utils';
import axios from 'axios';
const props = defineProps({
open: { type: Boolean, default: false },
info: {
type: Object,
default: () => ({ currentVersion: '', latestVersion: '', updateAvailable: false }),
},
});
const emit = defineEmits(['update:open', 'busy']);
function close() {
emit('update:open', false);
}
function updatePanel() {
Modal.confirm({
title: 'Update panel',
content: `The panel will be updated to ${props.info.latestVersion || ''} and restarted. Continue?`,
okText: 'Confirm',
cancelText: 'Cancel',
onOk: async () => {
const tip = props.info.latestVersion
? `Installation in progress, please do not refresh (${props.info.latestVersion})`
: 'Installation in progress, please do not refresh';
close();
emit('busy', { busy: true, tip });
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
if (!msg?.success) {
emit('busy', { busy: false });
return;
}
// Wait for the running process to exit, then poll the new panel
// until it answers (up to ~90s). Reload as soon as it's back.
await PromiseUtil.sleep(5000);
const deadline = Date.now() + 90_000;
let back = false;
while (Date.now() < deadline) {
try {
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
if (r?.data?.success) { back = true; break; }
} catch (_) { /* still restarting */ }
await PromiseUtil.sleep(2000);
}
if (back) {
message.success('Panel update started');
await PromiseUtil.sleep(800);
}
window.location.reload();
},
});
}
</script>
<template>
<a-modal :open="open" title="Update panel" :closable="true" :footer="null" @cancel="close">
<a-alert
v-if="info.updateAvailable"
type="warning"
class="mb-12"
message="A new panel version is available. Update will restart the service."
show-icon
/>
<a-list bordered class="version-list">
<a-list-item class="version-list-item">
<span>Current version</span>
<a-tag color="green">v{{ info.currentVersion || 'unknown' }}</a-tag>
</a-list-item>
<a-list-item v-if="info.updateAvailable" class="version-list-item">
<span>Latest version</span>
<a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
</a-list-item>
<a-list-item v-else class="version-list-item">
<span>Panel is up to date</span>
<a-tag color="green">Up to date</a-tag>
</a-list-item>
</a-list>
<div class="actions-row">
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
<template #icon><CloudDownloadOutlined /></template>
Update panel
</a-button>
</div>
</a-modal>
</template>
<style scoped>
.mb-12 { margin-bottom: 12px; }
.version-list { width: 100%; }
.version-list-item { display: flex; justify-content: space-between; }
.actions-row { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>