diff --git a/web/src/api/network.ts b/web/src/api/network.ts index 1b6415e..6fc15bb 100644 --- a/web/src/api/network.ts +++ b/web/src/api/network.ts @@ -21,17 +21,27 @@ export function deleteWolMac(mac: string) { }); } -// check if tailscale exists -export function getTailscale() { - return http.get('/api/network/tailscale'); -} - // install tailscale export function installTailscale() { return http.post('/api/network/tailscale/install'); } -// run tailscale -export function runTailscale() { - return http.post('/api/network/tailscale/run'); +// get tailscale status +export function getTailscaleStatus() { + return http.get('/api/network/tailscale/status'); +} + +// update tailscale status +export function updateTailscaleStatus(command: 'up' | 'down') { + return http.post('/api/network/tailscale/status', { command }); +} + +// login tailscale +export function loginTailscale() { + return http.post('/api/network/tailscale/login'); +} + +// logout tailscale +export function logoutTailscale() { + return http.post('/api/network/tailscale/logout'); } diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 76a4403..69a9d73 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -142,12 +142,9 @@ const en = { }, tailscale: { loading: 'Loading...', - notInstalled: 'Tailscale not found! Please install.', + notInstall: 'Tailscale not found! Please install.', install: 'Install', - installing: 'Installing...', - urlPeriod: 'This url is valid for 10 minutes', - installed: 'Tailscale already installed. Please login.', - login: 'Login', + installing: 'Installing', failed: 'Install failed', retry: 'Please refresh and try again. Or try to install manually', download: 'Download the', @@ -155,7 +152,18 @@ const en = { unzip: 'and unzip it', upTailscale: 'Upload tailscale to NanoKVM directory /usr/bin/', upTailscaled: 'Upload tailscaled to NanoKVM directory /usr/sbin/', - refresh: 'Refresh current page' + refresh: 'Refresh current page', + notLogin: + 'The device has not been bound yet. Please login and bind this device to your account.', + urlPeriod: 'This url is valid for 10 minutes', + login: 'Login', + loginSuccess: 'Login Success', + enable: 'Enable Tailscale', + deviceName: 'Device Name', + deviceIP: 'Device IP', + account: 'Account', + logout: 'Logout', + logout2: 'Sure to logout?' } } }; diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index 646f46d..4ddae2e 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -146,12 +146,9 @@ const fr = { }, tailscale: { loading: 'Chargement...', - notInstalled: "Tailscale non trouvé! Veuillez l'installer.", + notInstall: "Tailscale non trouvé! Veuillez l'installer.", install: 'Installer', - installing: 'Installation...', - urlPeriod: "L'URL est valide pendant 10 minutes", - installed: 'Tailscale est déjà installé. Veuillez vous connecter.', - login: 'Connexion', + installing: 'Installation', failed: 'Installation échouée', retry: "Veuillez rafraîchir et réessayer. Ou essayez d'installer manuellement", download: 'Télécharger le', @@ -159,7 +156,17 @@ const fr = { unzip: 'et décompressez-le', upTailscale: 'Téléverser tailscale dans le répertoire NanoKVM /usr/sbin/', upTailscaled: 'Téléverser tailscaled dans le répertoire NanoKVM /usr/sbin/', - refresh: 'Rafraîchir la page courante' + refresh: 'Rafraîchir la page courante', + notLogin: "L'appareil n'est pas relié. Connectez-vous et liez cet appareil à votre compte.", + urlPeriod: "L'URL est valide pendant 10 minutes", + login: 'Connexion', + loginSuccess: 'Connexion réussie', + enable: 'Démarrer Tailscale', + deviceName: "Nom de l'appareil", + deviceIP: "IP de l'appareil", + account: 'Compte', + logout: 'Déconnexion', + logout2: 'Voulez-vous vous déconnecter?' } } }; diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index b006d9c..8df4a54 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -147,12 +147,9 @@ const ru = { }, tailscale: { loading: 'Загрузка...', - notInstalled: 'Tailscale не найден! Пожалуйста, установите.', + notInstall: 'Tailscale не найден! Пожалуйста, установите.', install: 'Установить', - installing: 'Установка...', - urlPeriod: 'Этот адрес действителен в течение 10 минут', - installed: 'Tailscale уже установлен. Пожалуйста, войдите в систему.', - login: 'Войти', + installing: 'Установка', failed: 'Не удалось установить', retry: 'Пожалуйста, обновите и попробуйте снова, или попробуйте установить вручную', download: 'Скачайте', @@ -160,7 +157,17 @@ const ru = { unzip: 'и разархивируйте его', upTailscale: 'Переместите tailscale в каталог /usr/bin/ на NanoKVM', upTailscaled: 'Переместите tailscaled в каталог /usr/sbin/ на NanoKVM', - refresh: 'Обновите текущую страницу' + refresh: 'Обновите текущую страницу', + notLogin: 'Устройство не привязано. Войдите, чтобы привязать его к аккаунту.', + urlPeriod: 'Этот адрес действителен в течение 10 минут', + login: 'Войти', + loginSuccess: 'Вход выполнен', + enable: 'Включить Tailscale', + deviceName: 'Имя устройства', + deviceIP: 'IP адрес устройства', + account: 'аккаунт', + logout: 'Выход', + logout2: 'Вы действительно хотите выйти?' } } }; diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 50ef7ed..7eb0541 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -141,12 +141,9 @@ const zh = { }, tailscale: { loading: '加载中...', - notInstalled: '未检测到 Tailscale,请先安装', + notInstall: '未检测到 Tailscale,请先安装', install: '安装', - installing: '安装中...', - urlPeriod: '该链接10分钟内有效', - installed: 'Tailscale 已安装,请执行登录操作', - login: '登录', + installing: '安装中', failed: '安装失败', retry: '请刷新后重试,或尝试手动安装', download: '下载', @@ -154,7 +151,17 @@ const zh = { unzip: '并解压', upTailscale: '将 tailscale 文件上传到 /usr/bin/ 目录', upTailscaled: '将 tailscaled 文件上传到 /usr/sbin/ 目录', - refresh: '刷新页面' + refresh: '刷新页面', + notLogin: '该设备尚未绑定,请点击登录并将这台设备绑定到您的账号。', + urlPeriod: '该链接10分钟内有效', + login: '登录', + loginSuccess: '登录完成', + enable: '启用 Tailscale', + deviceName: '设备名称', + deviceIP: '设备地址', + account: '账号', + logout: '退出', + logout2: '确认退出?' } } }; diff --git a/web/src/pages/desktop/menu-phone/index.tsx b/web/src/pages/desktop/menu-phone/index.tsx index 142eb27..7888acb 100644 --- a/web/src/pages/desktop/menu-phone/index.tsx +++ b/web/src/pages/desktop/menu-phone/index.tsx @@ -3,14 +3,14 @@ import clsx from 'clsx'; import { MenuIcon } from 'lucide-react'; import { Image } from './image'; -import { Keyboard } from './keyboard'; -import { Mouse } from './mouse'; -import { Power } from './power'; +import { Keyboard } from './keyboard.tsx'; +import { Mouse } from './mouse.tsx'; +import { Power } from './power.tsx'; import { Screen } from './screen'; import { Script } from './script'; import { Settings } from './settings'; import { Terminal } from './terminal'; -import { Wol } from './wol'; +import { Wol } from './wol.tsx'; export const MenuPhone = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); diff --git a/web/src/pages/desktop/menu-phone/settings/index.tsx b/web/src/pages/desktop/menu-phone/settings/index.tsx index ea7e2ab..df27cc5 100644 --- a/web/src/pages/desktop/menu-phone/settings/index.tsx +++ b/web/src/pages/desktop/menu-phone/settings/index.tsx @@ -6,12 +6,12 @@ import { SettingsIcon } from 'lucide-react'; import { isSettingsOpenAtom } from '@/jotai/settings.ts'; import { About } from './about.tsx'; -import { ChangePassword } from './change-password.tsx'; import { Language } from './language.tsx'; import { Logout } from './logout.tsx'; -import { Tailscale } from './tailscale.tsx'; +import { Password } from './password.tsx'; +import { Tailscale } from './tailscale'; import { Update } from './update.tsx'; -import { VirtualDevice } from './virtual-device.tsx'; +import { VirtualDevices } from './virtual-devices.tsx'; export const Settings = () => { const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom); @@ -22,13 +22,15 @@ export const Settings = () => { - + + + + - - + - + ); diff --git a/web/src/pages/desktop/menu-phone/settings/change-password.tsx b/web/src/pages/desktop/menu-phone/settings/password.tsx similarity index 92% rename from web/src/pages/desktop/menu-phone/settings/change-password.tsx rename to web/src/pages/desktop/menu-phone/settings/password.tsx index 6429f23..8cdb9d7 100644 --- a/web/src/pages/desktop/menu-phone/settings/change-password.tsx +++ b/web/src/pages/desktop/menu-phone/settings/password.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -export const ChangePassword = () => { +export const Password = () => { const navigate = useNavigate(); const { t } = useTranslation(); diff --git a/web/src/pages/desktop/menu-phone/settings/tailscale.tsx b/web/src/pages/desktop/menu-phone/settings/tailscale.tsx deleted file mode 100644 index feda3bf..0000000 --- a/web/src/pages/desktop/menu-phone/settings/tailscale.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useState } from 'react'; -import { Button, Modal } from 'antd'; -import { useSetAtom } from 'jotai'; -import { LoaderCircleIcon } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -import * as api from '@/api/network'; -import { isSettingsOpenAtom } from '@/jotai/settings.ts'; - -type State = '' | 'installing' | 'installed' | 'failed'; - -export const Tailscale = () => { - const { t } = useTranslation(); - const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isStarting, setIsStarting] = useState(false); - const [state, setState] = useState(''); - const [loginUrl, setLoginUrl] = useState(''); - - function openModal() { - check(); - - setIsModalOpen(true); - setIsSettingsOpen(false); - } - - function check() { - if (['installing', 'installed'].includes(state)) return; - - if (isLoading) return; - setIsLoading(true); - - api - .getTailscale() - .then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } - - if (rsp.data.exist) { - setState('installed'); - } - }) - .finally(() => { - setIsLoading(false); - }); - } - - function install() { - if (state === 'installing') return; - setState('installing'); - - api - .installTailscale() - .then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - setState('failed'); - return; - } - - setState('installed'); - }) - .catch(() => { - setState('failed'); - }); - } - - function run() { - if (state !== 'installed') return; - - if (isStarting) return; - setIsStarting(true); - - setLoginUrl(''); - - api - .runTailscale() - .then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } - - if (rsp.data.url) { - updateLoginUrl(rsp.data.url); - } - }) - .finally(() => { - setIsStarting(false); - }); - } - - function updateLoginUrl(url: string) { - setLoginUrl(url); - - window.open(url, '_blank'); - - setTimeout(() => setLoginUrl(''), 10 * 60 * 1000); - } - - return ( - <> -
- Tailscale -
- - setIsModalOpen(false)} - > -
- {isLoading ? ( -
- - {t('tailscale.loading')} -
- ) : ( - <> - {state === '' && ( -
- {t('tailscale.notInstalled')} -
- -
-
- )} - - {state === 'installing' && ( -
- - {t('tailscale.installing')} -
- )} - - {state === 'installed' && ( -
- {loginUrl ? ( - <> - - {loginUrl} - - {t('tailscale.urlPeriod')} - - ) : ( - <> - {t('tailscale.installed')} -
- -
- - )} -
- )} - - {state === 'failed' && ( -
-
- {t('tailscale.failed')} - {t('tailscale.retry')} -
- -
    -
  • - {t('tailscale.download')} - - {t('tailscale.package')} - - {t('tailscale.unzip')} -
  • -
  • {t('tailscale.upTailscale')}
  • -
  • {t('tailscale.upTailscaled')}
  • -
  • {t('tailscale.refresh')}
  • -
-
- )} - - )} -
-
- - ); -}; diff --git a/web/src/pages/desktop/menu-phone/settings/tailscale/device.tsx b/web/src/pages/desktop/menu-phone/settings/tailscale/device.tsx new file mode 100644 index 0000000..894c1d8 --- /dev/null +++ b/web/src/pages/desktop/menu-phone/settings/tailscale/device.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react'; +import { LogoutOutlined } from '@ant-design/icons'; +import { Button, Divider, Switch } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { logoutTailscale, updateTailscaleStatus } from '@/api/network.ts'; + +type DeviceProps = { + status: string; + name: string; + ip: string; + account: string; + setStatus: (status: 'notLogin') => void; +}; + +export const Device = ({ status, name, ip, account, setStatus }: DeviceProps) => { + const { t } = useTranslation(); + + const [isRunning, setIsRunning] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [showLogoutConfirmation, setShowLogoutConfirmation] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + const [errMsg, setErrMsg] = useState(''); + + useEffect(() => { + setIsRunning(status === 'running'); + }, []); + + function updateStatus() { + if (isUpdating) return; + setIsUpdating(true); + + const command = isRunning ? 'down' : 'up'; + + updateTailscaleStatus(command) + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setIsRunning(rsp.data.status === 'running'); + }) + .finally(() => { + setIsUpdating(false); + }); + } + + function logout() { + if (isLoggingOut) return; + setIsLoggingOut(true); + + logoutTailscale() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setStatus('notLogin'); + }) + .finally(() => { + setIsLoggingOut(false); + }); + } + + return ( +
+
+ {t('tailscale.enable')} + +
+ + +
+ {t('tailscale.deviceName')} + {name} +
+ + +
+ {t('tailscale.deviceIP')} + {ip} +
+ + +
+ {t('tailscale.account')} + {account} +
+ + +
+ {!showLogoutConfirmation ? ( + + ) : ( + + )} +
+ + {errMsg && {errMsg}} +
+ ); +}; diff --git a/web/src/pages/desktop/menu-phone/settings/tailscale/index.tsx b/web/src/pages/desktop/menu-phone/settings/tailscale/index.tsx new file mode 100644 index 0000000..3e05859 --- /dev/null +++ b/web/src/pages/desktop/menu-phone/settings/tailscale/index.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { Modal } from 'antd'; +import { useSetAtom } from 'jotai/index'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { getTailscaleStatus } from '@/api/network.ts'; +import { isSettingsOpenAtom } from '@/jotai/settings.ts'; + +import { Device } from './device.tsx'; +import { Install } from './install.tsx'; +import { Login } from './login.tsx'; + +type Status = 'notInstall' | 'notLogin' | 'stopped' | 'running'; + +export const Tailscale = () => { + const { t } = useTranslation(); + const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState('notInstall'); + const [name, setName] = useState(''); + const [ip, setIp] = useState(''); + const [account, setAccount] = useState(''); + const [errMsg, setErrMsg] = useState(''); + + function openModal() { + getStatus(); + + setIsModalOpen(true); + setIsSettingsOpen(false); + } + + function getStatus() { + if (isLoading) return; + setIsLoading(true); + + getTailscaleStatus() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setStatus(rsp.data.status); + setName(rsp.data.name); + setIp(rsp.data.ip); + setAccount(rsp.data.account); + }) + .finally(() => { + setIsLoading(false); + }); + } + + return ( + <> +
+ Tailscale +
+ + setIsModalOpen(false)} + > +
+ {isLoading ? ( +
+ + {t('tailscale.loading')} +
+ ) : status === 'notInstall' ? ( + + ) : status === 'notLogin' ? ( + + ) : ( + + )} + + {errMsg && {errMsg}} +
+
+ + ); +}; diff --git a/web/src/pages/desktop/menu-phone/settings/tailscale/install.tsx b/web/src/pages/desktop/menu-phone/settings/tailscale/install.tsx new file mode 100644 index 0000000..1708654 --- /dev/null +++ b/web/src/pages/desktop/menu-phone/settings/tailscale/install.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { DownloadOutlined } from '@ant-design/icons'; +import { Button, Card } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { installTailscale } from '@/api/network.ts'; + +type InstallProps = { + onSuccess: () => void; +}; + +export const Install = ({ onSuccess }: InstallProps) => { + const { t } = useTranslation(); + + const [isInstalling, setIsInstalling] = useState(false); + const [isFailed, setIsFailed] = useState(false); + + function install() { + if (isInstalling) return; + setIsInstalling(true); + + installTailscale() + .then((rsp) => { + if (rsp.code !== 0) { + setIsFailed(true); + return; + } + + onSuccess(); + }) + .finally(() => { + setIsInstalling(false); + }); + } + + return ( + <> + {!isFailed ? ( +
+ {t('tailscale.notInstall')} + + +
+ ) : ( +
+
+ {t('tailscale.failed')} + {t('tailscale.retry')} +
+ + +
    +
  • + {t('tailscale.download')} + + {t('tailscale.package')} + + {t('tailscale.unzip')} +
  • +
  • {t('tailscale.upTailscale')}
  • +
  • {t('tailscale.upTailscaled')}
  • +
  • {t('tailscale.refresh')}
  • +
+
+
+ )} + + ); +}; diff --git a/web/src/pages/desktop/menu-phone/settings/tailscale/login.tsx b/web/src/pages/desktop/menu-phone/settings/tailscale/login.tsx new file mode 100644 index 0000000..d1501fe --- /dev/null +++ b/web/src/pages/desktop/menu-phone/settings/tailscale/login.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { UserSwitchOutlined } from '@ant-design/icons'; +import { Button, Card } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { loginTailscale } from '@/api/network.ts'; + +type LoginProps = { + onSuccess: () => void; +}; + +export const Login = ({ onSuccess }: LoginProps) => { + const { t } = useTranslation(); + + const [isLogging, setIsLogging] = useState(false); + const [loginUrl, setLoginUrl] = useState(''); + const [errMsg, setErrMsg] = useState(''); + + function login() { + if (isLogging) return; + setIsLogging(true); + + loginTailscale() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + // already logged in + if (rsp.data.status === 'running') { + onSuccess(); + return; + } + + const url = rsp.data.url; + if (!url) return; + + setLoginUrl(url); + window.open(url, '_blank'); + setTimeout(() => setLoginUrl(''), 10 * 60 * 1000); + }) + .finally(() => { + setIsLogging(false); + }); + } + + return ( +
+ {t('tailscale.notLogin')} + + {!loginUrl ? ( + + ) : ( + <> + + + {t('tailscale.urlPeriod')} + + + + )} + + {errMsg && {errMsg}} +
+ ); +}; diff --git a/web/src/pages/desktop/menu/settings/virtual-device.tsx b/web/src/pages/desktop/menu-phone/settings/virtual-devices.tsx similarity index 65% rename from web/src/pages/desktop/menu/settings/virtual-device.tsx rename to web/src/pages/desktop/menu-phone/settings/virtual-devices.tsx index c23073b..da8433a 100644 --- a/web/src/pages/desktop/menu/settings/virtual-device.tsx +++ b/web/src/pages/desktop/menu-phone/settings/virtual-devices.tsx @@ -1,14 +1,14 @@ import { useEffect, useState } from 'react'; -import { CheckIcon } from 'lucide-react'; +import { Switch } from 'antd'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/virtual-device'; -export const VirtualDevice = () => { +export const VirtualDevices = () => { const { t } = useTranslation(); const [isNetworkOn, setIsNetworkOn] = useState(false); const [isUSBOn, setIsUSBOn] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [loading, setLoading] = useState(''); // '' | 'network' | 'usb' useEffect(() => { api.getVirtualDevice().then((rsp) => { @@ -23,8 +23,8 @@ export const VirtualDevice = () => { }, []); function update(device: string) { - if (isLoading) return; - setIsLoading(true); + if (loading) return; + setLoading(device); api .updateVirtualDevice(device) @@ -41,25 +41,26 @@ export const VirtualDevice = () => { } }) .finally(() => { - setIsLoading(false); + setLoading(''); }); } return ( <>
update('network')} > - {isNetworkOn && } - {t('virtualDevice.network')} +
{t('virtualDevice.network')}
+
+
update('usb')} > - {isUSBOn && } - {t('virtualDevice.usb')} +
{t('virtualDevice.usb')}
+
); diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index bbf7bf8..b5ca483 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -3,16 +3,16 @@ import { Divider } from 'antd'; import clsx from 'clsx'; import { MenuIcon, XIcon } from 'lucide-react'; -import { Fullscreen } from './fullscreen'; +import { Fullscreen } from './fullscreen.tsx'; import { Image } from './image'; -import { Keyboard } from './keyboard'; -import { Mouse } from './mouse'; -import { Power } from './power'; +import { Keyboard } from './keyboard.tsx'; +import { Mouse } from './mouse.tsx'; +import { Power } from './power.tsx'; import { Screen } from './screen'; import { Script } from './script'; import { Settings } from './settings'; import { Terminal } from './terminal'; -import { Wol } from './wol'; +import { Wol } from './wol.tsx'; export const Menu = () => { const [isMenuOpen, setIsMenuOpen] = useState(true); diff --git a/web/src/pages/desktop/menu/settings/index.tsx b/web/src/pages/desktop/menu/settings/index.tsx index 5170944..7257d43 100644 --- a/web/src/pages/desktop/menu/settings/index.tsx +++ b/web/src/pages/desktop/menu/settings/index.tsx @@ -6,12 +6,12 @@ import { SettingsIcon } from 'lucide-react'; import { isSettingsOpenAtom } from '@/jotai/settings.ts'; import { About } from './about.tsx'; -import { ChangePassword } from './change-password.tsx'; import { Language } from './language.tsx'; import { Logout } from './logout.tsx'; -import { Tailscale } from './tailscale.tsx'; +import { Password } from './password.tsx'; +import { Tailscale } from './tailscale'; import { Update } from './update.tsx'; -import { VirtualDevice } from './virtual-device.tsx'; +import { VirtualDevices } from './virtual-devices.tsx'; export const Settings = () => { const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom); @@ -22,13 +22,15 @@ export const Settings = () => { - + + + + - - + - + ); diff --git a/web/src/pages/desktop/menu/settings/language.tsx b/web/src/pages/desktop/menu/settings/language.tsx index d933ae9..d80552c 100644 --- a/web/src/pages/desktop/menu/settings/language.tsx +++ b/web/src/pages/desktop/menu/settings/language.tsx @@ -34,7 +34,7 @@ export const Language = () => { ); return ( - +
{t('language')} diff --git a/web/src/pages/desktop/menu/settings/change-password.tsx b/web/src/pages/desktop/menu/settings/password.tsx similarity index 92% rename from web/src/pages/desktop/menu/settings/change-password.tsx rename to web/src/pages/desktop/menu/settings/password.tsx index 6429f23..8cdb9d7 100644 --- a/web/src/pages/desktop/menu/settings/change-password.tsx +++ b/web/src/pages/desktop/menu/settings/password.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -export const ChangePassword = () => { +export const Password = () => { const navigate = useNavigate(); const { t } = useTranslation(); diff --git a/web/src/pages/desktop/menu/settings/tailscale.tsx b/web/src/pages/desktop/menu/settings/tailscale.tsx deleted file mode 100644 index 18deb05..0000000 --- a/web/src/pages/desktop/menu/settings/tailscale.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useState } from 'react'; -import { Button, Modal } from 'antd'; -import { useSetAtom } from 'jotai'; -import { LoaderCircleIcon } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -import * as api from '@/api/network'; -import { isSettingsOpenAtom } from '@/jotai/settings.ts'; - -type State = '' | 'installing' | 'installed' | 'failed'; - -export const Tailscale = () => { - const { t } = useTranslation(); - const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isStarting, setIsStarting] = useState(false); - const [state, setState] = useState(''); - const [loginUrl, setLoginUrl] = useState(''); - - function openModal() { - check(); - - setIsModalOpen(true); - setIsSettingsOpen(false); - } - - function check() { - if (['installing', 'installed'].includes(state)) return; - - if (isLoading) return; - setIsLoading(true); - - api - .getTailscale() - .then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } - - if (rsp.data.exist) { - setState('installed'); - } - }) - .finally(() => { - setIsLoading(false); - }); - } - - function install() { - if (state === 'installing') return; - setState('installing'); - - api - .installTailscale() - .then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - setState('failed'); - return; - } - - setState('installed'); - }) - .catch(() => { - setState('failed'); - }); - } - - function run() { - if (state !== 'installed') return; - - if (isStarting) return; - setIsStarting(true); - - setLoginUrl(''); - - api - .runTailscale() - .then((rsp) => { - if (rsp.code !== 0) { - console.log(rsp.msg); - return; - } - - if (rsp.data.url) { - updateLoginUrl(rsp.data.url); - } - }) - .finally(() => { - setIsStarting(false); - }); - } - - function updateLoginUrl(url: string) { - setLoginUrl(url); - - window.open(url, '_blank'); - - setTimeout(() => setLoginUrl(''), 10 * 60 * 1000); - } - - return ( - <> -
- Tailscale -
- - setIsModalOpen(false)} - > -
- {isLoading ? ( -
- - {t('tailscale.loading')} -
- ) : ( - <> - {state === '' && ( -
- {t('tailscale.notInstalled')} -
- -
-
- )} - - {state === 'installing' && ( -
- - {t('tailscale.installing')} -
- )} - - {state === 'installed' && ( -
- {loginUrl ? ( - <> - - {loginUrl} - - {t('tailscale.urlPeriod')} - - ) : ( - <> - {t('tailscale.installed')} -
- -
- - )} -
- )} - - {state === 'failed' && ( -
-
- {t('tailscale.failed')} - {t('tailscale.retry')} -
- -
    -
  • - {t('tailscale.download')} - - {t('tailscale.package')} - - {t('tailscale.unzip')} -
  • -
  • {t('tailscale.upTailscale')}
  • -
  • {t('tailscale.upTailscaled')}
  • -
  • {t('tailscale.refresh')}
  • -
-
- )} - - )} -
-
- - ); -}; diff --git a/web/src/pages/desktop/menu/settings/tailscale/device.tsx b/web/src/pages/desktop/menu/settings/tailscale/device.tsx new file mode 100644 index 0000000..894c1d8 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/tailscale/device.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from 'react'; +import { LogoutOutlined } from '@ant-design/icons'; +import { Button, Divider, Switch } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { logoutTailscale, updateTailscaleStatus } from '@/api/network.ts'; + +type DeviceProps = { + status: string; + name: string; + ip: string; + account: string; + setStatus: (status: 'notLogin') => void; +}; + +export const Device = ({ status, name, ip, account, setStatus }: DeviceProps) => { + const { t } = useTranslation(); + + const [isRunning, setIsRunning] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [showLogoutConfirmation, setShowLogoutConfirmation] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + const [errMsg, setErrMsg] = useState(''); + + useEffect(() => { + setIsRunning(status === 'running'); + }, []); + + function updateStatus() { + if (isUpdating) return; + setIsUpdating(true); + + const command = isRunning ? 'down' : 'up'; + + updateTailscaleStatus(command) + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setIsRunning(rsp.data.status === 'running'); + }) + .finally(() => { + setIsUpdating(false); + }); + } + + function logout() { + if (isLoggingOut) return; + setIsLoggingOut(true); + + logoutTailscale() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setStatus('notLogin'); + }) + .finally(() => { + setIsLoggingOut(false); + }); + } + + return ( +
+
+ {t('tailscale.enable')} + +
+ + +
+ {t('tailscale.deviceName')} + {name} +
+ + +
+ {t('tailscale.deviceIP')} + {ip} +
+ + +
+ {t('tailscale.account')} + {account} +
+ + +
+ {!showLogoutConfirmation ? ( + + ) : ( + + )} +
+ + {errMsg && {errMsg}} +
+ ); +}; diff --git a/web/src/pages/desktop/menu/settings/tailscale/index.tsx b/web/src/pages/desktop/menu/settings/tailscale/index.tsx new file mode 100644 index 0000000..7a81174 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/tailscale/index.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { Modal } from 'antd'; +import { useSetAtom } from 'jotai/index'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { getTailscaleStatus } from '@/api/network.ts'; +import { isSettingsOpenAtom } from '@/jotai/settings.ts'; + +import { Device } from './device.tsx'; +import { Install } from './install.tsx'; +import { Login } from './login.tsx'; + +type Status = 'notInstall' | 'notLogin' | 'stopped' | 'running'; + +export const Tailscale = () => { + const { t } = useTranslation(); + const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState('notInstall'); + const [name, setName] = useState(''); + const [ip, setIp] = useState(''); + const [account, setAccount] = useState(''); + const [errMsg, setErrMsg] = useState(''); + + function openModal() { + getStatus(); + + setIsModalOpen(true); + setIsSettingsOpen(false); + } + + function getStatus() { + if (isLoading) return; + setIsLoading(true); + + getTailscaleStatus() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setStatus(rsp.data.status); + setName(rsp.data.name); + setIp(rsp.data.ip); + setAccount(rsp.data.account); + }) + .finally(() => { + setIsLoading(false); + }); + } + + return ( + <> +
+ Tailscale +
+ + setIsModalOpen(false)} + > +
+ {isLoading ? ( +
+ + {t('tailscale.loading')} +
+ ) : status === 'notInstall' ? ( + + ) : status === 'notLogin' ? ( + + ) : ( + + )} + + {errMsg && {errMsg}} +
+
+ + ); +}; diff --git a/web/src/pages/desktop/menu/settings/tailscale/install.tsx b/web/src/pages/desktop/menu/settings/tailscale/install.tsx new file mode 100644 index 0000000..1708654 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/tailscale/install.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { DownloadOutlined } from '@ant-design/icons'; +import { Button, Card } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { installTailscale } from '@/api/network.ts'; + +type InstallProps = { + onSuccess: () => void; +}; + +export const Install = ({ onSuccess }: InstallProps) => { + const { t } = useTranslation(); + + const [isInstalling, setIsInstalling] = useState(false); + const [isFailed, setIsFailed] = useState(false); + + function install() { + if (isInstalling) return; + setIsInstalling(true); + + installTailscale() + .then((rsp) => { + if (rsp.code !== 0) { + setIsFailed(true); + return; + } + + onSuccess(); + }) + .finally(() => { + setIsInstalling(false); + }); + } + + return ( + <> + {!isFailed ? ( +
+ {t('tailscale.notInstall')} + + +
+ ) : ( +
+
+ {t('tailscale.failed')} + {t('tailscale.retry')} +
+ + +
    +
  • + {t('tailscale.download')} + + {t('tailscale.package')} + + {t('tailscale.unzip')} +
  • +
  • {t('tailscale.upTailscale')}
  • +
  • {t('tailscale.upTailscaled')}
  • +
  • {t('tailscale.refresh')}
  • +
+
+
+ )} + + ); +}; diff --git a/web/src/pages/desktop/menu/settings/tailscale/login.tsx b/web/src/pages/desktop/menu/settings/tailscale/login.tsx new file mode 100644 index 0000000..d1501fe --- /dev/null +++ b/web/src/pages/desktop/menu/settings/tailscale/login.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { UserSwitchOutlined } from '@ant-design/icons'; +import { Button, Card } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { loginTailscale } from '@/api/network.ts'; + +type LoginProps = { + onSuccess: () => void; +}; + +export const Login = ({ onSuccess }: LoginProps) => { + const { t } = useTranslation(); + + const [isLogging, setIsLogging] = useState(false); + const [loginUrl, setLoginUrl] = useState(''); + const [errMsg, setErrMsg] = useState(''); + + function login() { + if (isLogging) return; + setIsLogging(true); + + loginTailscale() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + // already logged in + if (rsp.data.status === 'running') { + onSuccess(); + return; + } + + const url = rsp.data.url; + if (!url) return; + + setLoginUrl(url); + window.open(url, '_blank'); + setTimeout(() => setLoginUrl(''), 10 * 60 * 1000); + }) + .finally(() => { + setIsLogging(false); + }); + } + + return ( +
+ {t('tailscale.notLogin')} + + {!loginUrl ? ( + + ) : ( + <> + + + {t('tailscale.urlPeriod')} + + + + )} + + {errMsg && {errMsg}} +
+ ); +}; diff --git a/web/src/pages/desktop/menu-phone/settings/virtual-device.tsx b/web/src/pages/desktop/menu/settings/virtual-devices.tsx similarity index 65% rename from web/src/pages/desktop/menu-phone/settings/virtual-device.tsx rename to web/src/pages/desktop/menu/settings/virtual-devices.tsx index c23073b..da8433a 100644 --- a/web/src/pages/desktop/menu-phone/settings/virtual-device.tsx +++ b/web/src/pages/desktop/menu/settings/virtual-devices.tsx @@ -1,14 +1,14 @@ import { useEffect, useState } from 'react'; -import { CheckIcon } from 'lucide-react'; +import { Switch } from 'antd'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/virtual-device'; -export const VirtualDevice = () => { +export const VirtualDevices = () => { const { t } = useTranslation(); const [isNetworkOn, setIsNetworkOn] = useState(false); const [isUSBOn, setIsUSBOn] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [loading, setLoading] = useState(''); // '' | 'network' | 'usb' useEffect(() => { api.getVirtualDevice().then((rsp) => { @@ -23,8 +23,8 @@ export const VirtualDevice = () => { }, []); function update(device: string) { - if (isLoading) return; - setIsLoading(true); + if (loading) return; + setLoading(device); api .updateVirtualDevice(device) @@ -41,25 +41,26 @@ export const VirtualDevice = () => { } }) .finally(() => { - setIsLoading(false); + setLoading(''); }); } return ( <>
update('network')} > - {isNetworkOn && } - {t('virtualDevice.network')} +
{t('virtualDevice.network')}
+
+
update('usb')} > - {isUSBOn && } - {t('virtualDevice.usb')} +
{t('virtualDevice.usb')}
+
); diff --git a/web/src/pages/desktop/menu/terminal/nanokvm.tsx b/web/src/pages/desktop/menu/terminal/nanokvm.tsx index 948354b..1ad2c2c 100644 --- a/web/src/pages/desktop/menu/terminal/nanokvm.tsx +++ b/web/src/pages/desktop/menu/terminal/nanokvm.tsx @@ -1,4 +1,4 @@ -import { TerminalIcon } from 'lucide-react'; +import { SquareTerminalIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; type NanokvmProps = { @@ -15,10 +15,10 @@ export const Nanokvm = ({ setIsPopoverOpen }: NanokvmProps) => { return (
- + {t('terminal.nanokvm')}
); diff --git a/web/src/pages/desktop/menu/terminal/serial-port.tsx b/web/src/pages/desktop/menu/terminal/serial-port.tsx index cbbb82e..c9c192c 100644 --- a/web/src/pages/desktop/menu/terminal/serial-port.tsx +++ b/web/src/pages/desktop/menu/terminal/serial-port.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useState } from 'react'; import { Button, Input, InputNumber, Modal, Radio, RadioChangeEvent } from 'antd'; import { useSetAtom } from 'jotai'; -import { TerminalIcon } from 'lucide-react'; +import { SquareTerminalIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts'; @@ -56,10 +56,10 @@ export const SerialPort = ({ setIsPopoverOpen }: SerialPortProps) => { return ( <>
- + {t('terminal.serial')}