feat(gui): add service and remote mode support (#1578)

This PR fundamentally restructures the EasyTier GUI, introducing support for service mode and remote mode, transforming it from a simple desktop application into a powerful network management terminal. This change allows users to persistently run the EasyTier core as a background service or remotely manage multiple EasyTier instances, greatly improving deployment flexibility and manageability.
This commit is contained in:
Mg Pig
2025-11-25 13:59:27 +08:00
committed by GitHub
parent b44053f496
commit 1f2517c731
29 changed files with 2921 additions and 2097 deletions
+204 -8
View File
@@ -9,16 +9,193 @@ import { I18nUtils, RemoteManagement } from "easytier-frontend-lib"
import type { MenuItem } from 'primevue/menuitem'
import { useTray } from '~/composables/tray'
import { GUIRemoteClient } from '~/modules/api'
import { getAutoLaunchStatusAsync as getAutoLaunchStatus, loadAutoLaunchStatusAsync } from '~/modules/auto_launch'
import { getDockVisibilityStatus, loadDockVisibilityAsync } from '~/modules/dock_visibility'
import { useToast, useConfirm } from 'primevue'
import { loadMode, saveMode, type Mode } from '~/composables/mode'
import ModeSwitcher from '~/components/ModeSwitcher.vue'
import { getServiceStatus, type ServiceStatus } from '~/composables/backend'
const { t, locale } = useI18n()
const confirm = useConfirm()
const aboutVisible = ref(false)
const modeDialogVisible = ref(false)
const currentMode = ref<Mode>({ mode: 'normal' })
const editingMode = ref<Mode>({ mode: 'normal' })
const isModeSaving = ref(false)
const serviceStatus = ref<ServiceStatus>('NotInstalled')
async function openModeDialog() {
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
if (editingMode.value.mode === 'service') {
serviceStatus.value = await getServiceStatus()
}
modeDialogVisible.value = true
}
async function onModeSave() {
if (isModeSaving.value) {
return;
}
isModeSaving.value = true
try {
await initWithMode(editingMode.value);
modeDialogVisible.value = false
}
catch (e: any) {
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
console.error("Error switching mode", e, currentMode.value, editingMode.value)
await initWithMode(currentMode.value);
}
finally {
isModeSaving.value = false
}
}
async function onUninstallService() {
confirm.require({
message: t('mode.uninstall_service_confirm'),
header: t('mode.uninstall_service'),
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: t('web.common.cancel'),
severity: 'secondary',
outlined: true
},
acceptProps: {
label: t('mode.uninstall_service'),
severity: 'danger'
},
accept: async () => {
isModeSaving.value = true
try {
await initWithMode({ ...currentMode.value, mode: 'normal' });
await initService(undefined)
toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.uninstall_service_success'), life: 3000 })
modeDialogVisible.value = false
} catch (e: any) {
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
console.error("Error uninstalling service", e)
} finally {
isModeSaving.value = false
}
},
});
}
async function onStopService() {
isModeSaving.value = true
try {
await setServiceStatus(false)
toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.stop_service_success'), life: 3000 })
modeDialogVisible.value = false
}
catch (e: any) {
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
console.error("Error stopping service", e)
}
finally {
isModeSaving.value = false
}
}
async function initWithMode(mode: Mode) {
if (currentMode.value.mode === 'service' && mode.mode !== 'service') {
let serviceStatus = await getServiceStatus()
if (serviceStatus === "Running") {
await setServiceStatus(false)
serviceStatus = await getServiceStatus()
}
if (serviceStatus === "Stopped") {
await initService(undefined)
}
}
let url: string | undefined = undefined
let retrys = 1
switch (mode.mode) {
case 'remote':
if (!mode.remote_rpc_address) {
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.remote_rpc_address_empty'), life: 10000 })
return initWithMode({ ...mode, mode: 'normal' });
}
url = mode.remote_rpc_address
break;
case 'service':
if (!mode.config_dir || !mode.file_log_dir || !mode.file_log_level || !mode.rpc_portal) {
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.service_config_empty'), life: 10000 })
return initWithMode({ ...mode, mode: 'normal' });
}
let serviceStatus = await getServiceStatus()
if (serviceStatus === "NotInstalled" || JSON.stringify(mode) !== JSON.stringify(currentMode.value)) {
await initService({
config_dir: mode.config_dir,
file_log_dir: mode.file_log_dir,
file_log_level: mode.file_log_level,
rpc_portal: mode.rpc_portal,
})
serviceStatus = await getServiceStatus()
}
if (serviceStatus === "Stopped") {
await setServiceStatus(true)
}
url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1")
retrys = 5
break;
}
for (let i = 0; i < retrys; i++) {
try {
await connectRpcClient(url)
break;
} catch (e) {
if (i === retrys - 1) {
throw e;
}
console.error("Error connecting rpc client, retrying...", e)
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
currentMode.value = mode
saveMode(mode)
clientRunning.value = await isClientRunning()
}
onMounted(() => {
currentMode.value = loadMode()
initWithMode(currentMode.value);
});
useTray(true)
let toast = useToast();
const remoteClient = computed(() => new GUIRemoteClient());
const instanceId = ref<string | undefined>(undefined);
const clientRunning = ref(false);
watch(clientRunning, async (newVal, oldVal) => {
if (!newVal && oldVal) {
await reconnectClient()
}
})
onMounted(async () => {
clientRunning.value = await isClientRunning().catch(() => false)
const timer = setInterval(async () => {
try {
clientRunning.value = await isClientRunning()
} catch (e) {
clientRunning.value = false
console.error("Error checking client running status", e)
}
}, 1000)
return () => {
clearInterval(timer)
}
})
async function reconnectClient() {
editingMode.value = JSON.parse(JSON.stringify(loadMode()));
await onModeSave()
}
onMounted(async () => {
window.setTimeout(async () => {
@@ -81,11 +258,10 @@ const setting_menu_items: Ref<MenuItem[]> = ref([
},
},
{
label: () => getAutoLaunchStatus() ? t('disable_auto_launch') : t('enable_auto_launch'),
icon: 'pi pi-desktop',
command: async () => {
await loadAutoLaunchStatusAsync(!getAutoLaunchStatus())
},
label: () => `${t('mode.switch_mode')}: ${t('mode.' + currentMode.value.mode)}`,
icon: 'pi pi-sync',
command: openModeDialog,
visible: () => type() !== 'android',
},
{
label: () => getDockVisibilityStatus() ? t('hide_dock_icon') : t('show_dock_icon'),
@@ -117,6 +293,12 @@ const setting_menu_items: Ref<MenuItem[]> = ref([
},
])
async function connectRpcClient(url?: string) {
await initRpcConnection(url)
await sendConfigs()
console.log("easytier rpc connection established")
}
onMounted(async () => {
if (type() === 'android') {
try {
@@ -127,7 +309,6 @@ onMounted(async () => {
}
}
const unlisten = await listenGlobalEvents()
await sendConfigs()
return () => {
unlisten()
}
@@ -140,9 +321,24 @@ onMounted(async () => {
<Dialog v-model:visible="aboutVisible" modal :header="t('about.title')" :style="{ width: '70%' }">
<About />
</Dialog>
<Dialog v-model:visible="modeDialogVisible" modal :header="t('mode.switch_mode')" :style="{ width: '50vw' }">
<ModeSwitcher v-model="editingMode" @uninstall-service="onUninstallService" @stop-service="onStopService" />
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="modeDialogVisible = false" text />
<Button :label="t('web.common.save')" icon="pi pi-save" @click="onModeSave" autofocus :loading="isModeSaving" />
</template>
</Dialog>
<Menu ref="log_menu" :model="log_menu_items_popup" :popup="true" />
<RemoteManagement class="flex-1 overflow-y-auto" :api="remoteClient" v-bind:instance-id="instanceId" />
<RemoteManagement v-if="clientRunning" class="flex-1 overflow-y-auto" :api="remoteClient"
:pause-auto-refresh="isModeSaving" v-bind:instance-id="instanceId" />
<div v-else class="empty-state flex-1 flex flex-col items-center py-12">
<i class="pi pi-server text-5xl text-secondary mb-4 opacity-50"></i>
<div class="text-xl text-center font-medium mb-3">{{ t('client.not_running') }}
</div>
<Button @click="reconnectClient" :loading="isModeSaving" :label="t('client.retry')" icon="pi pi-replay"
iconPos="left" />
</div>
<Menubar :model="setting_menu_items" breakpoint="560px">
<template #item="{ item, props }">