mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
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:
parent
76f627ac65
commit
c44f25ec1f
5 changed files with 675 additions and 5 deletions
252
frontend/src/components/Sparkline.vue
Normal file
252
frontend/src/components/Sparkline.vue
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: { type: Array, required: true },
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
vbWidth: { type: Number, default: 320 },
|
||||||
|
height: { type: Number, default: 80 },
|
||||||
|
stroke: { type: String, default: '#008771' },
|
||||||
|
strokeWidth: { type: Number, default: 2 },
|
||||||
|
maxPoints: { type: Number, default: 120 },
|
||||||
|
showGrid: { type: Boolean, default: true },
|
||||||
|
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
|
||||||
|
fillOpacity: { type: Number, default: 0.15 },
|
||||||
|
showMarker: { type: Boolean, default: true },
|
||||||
|
markerRadius: { type: Number, default: 2.8 },
|
||||||
|
showAxes: { type: Boolean, default: false },
|
||||||
|
yTickStep: { type: Number, default: 25 },
|
||||||
|
tickCountX: { type: Number, default: 4 },
|
||||||
|
paddingLeft: { type: Number, default: 32 },
|
||||||
|
paddingRight: { type: Number, default: 6 },
|
||||||
|
paddingTop: { type: Number, default: 6 },
|
||||||
|
paddingBottom: { type: Number, default: 20 },
|
||||||
|
showTooltip: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoverIdx = ref(-1);
|
||||||
|
|
||||||
|
const viewBoxAttr = computed(() => `0 0 ${props.vbWidth} ${props.height}`);
|
||||||
|
const drawWidth = computed(() => Math.max(1, props.vbWidth - props.paddingLeft - props.paddingRight));
|
||||||
|
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
||||||
|
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
||||||
|
|
||||||
|
const dataSlice = computed(() => {
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (n === 0) return [];
|
||||||
|
return props.data.slice(props.data.length - n);
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelsSlice = computed(() => {
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (!props.labels?.length || n === 0) return [];
|
||||||
|
const start = Math.max(0, props.labels.length - n);
|
||||||
|
return props.labels.slice(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pointsArr = computed(() => {
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (n === 0) return [];
|
||||||
|
const slice = dataSlice.value;
|
||||||
|
const w = drawWidth.value;
|
||||||
|
const h = drawHeight.value;
|
||||||
|
const dx = n > 1 ? w / (n - 1) : 0;
|
||||||
|
return slice.map((v, i) => {
|
||||||
|
const x = Math.round(props.paddingLeft + i * dx);
|
||||||
|
const y = Math.round(props.paddingTop + (h - (Math.max(0, Math.min(100, v)) / 100) * h));
|
||||||
|
return [x, y];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
|
||||||
|
|
||||||
|
const areaPath = computed(() => {
|
||||||
|
if (pointsArr.value.length === 0) return '';
|
||||||
|
const first = pointsArr.value[0];
|
||||||
|
const last = pointsArr.value[pointsArr.value.length - 1];
|
||||||
|
const baseY = props.paddingTop + drawHeight.value;
|
||||||
|
const line = pointsStr.value.replace(/ /g, ' L ');
|
||||||
|
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridLines = computed(() => {
|
||||||
|
if (!props.showGrid) return [];
|
||||||
|
const h = drawHeight.value;
|
||||||
|
const w = drawWidth.value;
|
||||||
|
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||||
|
const y = Math.round(props.paddingTop + h * r);
|
||||||
|
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastPoint = computed(() => {
|
||||||
|
if (pointsArr.value.length === 0) return null;
|
||||||
|
return pointsArr.value[pointsArr.value.length - 1];
|
||||||
|
});
|
||||||
|
|
||||||
|
const yTicks = computed(() => {
|
||||||
|
if (!props.showAxes) return [];
|
||||||
|
const step = Math.max(1, props.yTickStep);
|
||||||
|
const out = [];
|
||||||
|
for (let p = 0; p <= 100; p += step) {
|
||||||
|
const y = Math.round(props.paddingTop + (drawHeight.value - (p / 100) * drawHeight.value));
|
||||||
|
out.push({ y, label: `${p}%` });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const xTicks = computed(() => {
|
||||||
|
if (!props.showAxes) return [];
|
||||||
|
const labels = labelsSlice.value;
|
||||||
|
const n = nPoints.value;
|
||||||
|
if (n === 0) return [];
|
||||||
|
const m = Math.max(2, props.tickCountX);
|
||||||
|
const w = drawWidth.value;
|
||||||
|
const dx = n > 1 ? w / (n - 1) : 0;
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const idx = Math.round((i * (n - 1)) / (m - 1));
|
||||||
|
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
|
||||||
|
const x = Math.round(props.paddingLeft + idx * dx);
|
||||||
|
out.push({ x, label });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onMouseMove(evt) {
|
||||||
|
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
||||||
|
const rect = evt.currentTarget.getBoundingClientRect();
|
||||||
|
const px = evt.clientX - rect.left;
|
||||||
|
const x = (px / rect.width) * props.vbWidth;
|
||||||
|
const n = nPoints.value;
|
||||||
|
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
||||||
|
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
||||||
|
hoverIdx.value = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
hoverIdx.value = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHoverText() {
|
||||||
|
const idx = hoverIdx.value;
|
||||||
|
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
||||||
|
const raw = Math.max(0, Math.min(100, Number(dataSlice.value[idx] || 0)));
|
||||||
|
const val = Number.isFinite(raw) ? raw.toFixed(2) : raw;
|
||||||
|
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
|
||||||
|
return `${val}%${lab ? ' • ' + lab : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable per-instance gradient id so multiple sparklines on a page
|
||||||
|
// don't clobber each other's <defs id="spkGrad">.
|
||||||
|
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
width="100%"
|
||||||
|
:height="height"
|
||||||
|
:viewBox="viewBoxAttr"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
class="sparkline-svg"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
|
||||||
|
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g v-if="showGrid">
|
||||||
|
<line
|
||||||
|
v-for="(g, i) in gridLines"
|
||||||
|
:key="i"
|
||||||
|
:x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2"
|
||||||
|
:stroke="gridColor" stroke-width="1"
|
||||||
|
class="cpu-grid-line"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-if="showAxes">
|
||||||
|
<text
|
||||||
|
v-for="(t, i) in yTicks"
|
||||||
|
:key="'y' + i"
|
||||||
|
class="cpu-grid-y-text"
|
||||||
|
:x="Math.max(0, paddingLeft - 4)"
|
||||||
|
:y="t.y + 4"
|
||||||
|
text-anchor="end"
|
||||||
|
font-size="10"
|
||||||
|
>{{ t.label }}</text>
|
||||||
|
<text
|
||||||
|
v-for="(t, i) in xTicks"
|
||||||
|
:key="'x' + i"
|
||||||
|
class="cpu-grid-x-text"
|
||||||
|
:x="t.x"
|
||||||
|
:y="paddingTop + drawHeight + 22"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="10"
|
||||||
|
>{{ t.label }}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
|
||||||
|
<polyline
|
||||||
|
:points="pointsStr"
|
||||||
|
fill="none"
|
||||||
|
:stroke="stroke"
|
||||||
|
:stroke-width="strokeWidth"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
v-if="showMarker && lastPoint"
|
||||||
|
:cx="lastPoint[0]" :cy="lastPoint[1]"
|
||||||
|
:r="markerRadius"
|
||||||
|
:fill="stroke"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
|
||||||
|
<line
|
||||||
|
class="cpu-grid-h-line"
|
||||||
|
:x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]"
|
||||||
|
:y1="paddingTop" :y2="paddingTop + drawHeight"
|
||||||
|
stroke="rgba(0,0,0,0.2)" stroke-width="1"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
:cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]"
|
||||||
|
r="3.5" :fill="stroke"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
class="cpu-grid-text"
|
||||||
|
:x="pointsArr[hoverIdx][0]"
|
||||||
|
:y="paddingTop + 12"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="11"
|
||||||
|
>{{ fmtHoverText() }}</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sparkline-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu-grid-y-text,
|
||||||
|
.cpu-grid-x-text {
|
||||||
|
fill: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.cpu-grid-text {
|
||||||
|
fill: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.dark) .sparkline-svg .cpu-grid-y-text,
|
||||||
|
:global(body.dark) .sparkline-svg .cpu-grid-x-text {
|
||||||
|
fill: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
:global(body.dark) .sparkline-svg .cpu-grid-text {
|
||||||
|
fill: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
frontend/src/pages/index/CpuHistoryModal.vue
Normal file
100
frontend/src/pages/index/CpuHistoryModal.vue
Normal file
|
|
@ -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>
|
||||||
|
|
@ -13,6 +13,9 @@ import XrayStatusCard from './XrayStatusCard.vue';
|
||||||
import PanelUpdateModal from './PanelUpdateModal.vue';
|
import PanelUpdateModal from './PanelUpdateModal.vue';
|
||||||
import LogModal from './LogModal.vue';
|
import LogModal from './LogModal.vue';
|
||||||
import BackupModal from './BackupModal.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.
|
// Drive AD-Vue 4's built-in dark algorithm from our reactive theme.
|
||||||
const antdThemeConfig = computed(() => ({
|
const antdThemeConfig = computed(() => ({
|
||||||
|
|
@ -45,6 +48,9 @@ const requestUri = window.location.pathname;
|
||||||
const logsOpen = ref(false);
|
const logsOpen = ref(false);
|
||||||
const backupOpen = ref(false);
|
const backupOpen = ref(false);
|
||||||
const panelUpdateOpen = 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.
|
// Page-level loading overlay; modals can request it via @busy.
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
@ -64,11 +70,9 @@ async function restartXray() {
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal-button stubs that aren't ported yet — keep wired so buttons
|
function openCpuHistory() { cpuHistoryOpen.value = true; }
|
||||||
// don't appear broken; full implementations come in 5c-iv-b / -v.
|
function openXrayLogs() { xrayLogsOpen.value = true; }
|
||||||
function openCpuHistory() { /* CPU history sparkline — 5c-iv-b */ }
|
function openVersionSwitch() { versionOpen.value = true; }
|
||||||
function openXrayLogs() { /* xray-logs viewer — 5c-iv-b */ }
|
|
||||||
function openVersionSwitch() { /* xray version picker — 5c-iv-b */ }
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -144,6 +148,9 @@ function openVersionSwitch() { /* xray version picker — 5c-iv-b */ }
|
||||||
:base-path="basePath"
|
:base-path="basePath"
|
||||||
@busy="setBusy"
|
@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-layout>
|
||||||
</a-config-provider>
|
</a-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
137
frontend/src/pages/index/VersionModal.vue
Normal file
137
frontend/src/pages/index/VersionModal.vue
Normal 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
frontend/src/pages/index/XrayLogModal.vue
Normal file
174
frontend/src/pages/index/XrayLogModal.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
Loading…
Reference in a new issue