3x-ui/web/html/index.html
copilot-swe-agent[bot] 10fb9dfa29
refactor: simplify GetLastStatus nil check and failsafe condition per review
Agent-Logs-Url: https://github.com/xAlokyx/3x-ui/sessions/dd7a9d3c-fddb-4521-9fcf-26bb342d408c

Co-authored-by: xAlokyx <234771438+xAlokyx@users.noreply.github.com>
2026-04-08 16:55:16 +00:00

1211 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' index-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
</a-alert>
</transition>
<transition name="list" appear>
<template>
<a-row v-if="!loadingStates.fetched">
<a-card class="card-placeholder text-center">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
<a-card hoverable>
<a-row :gutter="[0, isMobile ? 16 : 0]">
<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"></a-progress>
<div>
<b>{{ i18n "pages.index.cpu" }}:</b> [[ CPUFormatter.cpuCoreFormat(status.cpuCores) ]]
<a-tooltip>
<a-icon type="area-chart"></a-icon>
<template slot="title">
<div><b>{{ i18n "pages.index.logicalProcessors" }}:</b> [[ (status.logicalPro) ]]</div>
<div><b>{{ i18n "pages.index.frequency" }}:</b> [[
CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) ]]</div>
</template>
</a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<a-button size="small" shape="circle" class="ml-8" @click="openCpuHistory()">
<a-icon type="history" />
</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"></a-progress>
<div>
<b>{{ i18n "pages.index.memory"}}:</b> [[ SizeFormatter.sizeFormat(status.mem.current) ]] /
[[ SizeFormatter.sizeFormat(status.mem.total) ]]
</div>
</a-col>
</a-row>
</a-col>
<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"></a-progress>
<div>
<b>{{ i18n "pages.index.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"></a-progress>
<div>
<b>{{ i18n "pages.index.storage"}}:</b> [[ SizeFormatter.sizeFormat(status.disk.current) ]]
/ [[ SizeFormatter.sizeFormat(status.disk.total) ]]
</div>
</a-col>
</a-row>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card hoverable>
<template #title>
<a-space direction="horizontal">
<span>{{ i18n "pages.index.xrayStatus" }}</span>
<a-tag v-if="isMobile && status.xray.version != 'Unknown'" color="green">
v[[ status.xray.version ]]
</a-tag>
</a-space>
</template>
<template #extra>
<template v-if="status.xray.state != 'error'">
<a-badge status="processing"
:class="({ green: 'xray-running-animation', orange: 'xray-stop-animation' }[status.xray.color]) || 'xray-processing-animation'"
:text="status.xray.stateMsg" :color="status.xray.color" />
</template>
<template v-else>
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<span slot="title">
<a-row type="flex" align="middle" justify="space-between">
<a-col>
<span>{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
</a-col>
<a-col>
<a-icon type="bars" class="cursor-pointer float-right" @click="openLogs()"></a-icon>
</a-col>
</a-row>
</span>
<template slot="content">
<span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
</template>
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
</a-popover>
</template>
</template>
<template #actions>
<a-space v-if="app.ipLimitEnable" direction="horizontal" @click="openXrayLogs()" class="jc-center">
<a-icon type="bars"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
</a-space>
<a-space direction="horizontal" @click="stopXrayService" class="jc-center">
<a-icon type="poweroff"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.stopXray" }}</span>
</a-space>
<a-space direction="horizontal" @click="restartXrayService" class="jc-center">
<a-icon type="reload"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.restartXray" }}</span>
</a-space>
<a-space direction="horizontal" @click="openSelectV2rayVersion" class="jc-center">
<a-icon type="tool"></a-icon>
<span v-if="!isMobile">
[[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n
"pages.index.xraySwitch" }}' ]]
</span>
</a-space>
</template>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "menu.link" }}' hoverable>
<template #actions>
<a-space direction="horizontal" @click="openLogs()" class="jc-center">
<a-icon type="bars"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.logs" }}</span>
</a-space>
<a-space direction="horizontal" @click="openConfig" class="jc-center">
<a-icon type="control"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.config" }}</span>
</a-space>
<a-space direction="horizontal" @click="openBackup" class="jc-center">
<a-icon type="cloud-server"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.backup" }}</span>
</a-space>
</template>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='3X-UI' hoverable>
<a rel="noopener" href="https://github.com/MHSanaei/3x-ui/releases" target="_blank">
<a-tag color="green">
<span>v{{ .cur_ver }}</span>
</a-tag>
</a>
<a rel="noopener" href="https://t.me/XrayUI" target="_blank">
<a-tag color="green">
<span>@XrayUI</span>
</a-tag>
</a>
<a rel="noopener" href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank">
<a-tag color="purple">
<span>{{ i18n "pages.index.documentation" }}</span>
</a-tag>
</a>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "pages.index.operationHours" }}' hoverable>
<a-tag :color="status.xray.color">Xray: [[ TimeFormatter.formatSecond(status.appStats.uptime)
]]</a-tag>
<a-tag color="green">OS: [[ TimeFormatter.formatSecond(status.uptime) ]]</a-tag>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "pages.index.systemLoad" }}' hoverable>
<a-tag color="green">
<a-tooltip>
[[ status.loads[0] ]] | [[ status.loads[1] ]] | [[ status.loads[2] ]]
<template slot="title">
{{ i18n "pages.index.systemLoadDesc" }}
</template>
</a-tooltip>
</a-tag>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "usage"}}' hoverable>
<a-tag color="green"> {{ i18n "pages.index.memory" }}: [[
SizeFormatter.sizeFormat(status.appStats.mem) ]] </a-tag>
<a-tag color="green"> {{ i18n "pages.index.threads" }}: [[ status.appStats.threads ]] </a-tag>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "pages.index.overallSpeed" }}' hoverable>
<a-row :gutter="isMobile ? [8,8] : 0">
<a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.upload" }}'
:value="SizeFormatter.bitFormat(status.netIO.up)">
<template #prefix>
<a-icon type="arrow-up" />
</template>
<template #suffix>
/s
</template>
</a-custom-statistic>
</a-col>
<a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.download" }}'
:value="SizeFormatter.bitFormat(status.netIO.down)">
<template #prefix>
<a-icon type="arrow-down" />
</template>
<template #suffix>
/s
</template>
</a-custom-statistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "pages.index.totalData" }}' hoverable>
<a-row :gutter="isMobile ? [8,8] : 0">
<a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.sent" }}'
:value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
<template #prefix>
<a-icon type="cloud-upload" />
</template>
</a-custom-statistic>
</a-col>
<a-col :span="12">
<a-custom-statistic title='{{ i18n "pages.index.received" }}'
:value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
<template #prefix>
<a-icon type="cloud-download" />
</template>
</a-custom-statistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "pages.index.ipAddresses" }}' hoverable>
<template #extra>
<a-tooltip :placement="isMobile ? 'topRight' : 'top'">
<template #title>
{{ i18n "pages.index.toggleIpVisibility" }}
</template>
<a-icon :type="showIp ? 'eye' : 'eye-invisible'" class="fs-1rem"
@click="showIp = !showIp"></a-icon>
</a-tooltip>
</template>
<a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8,8] : 0">
<a-col :span="isMobile ? 24 : 12">
<a-custom-statistic title="IPv4" :value="status.publicIP.ipv4">
<template #prefix>
<a-icon type="global" />
</template>
</a-custom-statistic>
</a-col>
<a-col :span="isMobile ? 24 : 12">
<a-custom-statistic title="IPv6" :value="status.publicIP.ipv6">
<template #prefix>
<a-icon type="global" />
</template>
</a-custom-statistic>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='{{ i18n "pages.index.connectionCount" }}' hoverable>
<a-row :gutter="isMobile ? [8,8] : 0">
<a-col :span="12">
<a-custom-statistic title="TCP" :value="status.tcpCount">
<template #prefix>
<a-icon type="swap" />
</template>
</a-custom-statistic>
</a-col>
<a-col :span="12">
<a-custom-statistic title="UDP" :value="status.udpCount">
<template #prefix>
<a-icon type="swap" />
</template>
</a-custom-statistic>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</template>
</transition>
</a-spin>
</a-layout-content>
</a-layout>
<a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}'
:closable="true" @ok="() => versionModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header='Xray'>
<a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.xraySwitchClickDesk" }}'
show-icon></a-alert>
<a-list class="ant-version-list w-100" bordered>
<a-list-item class="ant-version-list-item" v-for="version, index in versionModal.versions">
<a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ version ]]</a-tag>
<a-radio :class="themeSwitcher.currentTheme" :checked="version === `v${status.xray.version}`"
@click="switchV2rayVersion(version)"></a-radio>
</a-list-item>
</a-list>
</a-collapse-panel>
<a-collapse-panel key="2" header='Geofiles'>
<a-list class="ant-version-list w-100" bordered>
<a-list-item class="ant-version-list-item"
v-for="file, index in ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat']">
<a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ file ]]</a-tag>
<a-icon type="reload" @click="updateGeofile(file)" class="mr-8" />
</a-list-item>
</a-list>
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
"pages.index.geofilesUpdateAll" }}</a-button></div>
</a-collapse-panel>
</a-collapse>
</a-modal>
<a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
:class="themeSwitcher.currentTheme" width="800px" footer="">
<template slot="title">
{{ i18n "pages.index.logs" }}
<a-icon :spin="logModal.loading" type="sync" class="va-middle ml-10" :disabled="logModal.loading"
@click="openLogs()">
</a-icon>
</template>
<a-form layout="inline">
<a-form-item class="mr-05">
<a-input-group compact>
<a-select size="small" v-model="logModal.rows" :style="{ width: '70px' }" @change="openLogs()"
:dropdown-class-name="themeSwitcher.currentTheme">
<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 size="small" v-model="logModal.level" :style="{ width: '95px' }" @change="openLogs()"
:dropdown-class-name="themeSwitcher.currentTheme">
<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="logModal.syslog" @change="openLogs()">SysLog</a-checkbox>
</a-form-item>
<a-form-item style="float: right;">
<a-button type="primary" icon="download" @click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
</a-form-item>
</a-form>
<div class="ant-input log-container" v-html="logModal.formattedLogs"></div>
</a-modal>
<a-modal id="xraylog-modal" v-model="xraylogModal.visible" :closable="true"
@cancel="() => xraylogModal.visible = false" :class="themeSwitcher.currentTheme" width="80vw" footer="">
<template slot="title">
{{ i18n "pages.index.logs" }}
<a-icon :spin="xraylogModal.loading" type="sync" class="va-middle ml-10" :disabled="xraylogModal.loading"
@click="openXrayLogs()">
</a-icon>
</template>
<a-form layout="inline">
<a-form-item class="mr-05">
<a-input-group compact>
<a-select size="small" v-model="xraylogModal.rows" :style="{ width: '70px' }" @change="openXrayLogs()"
:dropdown-class-name="themeSwitcher.currentTheme">
<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-input-group>
</a-form-item>
<a-form-item label="Filter:">
<a-input size="small" v-model="xraylogModal.filter" @keyup.enter="openXrayLogs()"></a-input>
</a-form-item>
<a-form-item>
<a-checkbox v-model="xraylogModal.showDirect" @change="openXrayLogs()">Direct</a-checkbox>
<a-checkbox v-model="xraylogModal.showBlocked" @change="openXrayLogs()">Blocked</a-checkbox>
<a-checkbox v-model="xraylogModal.showProxy" @change="openXrayLogs()">Proxy</a-checkbox>
</a-form-item>
<a-form-item style="float: right;">
<a-button type="primary" icon="download" @click="downloadXrayLogs"></a-button>
</a-form-item>
</a-form>
<div class="ant-input log-container" v-html="xraylogModal.formattedLogs"></div>
</a-modal>
<a-modal id="backup-modal" v-model="backupModal.visible" title='{{ i18n "pages.index.backupTitle" }}' :closable="true"
footer="" :class="themeSwitcher.currentTheme">
<a-list class="ant-backup-list w-100" bordered>
<a-list-item class="ant-backup-list-item">
<a-list-item-meta>
<template #title>{{ i18n "pages.index.exportDatabase" }}</template>
<template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template>
</a-list-item-meta>
<a-button @click="exportDatabase()" type="primary" icon="download" />
</a-list-item>
<a-list-item class="ant-backup-list-item">
<a-list-item-meta>
<template #title>{{ i18n "pages.index.importDatabase" }}</template>
<template #description>{{ i18n "pages.index.importDatabaseDesc" }}</template>
</a-list-item-meta>
<a-button @click="importDatabase()" type="primary" icon="upload" />
</a-list-item>
</a-list>
</a-modal>
<!-- CPU History Modal -->
<a-modal id="cpu-history-modal" v-model="cpuHistoryModal.visible" :closable="true"
@cancel="() => cpuHistoryModal.visible = false" :class="themeSwitcher.currentTheme" width="900px" footer="">
<template slot="title">
CPU History
<a-select size="small" v-model="cpuHistoryModal.bucket" class="ml-10" style="width: 80px"
@change="fetchCpuHistoryBucket">
<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 style="padding:16px">
<sparkline :data="cpuHistoryLong" :labels="cpuHistoryLabels" :vb-width="840" :height="220"
:stroke="status.cpu.color" :stroke-width="2.2" :show-grid="true" :show-axes="true" :tick-count-x="5"
:max-points="cpuHistoryLong.length" :fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true" />
<div style="margin-top:4px;font-size:11px;opacity:0.65">Timeframe: [[ cpuHistoryModal.bucket ]] sec per point (total [[ cpuHistoryLong.length ]] points)</div>
</div>
</a-modal>
</a-layout>
{{template "page/body_scripts" .}}
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aCustomStatistic" .}}
{{template "modals/textModal"}}
<script>
// Tiny Sparkline component using an inline SVG polyline
Vue.component('sparkline', {
props: {
data: { type: Array, required: true },
// viewBox width for drawing space; SVG width will be 100% of container
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 },
// New opts for axes/labels/tooltip
labels: { type: Array, default: () => [] }, // same length as data for x labels (e.g., timestamps)
showAxes: { type: Boolean, default: false },
yTickStep: { type: Number, default: 25 }, // percent ticks
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 },
},
data() {
return {
hoverIdx: -1,
}
},
computed: {
viewBoxAttr() {
return '0 0 ' + this.vbWidth + ' ' + this.height
},
drawWidth() {
return Math.max(1, this.vbWidth - this.paddingLeft - this.paddingRight)
},
drawHeight() {
return Math.max(1, this.height - this.paddingTop - this.paddingBottom)
},
nPoints() {
return Math.min(this.data.length, this.maxPoints)
},
dataSlice() {
const n = this.nPoints
if (n === 0) return []
return this.data.slice(this.data.length - n)
},
labelsSlice() {
const n = this.nPoints
if (!this.labels || this.labels.length === 0 || n === 0) return []
const start = Math.max(0, this.labels.length - n)
return this.labels.slice(start)
},
pointsArr() {
const n = this.nPoints
if (n === 0) return []
const slice = this.dataSlice
const max = 100
const w = this.drawWidth
const h = this.drawHeight
const dx = n > 1 ? w / (n - 1) : 0
return slice.map((v, i) => {
const x = Math.round(this.paddingLeft + i * dx)
const y = Math.round(this.paddingTop + (h - (Math.max(0, Math.min(100, v)) / max) * h))
return [x, y]
})
},
points() {
return this.pointsArr.map(p => `${p[0]},${p[1]}`).join(' ')
},
areaPath() {
if (this.pointsArr.length === 0) return ''
const first = this.pointsArr[0]
const last = this.pointsArr[this.pointsArr.length - 1]
const line = this.points
// Close to bottom to create an area fill
return `M ${first[0]},${this.paddingTop + this.drawHeight} L ${line.replace(/ /g, ' L ')} L ${last[0]},${this.paddingTop + this.drawHeight} Z`
},
gridLines() {
if (!this.showGrid) return []
const h = this.drawHeight
const w = this.drawWidth
// draw at 25%, 50%, 75%
return [0, 0.25, 0.5, 0.75, 1]
.map(r => Math.round(this.paddingTop + h * r))
.map(y => ({ x1: this.paddingLeft, y1: y, x2: this.paddingLeft + w, y2: y }))
},
lastPoint() {
if (this.pointsArr.length === 0) return null
return this.pointsArr[this.pointsArr.length - 1]
},
yTicks() {
if (!this.showAxes) return []
const step = Math.max(1, this.yTickStep)
const ticks = []
for (let p = 0; p <= 100; p += step) {
const y = Math.round(this.paddingTop + (this.drawHeight - (p / 100) * this.drawHeight))
ticks.push({ y, label: `${p}%` })
}
return ticks
},
xTicks() {
if (!this.showAxes) return []
const labels = this.labelsSlice
const n = this.nPoints
const m = Math.max(2, this.tickCountX)
const ticks = []
if (n === 0) return ticks
const w = this.drawWidth
const dx = n > 1 ? w / (n - 1) : 0
const positions = []
for (let i = 0; i < m; i++) {
const idx = Math.round((i * (n - 1)) / (m - 1))
positions.push(idx)
}
positions.forEach(idx => {
const label = labels[idx] != null ? String(labels[idx]) : String(idx)
const x = Math.round(this.paddingLeft + idx * dx)
ticks.push({ x, label })
})
return ticks
},
},
methods: {
onMouseMove(evt) {
if (!this.showTooltip || this.pointsArr.length === 0) return
const rect = evt.currentTarget.getBoundingClientRect()
const px = evt.clientX - rect.left
// translate to viewBox space
const x = (px / rect.width) * this.vbWidth
const n = this.nPoints
const dx = n > 1 ? this.drawWidth / (n - 1) : 0
const idx = Math.max(0, Math.min(n - 1, Math.round((x - this.paddingLeft) / (dx || 1))))
this.hoverIdx = idx
},
onMouseLeave() {
this.hoverIdx = -1
},
fmtHoverText() {
const labels = this.labelsSlice
const idx = this.hoverIdx
if (idx < 0 || idx >= this.dataSlice.length) return ''
const raw = Math.max(0, Math.min(100, Number(this.dataSlice[idx] || 0)))
const val = Number.isFinite(raw) ? raw.toFixed(2) : raw
const lab = labels[idx] != null ? labels[idx] : ''
return `${val}%${lab ? ' • ' + lab : ''}`
},
},
template: `
<svg width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none" class="idx-cpu-history-svg"
@mousemove="onMouseMove" @mouseleave="onMouseLeave">
<defs>
<linearGradient id="spkGrad" 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">
<!-- Y ticks/labels -->
<g v-for="(t,i) in yTicks" :key="'y'+i">
<text class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)" :y="t.y + 4" text-anchor="end" font-size="10" fill="rgba(0,0,0,0.3)" v-text="t.label"></text>
</g>
<!-- X ticks/labels -->
<g v-for="(t,i) in xTicks" :key="'x'+i">
<text class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 22" text-anchor="middle" font-size="10" fill="rgba(0,0,0,0.3)" v-text="t.label"></text>
</g>
</g>
<path v-if="areaPath" :d="areaPath" fill="url(#spkGrad)" stroke="none" />
<polyline :points="points" 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" />
<!-- Hover marker/tooltip -->
<g v-if="showTooltip && hoverIdx >= 0">
<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" fill="rgba(0,0,0,0.8)" v-text="fmtHoverText()"></text>
</g>
</svg>
`,
})
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 percent = this.percent;
if (percent < 80) {
return '#008771'; // Green
} else if (percent < 90) {
return "#f37b24"; // Orange
} else {
return "#cf3c3c"; // Red
}
}
}
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, data.disk.total);
this.loads = (data.loads || [0, 0, 0]).map(load => NumberFormatter.toFixed(load, 2));
this.mem = new CurTotal(data.mem.current, data.mem.total);
this.netIO = data.netIO;
this.netTraffic = data.netTraffic;
this.publicIP = data.publicIP;
this.swap = new CurTotal(data.swap.current, data.swap.total);
this.tcpCount = data.tcpCount;
this.udpCount = data.udpCount;
this.uptime = data.uptime;
this.appUptime = data.appUptime;
this.appStats = data.appStats;
this.xray = data.xray;
switch (this.xray.state) {
case 'running':
this.xray.color = "green";
this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusRunning" }}';
break;
case 'stop':
this.xray.color = "orange";
this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusStop" }}';
break;
case 'error':
this.xray.color = "red";
this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusError" }}';
break;
default:
this.xray.color = "gray";
this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusUnknown" }}';
break;
}
}
}
const versionModal = {
visible: false,
versions: [],
show(versions) {
this.visible = true;
this.versions = versions;
},
hide() {
this.visible = false;
},
};
const logModal = {
visible: false,
logs: [],
rows: 20,
level: 'info',
syslog: false,
loading: false,
show(logs) {
this.visible = true;
this.logs = logs;
this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
},
formatLogs(logs) {
let formattedLogs = '';
const levels = ["DEBUG", "INFO", "NOTICE", "WARNING", "ERROR"];
const levelColors = ["#3c89e8", "#008771", "#008771", "#f37b24", "#e04141", "#bcbcbc"];
logs.forEach((log, index) => {
let [data, message] = log.split(" - ", 2);
const parts = data.split(" ")
if (index > 0) formattedLogs += '<br>';
if (parts.length === 3) {
const d = parts[0];
const t = parts[1];
const level = parts[2];
const levelIndex = levels.indexOf(level, levels) || 5;
//formattedLogs += `<span style="color: gray;">${index + 1}.</span>`;
formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
} else {
const levelIndex = levels.indexOf(data, levels) || 5;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
}
if (message) {
if (message.startsWith("XRAY:"))
message = "<b>XRAY: </b>" + message.substring(5);
else
message = "<b>X-UI: </b>" + message;
}
formattedLogs += message ? ' - ' + message : '';
});
return formattedLogs;
},
hide() {
this.visible = false;
},
};
const xraylogModal = {
visible: false,
logs: [],
rows: 20,
showDirect: true,
showBlocked: true,
showProxy: true,
loading: false,
show(logs) {
this.visible = true;
this.logs = logs;
this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
},
formatLogs(logs) {
let formattedLogs = `
<style>
table {
border-collapse: collapse;
width: auto;
}
table td, table th {
padding: 2px 15px;
}
</style>
<table>
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Inbound</th>
<th>Outbound</th>
<th>Email</th>
</tr>
`;
logs.reverse().forEach((log, index) => {
let outboundColor = '';
if (log.Event === 1) {
outboundColor = ' style="color: #e04141;"'; //red for blocked
}
else if (log.Event === 2) {
outboundColor = ' style="color: #3c89e8;"'; //blue for proxies
}
let text = ``;
if (log.Email !== "") {
text = `<td>${log.Email}</td>`;
}
formattedLogs += `
<tr ${outboundColor}>
<td><b>${IntlUtil.formatDate(log.DateTime)}</b></td>
<td>${log.FromAddress}</td>
<td>${log.ToAddress}</td>
<td>${log.Inbound}</td>
<td>${log.Outbound}</td>
${text}
</tr>
`;
});
return formattedLogs += "</table>";
},
hide() {
this.visible = false;
},
};
const backupModal = {
visible: false,
show() {
this.visible = true;
},
hide() {
this.visible = false;
},
};
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
loadingStates: {
fetched: false,
spinning: false
},
status: new Status(),
cpuHistory: [], // small live widget history
cpuHistoryLong: [], // aggregated points from backend
cpuHistoryLabels: [],
cpuHistoryModal: { visible: false, bucket: 2 },
versionModal,
logModal,
xraylogModal,
backupModal,
loadingTip: '{{ i18n "loading"}}',
showAlert: false,
showIp: false,
ipLimitEnable: false,
},
methods: {
loading(spinning, tip = '{{ i18n "loading"}}') {
this.loadingStates.spinning = spinning;
this.loadingTip = tip;
},
async getStatus() {
try {
const msg = await HttpUtil.get('/panel/api/server/status');
if (msg.success && msg.obj !== null && msg.obj !== undefined) {
this.setStatus(msg.obj);
} else if (msg.success && msg.obj === null) {
// Server started but status not yet collected return without
// marking fetched so the caller can decide to retry or fall back.
return false;
} else if (!this.loadingStates.fetched) {
this.loadingStates.fetched = true;
}
} catch (e) {
console.error("Failed to get status:", e);
if (!this.loadingStates.fetched) {
this.loadingStates.fetched = true;
}
}
return true;
},
setStatus(data) {
this.loadingStates.fetched = true;
this.status = new Status(data);
// Push CPU percent into history (clamped 0..100)
const v = Math.max(0, Math.min(100, Number(data?.cpu ?? 0)))
this.cpuHistory.push(v)
const maxPoints = this.isMobile ? 60 : 120
if (this.cpuHistory.length > maxPoints) {
this.cpuHistory.splice(0, this.cpuHistory.length - maxPoints)
}
// If modal open, refresh current bucketed data
if (this.cpuHistoryModal.visible) {
this.fetchCpuHistoryBucket()
}
},
openCpuHistory() {
this.cpuHistoryModal.visible = true
this.fetchCpuHistoryBucket()
},
async fetchCpuHistoryBucket() {
const bucket = this.cpuHistoryModal.bucket || 2
try {
const msg = await HttpUtil.get(`/panel/api/server/cpuHistory/${bucket}`)
if (msg.success && Array.isArray(msg.obj)) {
const vals = []
const labels = []
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')
labels.push(bucket>=60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`)
vals.push(Math.max(0, Math.min(100, p.cpu)))
}
this.cpuHistoryLabels = labels
this.cpuHistoryLong = vals
}
} catch(e) {
console.error('Failed to fetch bucketed cpu history', e)
}
},
async openSelectV2rayVersion() {
this.loading(true);
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
this.loading(false);
if (!msg.success) {
return;
}
versionModal.show(msg.obj);
},
switchV2rayVersion(version) {
this.$confirm({
title: '{{ i18n "pages.index.xraySwitchVersionDialog"}}',
content: '{{ i18n "pages.index.xraySwitchVersionDialogDesc"}}'.replace('#version#', version),
okText: '{{ i18n "confirm"}}',
class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
versionModal.hide();
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
this.loading(false);
},
});
},
updateGeofile(fileName) {
const isSingleFile = !!fileName;
this.$confirm({
title: '{{ i18n "pages.index.geofileUpdateDialog" }}',
content: isSingleFile
? '{{ i18n "pages.index.geofileUpdateDialogDesc" }}'.replace("#filename#", fileName)
: '{{ i18n "pages.index.geofilesUpdateDialogDesc" }}',
okText: '{{ i18n "confirm"}}',
class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
versionModal.hide();
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
const url = isSingleFile
? `/panel/api/server/updateGeofile/${fileName}`
: `/panel/api/server/updateGeofile`;
await HttpUtil.post(url);
this.loading(false);
},
});
},
async stopXrayService() {
this.loading(true);
const msg = await HttpUtil.post('/panel/api/server/stopXrayService');
this.loading(false);
if (!msg.success) {
return;
}
},
async restartXrayService() {
this.loading(true);
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
this.loading(false);
if (!msg.success) {
return;
}
},
async openLogs() {
logModal.loading = true;
const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, { level: logModal.level, syslog: logModal.syslog });
if (!msg.success) {
return;
}
logModal.show(msg.obj);
await PromiseUtil.sleep(500);
logModal.loading = false;
},
async openXrayLogs() {
xraylogModal.loading = true;
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
if (!msg.success) {
return;
}
xraylogModal.show(msg.obj);
await PromiseUtil.sleep(500);
xraylogModal.loading = false;
},
downloadXrayLogs() {
if (!Array.isArray(this.xraylogModal.logs) || this.xraylogModal.logs.length === 0) {
FileManager.downloadTextFile('', 'x-ui.log');
return;
}
const lines = this.xraylogModal.logs.map(l => {
try {
const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
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');
},
async openConfig() {
this.loading(true);
const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
this.loading(false);
if (!msg.success) {
return;
}
txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json');
},
openBackup() {
backupModal.show();
},
exportDatabase() {
window.location = basePath + 'panel/api/server/getDb';
},
importDatabase() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.db';
fileInput.addEventListener('change', async (event) => {
const dbFile = event.target.files[0];
if (dbFile) {
const formData = new FormData();
formData.append('db', dbFile);
backupModal.hide();
this.loading(true);
const uploadMsg = await HttpUtil.post('/panel/api/server/importDB', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
this.loading(false);
if (!uploadMsg.success) {
return;
}
this.loading(true);
const restartMsg = await HttpUtil.post("/panel/setting/restartPanel");
this.loading(false);
if (restartMsg.success) {
this.loading(true);
await PromiseUtil.sleep(5000);
location.reload();
}
}
});
fileInput.click();
},
startPolling() {
// Fallback polling mechanism
const pollInterval = setInterval(async () => {
if (window.wsClient && window.wsClient.isConnected) {
clearInterval(pollInterval);
return;
}
try {
await this.getStatus();
} catch (e) {
console.error(e);
}
}, 2000);
},
},
async mounted() {
if (window.location.protocol !== "https:") {
this.showAlert = true;
}
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (msg.success) {
this.ipLimitEnable = msg.obj.ipLimitEnable;
}
// Initial status fetch retry up to 5 times (500 ms apart) if the server
// returns null (status not yet collected on startup). A hard failsafe
// timeout of 6 seconds guarantees the loading screen always clears.
await (async () => {
const failsafeTimer = setTimeout(() => {
this.loadingStates.fetched = true;
}, 6000);
const maxRetries = 5;
for (let i = 0; i < maxRetries; i++) {
const ok = await this.getStatus();
if (ok || this.loadingStates.fetched) {
clearTimeout(failsafeTimer);
return;
}
// Null status wait briefly then retry
await new Promise(resolve => setTimeout(resolve, 500));
}
// All retries exhausted show zeroed data
clearTimeout(failsafeTimer);
if (!this.loadingStates.fetched) this.loadingStates.fetched = true;
})();
// Setup WebSocket for real-time updates
if (window.wsClient) {
window.wsClient.connect();
// Listen for status updates
window.wsClient.on('status', (payload) => {
this.setStatus(payload);
});
// Listen for Xray state changes
window.wsClient.on('xray_state', (payload) => {
if (this.status && this.status.xray) {
this.status.xray.state = payload.state;
this.status.xray.errorMsg = payload.errorMsg || '';
switch (payload.state) {
case 'running':
this.status.xray.color = "green";
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusRunning" }}';
break;
case 'stop':
this.status.xray.color = "orange";
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusStop" }}';
break;
case 'error':
this.status.xray.color = "red";
this.status.xray.stateMsg = '{{ i18n "pages.index.xrayStatusError" }}';
break;
}
}
});
// Notifications disabled - white notifications are not needed
// Fallback to polling if WebSocket fails
window.wsClient.on('error', () => {
console.warn('WebSocket connection failed, falling back to polling');
this.startPolling();
});
window.wsClient.on('disconnected', () => {
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
console.warn('WebSocket reconnection failed, falling back to polling');
this.startPolling();
}
});
} else {
// Fallback to polling if WebSocket is not available
this.startPolling();
}
},
});
</script>
{{ template "page/body_end" .}}