3x-ui/frontend/src/pages/nodes/NodeFormModal.vue
MHSanaei f4f0af576a
feat(ws): live updates on inbounds/xray/nodes pages, drop polling + manual refresh
Replaces the legacy polling + manual-refresh model with WebSocket pushes
across the three live-data pages. The hub already broadcast traffic /
client_stats / outbounds; this wires the frontend to consume them and
adds a new `nodes` channel for the heartbeat job's snapshot.

Frontend
- new useWebSocket composable: page-scoped singleton WebSocketClient,
  lifecycle-managed on/off, leaves disconnect to page-unload
- inbounds: useInbounds gains applyTrafficEvent / applyClientStatsEvent
  / applyInvalidate that merge counters and online/lastOnline in place;
  InboundsPage subscribes; InboundList drops the auto-refresh popover,
  the refresh button, and the now-unused refreshing prop
- xray outbounds: useXraySetting gains applyOutboundsEvent; XrayPage
  subscribes; OutboundsTab drops the refresh button + emit
- nodes: useNodes gains applyNodesEvent and stops the 5s
  setInterval/visibilitychange polling; NodesPage subscribes;
  NodeList drops the refresh button and ReloadOutlined import

Backend
- web/websocket: new MessageTypeNodes + BroadcastNodes notifier
- node_heartbeat_job: after wg.Wait(), reload the table once and
  BroadcastNodes(updated). Gated on websocket.HasClients() so a panel
  with no open browser doesn't spend the DB read

Bug fixes spotted in this pass
- websocket.js #buildUrl defaulted basePath to '' when the global was
  missing (dev mode), producing `ws://host:portws` and a SyntaxError
  on the WebSocket constructor. Fall back to '/' and ensure leading
  slash.
- vite.config.js: forward /ws to ws://localhost:2053 with ws:true so
  dev (5173) reaches the Go backend's WebSocket
- NodeFormModal: a-input-password's visibilityToggle is Boolean in
  AntD Vue 4; the v3-era object form (`{ visible, 'onUpdate:visible' }`)
  triggered a Vue prop-type warning. Drop the override (default true
  shows the eye icon and toggles internally) and remove the orphaned
  tokenVisible ref

Translations
- pages.inbounds.autoRefresh / autoRefreshInterval: removed from all
  13 locales (UI gone)
- pages.nodes.refresh: removed from all 13 locales (UI gone)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 17:30:31 +02:00

223 lines
6 KiB
Vue

<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
const props = defineProps({
open: { type: Boolean, default: false },
mode: { type: String, default: 'add' }, // 'add' | 'edit'
node: { type: Object, default: null },
testConnection: { type: Function, required: true },
save: { type: Function, required: true }, // (payload) => Promise<msg>
});
const emit = defineEmits(['update:open']);
const { t } = useI18n();
// Default form shape — used for "add" mode and to reset between
// edits. Sane defaults: HTTPS, port 2053, base path '/', enabled.
function defaultForm() {
return {
id: 0,
name: '',
remark: '',
scheme: 'https',
address: '',
port: 2053,
basePath: '/',
apiToken: '',
enable: true,
};
}
const form = reactive(defaultForm());
const submitting = ref(false);
const testing = ref(false);
const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
// Reset the form whenever the modal is opened. In edit mode we copy
// the existing node into the form fields; in add mode we wipe back
// to defaults so a previous edit doesn't leak through.
watch(
() => props.open,
(open) => {
if (!open) return;
Object.assign(form, defaultForm());
testResult.value = null;
if (props.mode === 'edit' && props.node) {
Object.assign(form, props.node);
}
},
);
const title = computed(() =>
props.mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode'),
);
function close() {
if (!submitting.value) emit('update:open', false);
}
function buildPayload() {
return {
id: form.id || 0,
name: form.name?.trim() || '',
remark: form.remark?.trim() || '',
scheme: form.scheme || 'https',
address: form.address?.trim() || '',
port: Number(form.port) || 0,
basePath: form.basePath?.trim() || '/',
apiToken: form.apiToken?.trim() || '',
enable: !!form.enable,
};
}
async function onTest() {
testing.value = true;
testResult.value = null;
try {
const payload = buildPayload();
if (!payload.address || !payload.port) {
message.error(t('pages.nodes.toasts.fillRequired'));
return;
}
const msg = await props.testConnection(payload);
if (msg?.success) {
testResult.value = msg.obj;
} else {
testResult.value = { status: 'offline', error: msg?.msg || 'unknown error' };
}
} finally {
testing.value = false;
}
}
async function onSave() {
const payload = buildPayload();
if (!payload.name || !payload.address || !payload.port) {
message.error(t('pages.nodes.toasts.fillRequired'));
return;
}
submitting.value = true;
try {
const msg = await props.save(payload);
if (msg?.success) {
emit('update:open', false);
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<a-modal
:open="open"
:title="title"
:confirm-loading="submitting"
:ok-text="t('save')"
:cancel-text="t('cancel')"
:mask-closable="false"
width="640px"
@ok="onSave"
@cancel="close"
>
<a-form layout="vertical" :model="form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.nodes.name')" required>
<a-input v-model:value="form.name" :placeholder="t('pages.nodes.namePlaceholder')" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.nodes.remark')">
<a-input v-model:value="form.remark" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="6">
<a-form-item :label="t('pages.nodes.scheme')">
<a-select v-model:value="form.scheme">
<a-select-option value="https">https</a-select-option>
<a-select-option value="http">http</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.nodes.address')" required>
<a-input v-model:value="form.address" :placeholder="t('pages.nodes.addressPlaceholder')" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :label="t('pages.nodes.port')" required>
<a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('pages.nodes.basePath')">
<a-input v-model:value="form.basePath" placeholder="/" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('pages.nodes.enable')">
<a-switch v-model:checked="form.enable" />
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="t('pages.nodes.apiToken')" required>
<a-input-password
v-model:value="form.apiToken"
:placeholder="t('pages.nodes.apiTokenPlaceholder')"
/>
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
</a-form-item>
<div class="test-row">
<a-button :loading="testing" @click="onTest">
{{ t('pages.nodes.testConnection') }}
</a-button>
<div v-if="testResult" class="test-result">
<a-alert
v-if="testResult.status === 'online'"
type="success"
show-icon
:message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined"
/>
<a-alert
v-else
type="error"
show-icon
:message="t('pages.nodes.connectionFailed')"
:description="testResult.error"
/>
</div>
</div>
</a-form>
</a-modal>
</template>
<style scoped>
.hint {
font-size: 12px;
opacity: 0.6;
margin-top: 4px;
}
.test-row {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.test-result {
width: 100%;
}
</style>