feat: support reboot

perf: update menu bar draggable bounds
perf: unify logout logic and styles in settings
This commit is contained in:
wj-xiao
2025-03-10 19:04:18 +08:00
parent 21b119294c
commit 9aebabfd59
16 changed files with 223 additions and 49 deletions

View File

@@ -53,16 +53,16 @@ jwt:
revokeTokensOnLogout: true
# Address for custom STUN server
# Note: You can disable the STUN service by setting it to 'disable' (e.g., in a LAN environment)
stun: stun.l.google.com:19302
# Address and authentication for custom TURN server
turn:
turnAddr: turn.cloudflare.com:3478
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
```
## Compile & Deploy
Note: Use Linux operating system (x86-64). This build process is not compatible with ARM, Windows or macOS.

View File

@@ -50,10 +50,12 @@ jwt:
# 在帐号登出时是否使所有 jwt token 失效。默认为 true
revokeTokensOnLogout: true
# 自定义 STUN 服务器的地址
# 注意可以设置为“disable”来禁用 STUN 服务(例如在局域网环境中使用时)
stun: stun.l.google.com:19302
turn:
turnAddr: turn.cloudflare.com:3478
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
```

View File

@@ -43,4 +43,6 @@ func vmRouter(r *gin.Engine) {
api.GET("/vm/mdns", service.GetMdnsState) // get mDNS state
api.POST("/vm/mdns/enable", service.EnableMdns) // enable mDNS
api.POST("/vm/mdns/disable", service.DisableMdns) // disable mDNS
api.POST("/vm/system/reboot", service.Reboot) // reboot system
}

View File

@@ -71,6 +71,7 @@ func (s *Service) DisableMdns(c *gin.Context) {
}
_ = os.Remove(AvahiDaemonPid)
_ = os.Remove(AvahiDaemonScript)
rsp.OkRsp(c)
log.Debugf("avahi-daemon stopped")

View File

@@ -0,0 +1,25 @@
package vm
import (
"NanoKVM-Server/proto"
"os/exec"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
func (s *Service) Reboot(c *gin.Context) {
var rsp proto.Response
log.Println("reboot system...")
err := exec.Command("reboot").Run()
if err != nil {
rsp.ErrRsp(c, -1, "operation failed")
log.Errorf("failed to reboot: %s", err)
return
}
rsp.OkRsp(c)
log.Debug("system rebooted")
}

View File

@@ -91,3 +91,8 @@ export function enableMdns() {
export function disableMdns() {
return http.post('/api/vm/mdns/disable');
}
// reboot
export function reboot() {
return http.post('/api/vm/system/reboot');
}

View File

@@ -207,7 +207,11 @@ const en = {
disk: 'Virtual Disk',
diskDesc: 'Mount virtual U-disk on the remote host',
network: 'Virtual Network',
networkDesc: 'Mount virtual network card on the remote host'
networkDesc: 'Mount virtual network card on the remote host',
reboot: 'Reboot',
rebootDesc: 'Are you sure you want to reboot NanoKVM?',
okBtn: 'Yes',
cancelBtn: 'No'
},
tailscale: {
title: 'Tailscale',
@@ -241,7 +245,7 @@ const en = {
deviceIP: 'Device IP',
account: 'Account',
logout: 'Logout',
logout2: 'Confirm logout',
logoutDesc: 'Are you sure you want to logout?',
uninstall: 'Uninstall Tailscale',
okBtn: 'Yes',
cancelBtn: 'No'
@@ -261,7 +265,10 @@ const en = {
webAccount: 'Web Account Name',
password: 'Password',
updateBtn: 'Change',
logoutBtn: 'Logout'
logoutBtn: 'Logout',
logoutDesc: 'Are you sure you want to logout?',
okBtn: 'Yes',
cancelBtn: 'No'
}
},
error: {

View File

@@ -233,7 +233,7 @@ const zh = {
deviceIP: '设备地址',
account: '账号',
logout: '退出',
logout2: '确退出?',
logoutDesc: '确定要退出',
uninstall: '卸载 Tailscale',
okBtn: '确认',
cancelBtn: '取消'
@@ -253,8 +253,22 @@ const zh = {
webAccount: '网页帐号',
password: '密码',
updateBtn: '修改',
logoutBtn: '退出'
logoutBtn: '退出',
logoutDesc: '确定要退出吗?',
confirm: '确定',
cancel: '取消'
}
},
error: {
title: '我们遇到了问题',
refresh: '刷新'
},
fullscreen: {
toggle: '切换全屏'
},
menu: {
collapse: '收起',
expand: '展开'
}
}
};

View File

@@ -26,16 +26,45 @@ export const Menu = () => {
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [bounds, setBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
const nodeRef = useRef<any>(null);
useEffect(() => {
// disabled menu items
const items = getMenuDisabledItems();
setMenuDisabledItems(items);
// react-draggable bounds
const handleResize = () => {
if (!nodeRef.current) return;
const elementRect = nodeRef.current.getBoundingClientRect();
const width = (window.innerWidth - elementRect.width) / 2;
setBounds({
left: -width,
top: -10,
right: width,
bottom: window.innerHeight - elementRect.height - 10
});
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return (
<Draggable nodeRef={nodeRef} handle="strong" positionOffset={{ x: '-50%', y: '0%' }}>
<Draggable
nodeRef={nodeRef}
bounds={bounds}
handle="strong"
positionOffset={{ x: '-50%', y: '0%' }}
>
<div ref={nodeRef} className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
<div className="sticky top-[10px] flex w-full justify-center">
<div

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { LogoutOutlined } from '@ant-design/icons';
import { Button, Divider } from 'antd';
import { Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { removeToken } from '@/lib/cookie.ts';
import { Logout } from './logout.tsx';
export const Account = () => {
const { t } = useTranslation();
@@ -25,18 +25,6 @@ export const Account = () => {
navigate('/auth/password');
}
async function logout() {
api.logout().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
removeToken();
navigate('/auth/login');
});
}
return (
<>
<div className="text-base font-bold">{t('settings.account.title')}</div>
@@ -60,11 +48,7 @@ export const Account = () => {
</div>
<Divider />
<div className="flex justify-center">
<Button type="primary" danger icon={<LogoutOutlined />} onClick={logout}>
{t('settings.account.logoutBtn')}
</Button>
</div>
<Logout />
</>
);
};

View File

@@ -0,0 +1,40 @@
import { LogoutOutlined } from '@ant-design/icons';
import { Button, Popconfirm } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { removeToken } from '@/lib/cookie.ts';
export const Logout = () => {
const { t } = useTranslation();
const navigate = useNavigate();
function logout() {
api.logout().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
removeToken();
navigate('/auth/login');
});
}
return (
<div className="flex justify-center pt-3">
<Popconfirm
placement="bottom"
title={t('settings.account.logoutDesc')}
okText={t('settings.account.okBtn')}
cancelText={t('settings.account.cancelBtn')}
onConfirm={logout}
>
<Button danger type="primary" size="large" shape="round" icon={<LogoutOutlined />}>
{t('settings.account.logoutBtn')}
</Button>
</Popconfirm>
</div>
);
};

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Mdns } from './mdns.tsx';
import { Oled } from './oled.tsx';
import { Reboot } from './reboot.tsx';
import { Ssh } from './ssh.tsx';
import { VirtualDevices } from './virtual-devices.tsx';
import { Wifi } from './wifi.tsx';
@@ -22,6 +23,9 @@ export const Device = () => {
<Mdns />
<VirtualDevices />
</div>
<Divider />
<Reboot />
</>
);
};

View File

@@ -0,0 +1,59 @@
import { useState } from 'react';
import { ReloadOutlined } from '@ant-design/icons';
import { Button, Popconfirm } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm.ts';
export const Reboot = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
function reboot() {
if (isLoading) return;
setIsLoading(true);
const timeoutId = setTimeout(() => {
window.location.reload();
}, 30000);
api
.reboot()
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
setIsLoading(false);
clearTimeout(timeoutId);
}
})
.catch((err) => {
console.log(err);
setIsLoading(false);
clearTimeout(timeoutId);
});
}
return (
<div className="flex justify-center pt-3">
<Popconfirm
placement="bottom"
title={t('settings.device.rebootDesc')}
okText={t('settings.device.okBtn')}
cancelText={t('settings.device.cancelBtn')}
onConfirm={reboot}
>
<Button
danger
type="primary"
size="large"
shape="round"
loading={isLoading}
icon={<ReloadOutlined />}
>
{t('settings.device.reboot')}
</Button>
</Popconfirm>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { LogoutOutlined } from '@ant-design/icons';
import { Button, Switch } from 'antd';
import { Button, Divider, Popconfirm, Switch } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/extensions/tailscale.ts';
@@ -17,7 +17,6 @@ export const Device = ({ status, onLogout }: DeviceProps) => {
const [isRunning, setIsRunning] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isConfirmation, setIsConfirmation] = useState(false);
const [isLogging, setIsLogging] = useState(false);
const [errMsg, setErrMsg] = useState('');
@@ -82,30 +81,27 @@ export const Device = ({ status, onLogout }: DeviceProps) => {
<span>{t('settings.tailscale.account')}</span>
<span>{status.account}</span>
</div>
<Divider />
<div className="flex justify-center pt-10">
{!isConfirmation ? (
<Button
type="primary"
size="large"
shape="round"
icon={<LogoutOutlined />}
onClick={() => setIsConfirmation(true)}
>
{t('settings.tailscale.logout')}
</Button>
) : (
<div className="flex justify-center pt-3">
<Popconfirm
placement="bottom"
title={t('settings.tailscale.logoutDesc')}
okText={t('settings.tailscale.okBtn')}
cancelText={t('settings.tailscale.cancelBtn')}
onConfirm={logout}
>
<Button
danger
type="primary"
size="large"
shape="round"
icon={<LogoutOutlined />}
loading={isLogging}
onClick={logout}
>
{t('settings.tailscale.logout2')}
{t('settings.tailscale.logout')}
</Button>
)}
</Popconfirm>
</div>
{errMsg && <span className="text-red-500">{errMsg}</span>}

View File

@@ -43,7 +43,13 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
icon={<DownloadOutlined />}
subTitle={t('settings.tailscale.notInstall')}
extra={
<Button type="primary" size="large" loading={state === 'installing'} onClick={install}>
<Button
key="install"
type="primary"
size="large"
loading={state === 'installing'}
onClick={install}
>
{state === 'installing'
? t('settings.tailscale.installing')
: t('settings.tailscale.install')}
@@ -57,7 +63,7 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
subTitle={t('settings.tailscale.retry')}
icon={<InfoCircleOutlined />}
extra={
<Card styles={{ body: { padding: 0 } }}>
<Card key="tips" styles={{ body: { padding: 0 } }}>
<ul className="list-decimal text-left font-mono text-sm text-neutral-300">
<li>
{t('settings.tailscale.download')}

View File

@@ -106,7 +106,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
title={`${currentVersion} -> ${latestVersion}`}
subTitle={t('settings.update.available')}
extra={[
<Button type="primary" onClick={update}>
<Button key="confirm" type="primary" onClick={update}>
{t('settings.update.confirm')}
</Button>
]}