chore: frontend UX improvements, CI pipeline, and dev tooling

- AppSidebar: logout via POST /logout instead of navigating to GET
- InboundList: persist filter state (search, protocol, node) to
  localStorage across page reloads; add protocol and node filter dropdowns
- IndexPage: add health status strip (Xray, CPU, Memory, Update) with
  quick-action buttons
- dependabot: weekly go mod and npm update schedule
- ci.yml: add GitHub Actions workflow for build and vet
- .nvmrc: pin Node 22 for local development
- frontend: bump package.json and package-lock.json
- SubPage, DnsPresetsModal, api-docs: minor fixes
This commit is contained in:
farhadh 2026-05-11 21:10:58 +02:00
parent 6343c43f62
commit 11629fba80
No known key found for this signature in database
11 changed files with 234 additions and 16 deletions

View file

@ -9,3 +9,11 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"

59
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
go-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Build
run: npm run build
working-directory: frontend
- name: Audit
run: npm audit --audit-level=high
working-directory: frontend

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

@ -7,6 +7,10 @@
"": {
"name": "3x-ui-frontend",
"version": "0.0.2",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",

View file

@ -4,6 +4,10 @@
"version": "0.0.2",
"type": "module",
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",

View file

@ -14,6 +14,7 @@ import {
} from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
import { HttpUtil } from '@/utils';
const { t } = useI18n();
@ -45,7 +46,7 @@ const tabs = computed(() => [
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
{ key: 'logout', icon: 'logout', title: t('logout') },
]);
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
@ -55,7 +56,12 @@ const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
const drawerWidth = 'min(82vw, 320px)';
function openLink(key) {
async function openLink(key) {
if (key === 'logout') {
await HttpUtil.post('/logout');
window.location.href = props.basePath || '/';
return;
}
if (key.startsWith('http')) {
window.open(key);
} else {

View file

@ -19,9 +19,9 @@ export const sections = [
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
},
{
method: 'GET',
method: 'POST',
path: '/logout',
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.',
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
},
{
method: 'GET',
@ -43,7 +43,7 @@ export const sections = [
id: 'inbounds',
title: 'Inbounds API',
description:
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.',
endpoints: [
{
method: 'GET',
@ -531,7 +531,7 @@ export const sections = [
description: 'Operations that interact with the configured Telegram bot.',
endpoints: [
{
method: 'GET',
method: 'POST',
path: '/panel/api/backuptotgbot',
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
},

View file

@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
@ -67,9 +67,29 @@ const emit = defineEmits([
]);
// ============ Toolbar / search & filter =============================
const enableFilter = ref(false);
const searchKey = ref('');
const filterBy = ref('');
const FILTER_STATE_KEY = 'inboundsFilterState';
const savedFilterState = (() => {
try {
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
} catch (_e) {
return {};
}
})();
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || '');
const nodeFilter = ref(savedFilterState.nodeFilter || '');
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter: enableFilter.value,
searchKey: searchKey.value,
filterBy: filterBy.value,
protocolFilter: protocolFilter.value,
nodeFilter: nodeFilter.value,
}));
});
// Toggle the filter mode flip cleans the other input.
function onToggleFilter() {
@ -77,6 +97,35 @@ function onToggleFilter() {
else filterBy.value = '';
}
const protocolOptions = computed(() => {
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
return [...values].sort();
});
const nodeOptions = computed(() => {
const values = new Map();
if (props.dbInbounds.some((i) => i.nodeId == null)) {
values.set('local', t('pages.inbounds.localPanel'));
}
for (const dbInbound of props.dbInbounds) {
if (dbInbound.nodeId == null) continue;
const node = props.nodesById.get(dbInbound.nodeId);
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
}
return [...values.entries()].map(([value, label]) => ({ value, label }));
});
function applySecondaryFilters(rows) {
return rows.filter((dbInbound) => {
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
if (nodeFilter.value) {
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
if (nodeValue !== nodeFilter.value) return false;
}
return true;
});
}
// ============ Search / filter projection =============================
// Mirrors the legacy logic: when searching, keep inbounds that match
// anywhere (deep search); when filtering, keep inbounds that have at
@ -99,7 +148,7 @@ function projectInbound(dbInbound, predicate) {
const visibleInbounds = computed(() => {
if (enableFilter.value) {
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = [];
for (const dbInbound of props.dbInbounds) {
const c = props.clientCount[dbInbound.id];
@ -107,15 +156,15 @@ const visibleInbounds = computed(() => {
const list = c[filterBy.value];
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
}
return out;
return applySecondaryFilters(out);
}
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = [];
for (const dbInbound of props.dbInbounds) {
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
}
return out;
return applySecondaryFilters(out);
});
// ============ Columns =================================================
@ -269,6 +318,18 @@ function showQrCodeMenu(dbInbound) {
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group>
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
{{ protocol }}
</a-select-option>
</a-select>
<a-select v-if="nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
{{ node.label }}
</a-select-option>
</a-select>
</div>
<!-- ====================== Mobile: card list ======================= -->

View file

@ -63,6 +63,33 @@ const displayVersion = computed(
() => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
);
const healthItems = computed(() => {
const cpuPercent = Number(status.cpu?.percent || 0);
const memPercent = Number(status.mem?.percent || 0);
return [
{
label: 'Xray',
value: status.xray.state,
color: status.xray.color,
},
{
label: 'CPU',
value: `${cpuPercent.toFixed(1)}%`,
color: cpuPercent > 85 ? 'red' : cpuPercent > 65 ? 'orange' : 'green',
},
{
label: 'Memory',
value: `${memPercent.toFixed(1)}%`,
color: memPercent > 85 ? 'red' : 'blue',
},
{
label: 'Update',
value: panelUpdateInfo.value.updateAvailable ? panelUpdateInfo.value.latestVersion : 'current',
color: panelUpdateInfo.value.updateAvailable ? 'orange' : 'green',
},
];
});
// Hide/reveal the public IPv4/IPv6 same pattern as legacy.
const showIp = ref(false);
@ -124,6 +151,25 @@ async function openConfig() {
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
<a-col :span="24">
<div class="health-strip">
<div class="health-tags">
<a-tag v-for="item in healthItems" :key="item.label" :color="item.color">
{{ item.label }}: {{ item.value }}
</a-tag>
</div>
<a-space :size="8" wrap class="critical-actions">
<a-button size="small" @click="refresh">{{ t('refresh') }}</a-button>
<a-button size="small" danger @click="restartXray">{{ t('pages.index.restartXray') }}</a-button>
<a-button size="small" @click="openXrayLogs">{{ t('pages.index.logs') }}</a-button>
<a-button v-if="panelUpdateInfo.updateAvailable" size="small" type="primary"
@click="panelUpdateOpen = true">
{{ t('pages.index.updatePanel') }}
</a-button>
</a-space>
</div>
</a-col>
<a-col :span="24">
<StatusCard :status="status" :is-mobile="isMobile" />
</a-col>
@ -369,6 +415,36 @@ async function openConfig() {
min-height: calc(100vh - 120px);
}
.health-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 6px;
background: var(--bg-card);
}
.health-tags,
.critical-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.health-tags :deep(.ant-tag) {
margin-inline-end: 0;
}
@media (max-width: 768px) {
.health-strip {
align-items: flex-start;
flex-direction: column;
}
}
.action {
cursor: pointer;
justify-content: center;

View file

@ -40,7 +40,6 @@ const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || '';
const subTitle = subData.subTitle || '';
const subSupportUrl = subData.subSupportUrl || '';
const links = Array.isArray(subData.links) ? subData.links : [];
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
// render in Gregorian or Jalali on this standalone subscription page.

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
defineProps({
open: { type: Boolean, default: false },
});