mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
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:
parent
c3293bca82
commit
76f627ac65
4 changed files with 442 additions and 56 deletions
87
frontend/src/pages/index/BackupModal.vue
Normal file
87
frontend/src/pages/index/BackupModal.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
160
frontend/src/pages/index/LogModal.vue
Normal file
160
frontend/src/pages/index/LogModal.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
99
frontend/src/pages/index/PanelUpdateModal.vue
Normal file
99
frontend/src/pages/index/PanelUpdateModal.vue
Normal 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>
|
||||
Loading…
Reference in a new issue