mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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>
223 lines
6 KiB
Vue
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>
|