Compare commits

...

7 Commits

Author SHA1 Message Date
wenjie
aae370fc2b Merge pull request #10 from Stoufiler/add-fr-language
Add french language, use switch case instead of if's, correct one typo
2024-08-19 10:16:35 +08:00
Stoufiler
74a8c9ee42 Add french language, use switch case instead of if's, correct one typo 2024-08-17 20:12:55 +02:00
Caize Wu
dddf3de8db Update README_ZH.md 2024-08-09 20:22:41 +08:00
Caize Wu
4c4aa4270e Update README.md 2024-08-09 20:21:23 +08:00
wj-xiao
a21159aaff create web 2024-08-09 19:45:45 +08:00
Z2Z-GuGu
1e3c131e0d Update readme doc 2024-07-08 10:21:13 +08:00
Z2Z-GuGu
a037767f77 update readme 2024-07-03 19:00:01 +08:00
115 changed files with 12505 additions and 6 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
*.log
pnpm-debug.log*
web/node_modules
web/dist
web/web
.DS_Store
*.local
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.idea
.vscode

View File

@@ -36,8 +36,8 @@ The NanoKVM Full is a full version with a sophisticated case and complete access
| Video encoding | MJPEG, H264(developing) | MJPEG, H264(developing) | MJPEG, H264 |
| Video delay | 90~230ms | 90~230ms | 100~230ms |
| UEFI/BIOS | ✓ | ✓ | ✓ |
| Keyboard and Mouse<br>Virtual HID | ✓ | ✓ | ✓ |
| USB storage <br> Virtual CD-ROM | ✓ | ✓ | ✓ |
| Virtual HID | ✓ | ✓ | ✓ |
| Virtual CD-ROM | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ |
| Wake-on-LAN | ✓ | ✓ | ✓ |
| ATX power control | None,Users can connect by their own | USB interface IO control board | RJ-45 interface IO control board |
@@ -60,9 +60,16 @@ The NanoKVM Lite is constructed by the LicheeRV Nano and HDMItoCSI board, and th
The NanoKVM image is built on LicheeRV Nano SDK and MaixCDK, and is compatible with materials that use the LicheeRV Nano, opposite the KVM software cannot be used with the LicheeRV Nano or other SG2002 products. If you would like to build an HDMI input application on LicheeRV Nano or MaixCam, please contact us for technical support.
+ [HDMItoCSI Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
+ [NanoKVM-A Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
+ [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
+ [NanoKVM img](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
## Where to buy
* [Aliexpress(global except USA&Russia)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [Preorder (anyother country that not support in Aliexpress or Taobao)](https://sipeed.com/nanokvm)
## Community

View File

@@ -36,8 +36,8 @@ NanoKVM Full 为完整版配置,带精致外壳和完整配件,内置开机
| 视频编码 | MJPEG, H264(developing) | MJPEG, H264(developing) | MJPEG, H264 |
| 视频延迟 | 90~230ms | 90~230ms | 100~230ms |
| UEFI/BIOS | ✓ | ✓ | ✓ |
| 模拟USB键鼠 <br>Virtual HID | ✓ | ✓ | ✓ |
| 模拟USB存储 <br> Virtual CD-ROM | ✓ | ✓ | ✓ |
| 模拟USB键鼠 | ✓ | ✓ | ✓ |
| 模拟USB存储 | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ |
| Wake-on-LAN | ✓ | ✓ | ✓ |
| ATX电源控制 | 无,用户可自行连接 | USB接口IO控制板 | RJ45接口IO控制板 |
@@ -60,9 +60,16 @@ NanoKVM Lite 由 LicheeRV Nano E 和 HDMItoCSI 小板构成,NanoKVM FULL 在 N
NanoKVM 镜像在LicheeRV Nano SDK 和 MaixCDK 基础上构建,可以兼容使用 LicheeRV Nano 的资料,反之LicheeRV Nano 或其他 SG2002 产品无法使用KVM软件。如果您想在 LicheeRV Nano 或 MaixCam 上构建 HDMI输入相关应用,请与我们联系,以获得技术支持。
+ [HDMItoCSI 板原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
+ [NanoKVM-A 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
+ [NanoKVM-B 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
+ [NanoKVM 镜像下载](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
## 购买渠道
* [Aliexpress(全球,除了美国和俄罗斯)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [Preorder (其它不支持淘宝和速卖通的国家)](https://sipeed.com/nanokvm)
## 社区

9
web/.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

20
web/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

1
web/.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.hbs

35
web/.prettierrc.yaml Normal file
View File

@@ -0,0 +1,35 @@
singleQuote: true
trailingComma: none
printWidth: 100
tabWidth: 2
bracketSpacing: true
importOrder:
- ^(react/(.*)$)|^(react$)
- ^(next/(.*)$)|^(next$)
- <THIRD_PARTY_MODULES>
- ''
- ^types$
- ^@/assets(.*)$
- ^@/api/(.*)$
- ^@/types$
- ^@/lib/(.*)$
- ^@/jotai/(.*)$
- ^@/hooks/(.*)$
- ^@/components/(.*)$
- ^@/pages/(.*)$
- ^@/styles/(.*)$
- ''
- '^[./]'
importOrderSeparation: false
importOrderSortSpecifiers: true
importOrderBuiltinModulesToTop: true
importOrderParserPlugins:
- typescript
- jsx
- tsx
- decorators-legacy
importOrderMergeDuplicateImports: true
importOrderCombineTypeAndValueImports: true
plugins:
- '@ianvs/prettier-plugin-sort-imports'
- prettier-plugin-tailwindcss

34
web/README.md Normal file
View File

@@ -0,0 +1,34 @@
# NanoKVM Frontend
## Structure
```shell
src
├── api // backend api
├── assets // static resources
├── components // public components
├── i18n // i18n
├── jotai // Global jotai variables
├── lib // lib
├── pages // web pages
│ ├── auth // login and password
│ ├── desktop // remote desktop
│ └── terminal // web terminal
├── router.tsx // routers
└── types // types
```
## Deployment
Build:
```shell
cd web
pnpm install
pnpm build
```
Once compilation is complete, the `dist` folder will be generated. Rename the folder to `web` and upload it to the `/kvmapp/server/` directory in NanoKVM.
Then, restart the service by executing `/etc/init.d/S95nanokvm` in NanoKVM.
Additionally, browser may have old version cache. If you can't open the page, try a force refresh or clear the cache.

36
web/README_ZH.md Normal file
View File

@@ -0,0 +1,36 @@
# NanoKVM 前端页面
## 目录结构
```shell
src
├── api // 后端接口
├── assets // 资源文件
├── components // 公共组件
├── i18n // 多语言
├── jotai // 全局 jotai 变量
├── lib // lib
├── pages // 页面
│ ├── auth // 鉴权页面
│ ├── desktop // 远程桌面
│ └── terminal // 终端
├── router.tsx // 路由
└── types // 类型定义
```
## 部署
编译:
```shell
cd web
pnpm install
pnpm build
```
编译完成后会生成 `dist` 文件夹,将该文件夹重命名为 `web` 并上传到 NanoKVM 的 `/kvmapp/server/` 目录下。
然后在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务即可。
注意:更新 web 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/sipeed.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NanoKVM</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

62
web/package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.3.7",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.19.2",
"axios": "^1.7.2",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.11.5",
"jotai": "^2.9.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.408.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.2",
"react-responsive": "^10.0.0",
"react-router-dom": "^6.23.1",
"react-simple-keyboard": "^3.7.124",
"semver": "^7.6.3",
"vaul": "^0.9.1",
"websocket": "^1.0.35"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/semver": "^7.5.8",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^7.10.0",
"@typescript-eslint/parser": "^7.10.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}

5130
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
web/public/sipeed.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

17
web/src/api/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import { http } from '@/lib/http';
export function login(username: string, password: string) {
const data = {
username,
password
};
return http.post('/api/auth/login', data);
}
export function changePassword(username: string, password: string) {
const data = {
username,
password
};
return http.post('/api/auth/password', data);
}

25
web/src/api/firmware.ts Normal file
View File

@@ -0,0 +1,25 @@
import { http } from '@/lib/http.ts';
// get firmware version
export function getVersion() {
return http.get('/api/firmware/version');
}
// update firmware to latest version
export function update() {
return http.request({
method: 'post',
url: '/api/firmware/update',
timeout: 3 * 60 * 1000
});
}
// check if the lib exists
export function getLib() {
return http.get('/api/firmware/lib');
}
// update lib
export function updateLib() {
return http.post('/api/firmware/lib/update');
}

37
web/src/api/network.ts Normal file
View File

@@ -0,0 +1,37 @@
import { http } from '@/lib/http.ts';
// wake on lan
export function wol(mac: string) {
const data = {
mac
};
return http.post('/api/network/wol', data);
}
// get wake-on-lan macs history
export function getWolMacs() {
return http.get('/api/network/wol/mac');
}
export function deleteWolMac(mac: string) {
return http.request({
method: 'delete',
url: '/api/network/wol/mac',
data: { mac }
});
}
// 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');
}

37
web/src/api/script.ts Normal file
View File

@@ -0,0 +1,37 @@
import { http } from '@/lib/http.ts';
export function uploadScript(formData: FormData) {
return http.request({
url: '/api/vm/script/upload',
method: 'post',
headers: {
'Content-Type': 'multipart/form-data'
},
data: formData
});
}
export function runScript(name: string, type: string) {
const data = {
name,
type
};
return http.request({
url: '/api/vm/script/run',
method: 'post',
data
});
}
export function getScripts() {
return http.get('/api/vm/script');
}
export function deleteScript(name: string) {
return http.request({
url: '/api/vm/script',
method: 'delete',
data: { name }
});
}

24
web/src/api/storage.ts Normal file
View File

@@ -0,0 +1,24 @@
import { http } from '@/lib/http.ts';
// get image list
export function getImages() {
return http.get('/api/storage/images');
}
// get mounted image
export function getMountedImage() {
return http.get('/api/storage/images/mounted');
}
// mount/unmount image
export function mountImage(file?: string) {
const data = {
file: file ? file : ''
};
return http.post('/api/storage/image/mount', data);
}
// reset hid
export function resetHid() {
return http.post('/api/storage/hid/reset');
}

16
web/src/api/stream.ts Normal file
View File

@@ -0,0 +1,16 @@
import { http } from '@/lib/http.ts';
// get whether the frame detect is enabled
export function getFrameDetect() {
return http.get('/api/stream/mjpeg/detect');
}
// enable/disable frame detect
export function updateFrameDetect() {
return http.post('/api/stream/mjpeg/detect');
}
// pause frame detect for a while (prevent a black screen when opening the page for the first time)
export function stopFrameDetect() {
return http.post('/api/stream/mjpeg/detect/stop');
}

View File

@@ -0,0 +1,15 @@
import { http } from '@/lib/http.ts';
// get virtual devices status
export function getVirtualDevice() {
return http.get('/api/vm/device/virtual');
}
// mount/unmount virtual device
export function updateVirtualDevice(device: string) {
const data = {
device
};
return http.post('/api/vm/device/virtual', data);
}

29
web/src/api/vm.ts Normal file
View File

@@ -0,0 +1,29 @@
import { http } from '@/lib/http.ts';
// get NanoKVM information
export function getInfo() {
return http.get('/api/vm/info');
}
// set gpio value
export function setGpio(type: string, duration: number) {
const data = {
type,
duration
};
return http.post('/api/vm/gpio', data);
}
// get led gpio enabled
export function getLedGpio() {
return http.get('/api/vm/gpio/led');
}
// update screen arguments
export function updateScreen(type: string, value: number) {
const data = {
type,
value
};
return http.post('/api/vm/screen', data);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-x"><path d="m14.5 12.5-5-5"/><path d="m9.5 12.5 5-5"/><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M12 17v4"/><path d="M8 21h8"/></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
background: #000;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #000000;
}
::-webkit-scrollbar-thumb {
background: #555;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}

View File

@@ -0,0 +1,97 @@
.keyboardContainer {
display: flex;
background-color: #e5e5e5;
justify-content: center;
margin: 0 auto;
border-radius: 0 5px;
}
.simple-keyboard.hg-theme-default {
display: inline-block;
}
.simple-keyboard-main.simple-keyboard {
width: 640px;
min-width: 640px;
background: none;
}
.simple-keyboard-main.simple-keyboard .hg-button {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.simple-keyboard-main.simple-keyboard .hg-row:first-child {
margin-bottom: 10px;
}
.simple-keyboard-arrows.simple-keyboard {
align-self: flex-end;
background: none;
}
.simple-keyboard .hg-button.selectedButton {
background: rgba(5, 25, 70, 0.53);
color: white;
}
.simple-keyboard .hg-button.emptySpace {
pointer-events: none;
background: none;
border: none;
box-shadow: none;
}
.simple-keyboard-arrows .hg-row {
justify-content: center;
}
.simple-keyboard-arrows .hg-button {
width: 50px;
flex-grow: 0;
justify-content: center;
display: flex;
align-items: center;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.controlArrows {
display: flex;
align-items: center;
justify-content: space-between;
flex-flow: column;
}
.simple-keyboard-control.simple-keyboard {
background: none;
}
.simple-keyboard-control.simple-keyboard .hg-row:first-child {
margin-bottom: 10px;
}
.simple-keyboard-control .hg-button {
width: 50px;
flex-grow: 0;
justify-content: center;
display: flex;
align-items: center;
font-size: 14px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.hg-button.hg-functionBtn.hg-button-space {
width: 350px;
}
.simple-keyboard.hg-layout-default .hg-button.hg-highlight {
background: rgb(37 99 235);
border-bottom: 1px solid #2563eb;
box-shadow: 0 1px 3px 0 rgb(37 99 235 / 0.1), 0 1px 2px -1px rgb(37 99 235 / 0.1);
color: white;
}
.simple-keyboard.hg-layout-default .hg-button.hg-double{
text-align: center;
font-size: 14px;
line-height: 16px;
}

View File

@@ -0,0 +1,14 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { existToken } from '@/lib/cookie.ts';
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
const hasToken = existToken();
if (!hasToken) {
return <Navigate to={'/auth/login'} replace />;
}
return children;
};

View File

@@ -0,0 +1,20 @@
import { Helmet, HelmetData } from 'react-helmet-async';
type HeadProps = {
title?: string;
description?: string;
};
const helmetData = new HelmetData({});
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
return (
<Helmet
helmetData={helmetData}
title={title ? `${title} - NanoKVM` : undefined}
defaultTitle="NanoKVM"
>
<meta name="description" content={description} />
</Helmet>
);
};

View File

@@ -0,0 +1,17 @@
import { Button } from 'antd';
export const MainError = () => {
return (
<div
className="flex h-screen w-screen flex-col items-center justify-center space-y-5"
role="alert"
>
<h2 className="text-lg font-semibold text-red-500">
Sorry, there seems to be some problems :(
</h2>
<Button type="primary" danger onClick={() => window.location.assign(window.location.origin)}>
Refresh
</Button>
</div>
);
};

View File

@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
export const Root = () => {
return (
<div className="h-screen w-screen">
<Outlet />
</div>
);
};

144
web/src/i18n/en.ts Normal file
View File

@@ -0,0 +1,144 @@
export const en = {
translation: {
language: 'Language',
changePassword: 'Change Password',
logout: 'Logout',
keyboard: 'Keyboard',
images: 'Images',
loading: 'Loading',
empty: 'Nothing Found',
settings: 'Settings',
showMouse: 'Show mouse',
hideMouse: 'Hide mouse',
power: 'Power',
reset: 'Reset',
powerShort: 'Power (short click)',
powerLong: 'Power (long click)',
hddLed: 'HDD LED',
checkLibFailed: 'Failed to check runtime library, please try again',
updateLibFailed: 'Failed to update runtime library, please try again',
updatingLib: 'Updating runtime library. Please refresh the page after updating.',
checkForUpdate: 'Check for Update',
head: {
desktop: 'Remote Desktop',
login: 'Login',
changePassword: 'Change Password',
terminal: 'Terminal'
},
auth: {
login: 'Login',
placeholderUsername: 'Please enter username',
placeholderPassword: 'please enter password',
placeholderPassword2: 'please enter password again',
noEmptyUsername: 'username can not be empty',
noEmptyPassword: 'password cant not be empty',
noAccount: 'Failed to get user information, please refresh web page or reset password',
invalidUser: 'invalid username or password',
error: 'unexpected error',
changePassword: 'Change Password',
differentPassword: 'password do not match',
illegalUsername: 'username contains illegal characters',
illegalPassword: 'password contains illegal characters',
forgetPassword: 'Forget Password',
resetPassword: 'Reset Password',
reset1: 'If you have forgotten the password, please follow the steps to reset it:',
reset2: '1. Log into the NanoKVM device via SSH;',
reset3: '2. Delete the file in the device: ',
reset4: '3. Use the default account to login: ',
ok: 'Ok',
cancel: 'Cancel'
},
screen: {
resolution: 'Resolution',
fps: 'FPS',
customizeFps: 'Customize',
quality: 'Quality',
frameDetect: 'Frame Detect',
frameDetectTip:
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen."
},
cursor: {
default: 'Default cursor',
pointer: 'Pointer cursor',
cell: 'Cell cursor',
text: 'Text cursor',
grab: 'Grab cursor',
hide: 'Hide cursor',
mode: 'Mouse mode',
absolute: 'Absolute mode',
relative: 'Relative mode',
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
resetHid: 'Reset HID'
},
script: {
title: 'Script',
upload: 'Upload',
run: 'Run',
runBackground: 'Run Background',
runFailed: 'Run failed',
attention: 'Attention',
delDesc: 'Are you sure to delete this file?',
confirm: 'Yes',
cancel: 'No',
delete: 'Delete',
close: 'Close'
},
terminal: {
title: 'Terminal',
nanokvm: 'NanoKVM Terminal',
serial: 'Serial Port Terminal',
serialPort: 'Serial Port',
serialPortPlaceholder: 'Please enter the serial port',
baudrate: 'Baud rate',
confirm: 'Ok'
},
wol: {
sending: 'Sending command...',
sent: 'Command sent',
input: 'Please enter the MAC',
ok: 'Ok'
},
about: {
title: 'About NanoKVM',
information: 'Information',
ip: 'IP',
mdns: 'mDNS',
firmware: 'Application Version',
image: 'Image Version',
deviceKey: 'Device Key',
queryFailed: 'Query failed',
community: 'Community'
},
update: {
title: 'Check for Update',
queryFailed: 'Get version failed',
updateFailed: 'Update failed. Please retry.',
isLatest: 'You already have the latest version.',
available: 'An update is available. Are you sure to update?',
updating: 'Update started. Please wait...',
confirm: 'Confirm',
cancel: 'Cancel'
},
virtualDevice: {
network: 'Virtual Network',
usb: 'Virtual USB'
},
tailscale: {
loading: 'Loading...',
notInstalled: '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',
failed: 'Install failed',
retry: 'Please refresh and try again. Or try to install manually',
download: 'Download the',
package: 'installation package',
unzip: 'and unzip it',
upTailscale: 'Upload tailscale to NanoKVM directory /usr/bin/',
upTailscaled: 'Upload tailscaled to NanoKVM directory /usr/sbin/',
refresh: 'Refresh current page'
}
}
};

144
web/src/i18n/fr.ts Normal file
View File

@@ -0,0 +1,144 @@
export const fr = {
translation: {
language: 'Langue',
changePassword: 'Changer le mot de passe',
logout: 'Déconnexion',
keyboard: 'Clavier',
images: 'Images',
loading: 'Chargement',
empty: 'Vide',
settings: 'Réglages',
showMouse: 'Afficher la souris',
hideMouse: 'Masquer la souris',
power: 'Power',
reset: 'Reset',
powerShort: 'Power (appui court)',
powerLong: 'Power (appui long)',
hddLed: 'HDD LED',
checkLibFailed: 'Impossible de vérifier la bibliothèque d\'exécution, veuillez réessayer',
updateLibFailed: 'Impossible de mettre à jour la bibliothèque d\'exécution, veuillez réessayer',
updatingLib: 'Mise à jour de la bibliothèque d\'exécution. Veuillez rafraîchir la page après la mise à jour.',
checkForUpdate: 'Chercher des mises à jour',
head: {
desktop: 'Bureau à distance',
login: 'Connexion',
changePassword: 'Changer le mot de passe',
terminal: 'Terminal'
},
auth: {
login: 'Connexion',
placeholderUsername: 'Veuillez entrer votre nom d\'utilisateur',
placeholderPassword: 'Veuillez entrer votre mot de passe',
placeholderPassword2: 'Veuillez entrer votre mot de passe à nouveau',
noEmptyUsername: 'Le nom d\'utilisateur ne peut pas être vide',
noEmptyPassword: 'Le mot de passe ne peut pas être vide',
noAccount: 'Impossible de récupérer les informations de l\'utilisateur, veuillez rafraîchir la page ou réinitialiser le mot de passe',
invalidUser: 'Nom d\'utilisateur ou mot de passe invalide',
error: 'Erreur inattendue',
changePassword: 'Changer le mot de passe',
differentPassword: 'Les mots de passe ne correspondent pas',
illegalUsername: 'Le nom d\'utilisateur contient des caractères illégaux',
illegalPassword: 'Le mot de passe contient des caractères illégaux',
forgetPassword: 'Mot de passe oublié',
resetPassword: 'Réinitialiser le mot de passe',
reset1: 'Si vous avez oublié le mot de passe, veuillez suivre les étapes pour le réinitialiser:',
reset2: '1. Connectez-vous à l\'appareil NanoKVM via SSH;',
reset3: '2. Supprimez le fichier de l\'appareil: ',
reset4: '3. Utilisez le compte par défaut pour vous connecter: ',
ok: 'Se connecter',
cancel: 'Annuler'
},
screen: {
resolution: 'Résolution',
fps: 'FPS',
customizeFps: 'Personnaliser',
quality: 'Qualité',
frameDetect: 'Frame Detect',
frameDetectTip:
"Calcule la différence entre les images. Arrête la transmission du flux vidéo lorsqu'aucun changement n'est détecté sur l'écran de l'hôte distant"
},
cursor: {
default: 'Curseur par défaut',
pointer: 'Curseur de la souris',
cell: 'Curseur de cellule',
text: 'Curseur de texte',
grab: 'Curseur de poignée',
hide: 'Cacher le curseur',
mode: 'Mode de la souris',
absolute: 'Mode absolu',
relative: 'Mode relatif',
requestPointer: 'Utilisation du mode relatif. Veuillez cliquer sur le bureau pour obtenir le pointeur de la souris.',
resetHid: 'Réinitialiser le périphérique HID',
},
script: {
title: 'Script',
upload: 'Téléverser',
run: 'Executer',
runBackground: 'Executer en arrière-plan',
runFailed: 'Échec de l\'exécution',
attention: 'Attention',
delDesc: 'Etes-vous sûr de vouloir supprimer ce fichier?',
confirm: 'Oui',
cancel: 'Non',
delete: 'Supprimer',
close: 'Fermer'
},
terminal: {
title: 'Terminal',
nanokvm: 'Terminal NanoKVM',
serial: 'Terminal Port Série',
serialPort: 'Port série',
serialPortPlaceholder: 'Veuillez entrer le port série',
baudrate: 'Débit en bauds',
confirm: 'Ok'
},
wol: {
sending: 'Envoi de la commande...',
sent: 'Commande envoyée',
input: 'Veuillez entrer l\'adresse MAC',
ok: 'Ok'
},
about: {
title: 'A propos de NanoKVM',
information: 'Informations',
ip: 'IP',
mdns: 'mDNS',
firmware: 'Version de l\'application',
image: 'Version de l\'image',
deviceKey: 'Clé de l\'appareil',
queryFailed: 'Echec de la requête',
community: 'Communauté',
},
update: {
title: 'Vérifier les mises à jour',
queryFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer.',
updateFailed: 'Mis à jour échouée. Veuillez réessayer.',
isLatest: 'Vous avez déjà la dernière version.',
available: 'Une mise à jour est disponible. Voulez-vous vraiment mettre à jour?',
updating: 'Mise à jour en cours. Veuillez patienter...',
confirm: 'Confirmer',
cancel: 'Annuler'
},
virtualDevice: {
network: 'Réseau virtuel',
usb: 'USB virtuel'
},
tailscale: {
loading: 'Chargement...',
notInstalled: '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',
failed: 'Installation échouée',
retry: 'Veuillez rafraîchir et réessayer. Ou essayez d\'installer manuellement',
download: 'Télécharger le',
package: 'installation package',
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'
}
}
};

24
web/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLanguage } from '@/lib/localstorage.ts';
import { en } from './en';
import { zh } from './zh';
import { fr } from './fr';
const lng = getLanguage();
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources: { zh, en, fr },
lng,
interpolation: {
escapeValue: false // react already safes from xss
}
})
.then();
export default i18n;

143
web/src/i18n/zh.ts Normal file
View File

@@ -0,0 +1,143 @@
export const zh = {
translation: {
currentVersion: '版本',
latestVersion: '最新版本:',
changePassword: '修改密码',
logout: '登出',
keyboard: '键盘',
images: '镜像',
loading: '加载中',
empty: '无镜像文件',
settings: '设置',
showMouse: '显示鼠标',
hideMouse: '隐藏鼠标',
power: '电源',
reset: '重启',
powerShort: '电源(短按)',
powerLong: '电源(长按)',
hddLed: '硬盘指示灯',
checkLibFailed: '检查运行库失败,请重试',
updateLibFailed: '更新运行库失败,请重试',
updatingLib: '正在更新运行库。更新完成后请刷新页面。',
head: {
desktop: '远程桌面',
login: '登录',
changePassword: '修改密码',
terminal: '终端'
},
auth: {
login: '登录',
placeholderUsername: '请输入用户名',
placeholderPassword: '请输入密码',
placeholderPassword2: '请再次输入密码',
noEmptyUsername: '用户名不能为空',
noEmptyPassword: '密码不能为空',
noAccount: '获取用户信息失败,请刷新重试或重置密码',
invalidUser: '用户名或密码错误',
error: '未知错误',
changePassword: '修改密码',
differentPassword: '两次密码不一致',
illegalUsername: '用户名中包含非法字符',
illegalPassword: '密码中包含非法字符',
forgetPassword: '忘记密码',
resetPassword: '重置密码',
reset1: '如果您忘记了登录密码,请按照以下步骤重置密码:',
reset2: '1. 通过 SSH 登录到您的 NanoKVM 设备',
reset3: '2. 删除设备中的文件:',
reset4: '3. 使用默认的账号登录: ',
ok: '确定',
cancel: '取消'
},
screen: {
resolution: '分辨率',
fps: '帧率',
customizeFps: '自定义',
quality: '图像质量',
frameDetect: '帧差检测',
frameDetectTip: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流'
},
cursor: {
default: '默认指针',
pointer: '悬浮指针',
cell: '单元指针',
text: '文本指针',
grab: '抓取指针',
hide: '隐藏指针',
mode: '鼠标模式',
absolute: '绝对模式',
relative: '相对模式',
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
resetHid: '重置 HID'
},
script: {
title: '脚本',
upload: '上传',
run: '运行',
runBackground: '后台运行',
runFailed: '运行失败',
attention: '注意',
delDesc: '确定要删除该文件吗?',
confirm: '确定',
cancel: '取消',
delete: '删除',
close: '关闭'
},
terminal: {
title: '终端',
nanokvm: 'NanoKVM 终端',
serial: '串口终端',
serialPort: '串口',
serialPortPlaceholder: '请输入串口',
baudrate: '波特率',
confirm: '确定'
},
wol: {
sending: '指令发送中...',
sent: '指令已发送',
input: '请输入MAC地址',
ok: '确定'
},
about: {
title: '关于 NanoKVM',
information: '信息',
ip: 'IP',
mdns: 'mDNS',
firmware: '应用版本',
image: '镜像版本',
deviceKey: '设备码',
queryFailed: '查询失败',
community: '社区'
},
update: {
title: '检查更新',
queryFailed: '获取版本号失败',
updateFailed: '更新失败,请重试',
isLatest: '已经是最新版本。',
available: '有新的可用版本,确定要更新吗?',
updating: '更新中,请稍候...',
confirm: '确定',
cancel: '取消'
},
virtualDevice: {
network: '虚拟网口',
usb: '虚拟U盘'
},
tailscale: {
loading: '加载中...',
notInstalled: '未检测到 Tailscale,请先安装',
install: '安装',
installing: '安装中...',
urlPeriod: '该链接10分钟内有效',
installed: 'Tailscale 已安装,请执行登录操作',
login: '登录',
failed: '安装失败',
retry: '请刷新后重试,或尝试手动安装',
download: '下载',
package: '安装包',
unzip: '并解压',
upTailscale: '将 tailscale 文件上传到 /usr/bin/ 目录',
upTailscaled: '将 tailscaled 文件上传到 /usr/sbin/ 目录',
refresh: '刷新页面'
}
}
};

View File

@@ -0,0 +1,7 @@
import { atom } from 'jotai';
// is the keyboard enabled (Disable keyboard events when input is required)
export const isKeyboardEnableAtom = atom(true);
// is the virtual keyboard opened
export const isKeyboardOpenAtom = atom(false);

7
web/src/jotai/mouse.ts Normal file
View File

@@ -0,0 +1,7 @@
import { atom } from 'jotai';
// mouse cursor style
export const mouseStyleAtom = atom('cursor-default');
// mouse mode: absolute or relative
export const mouseModeAtom = atom('absolute');

View File

@@ -0,0 +1,6 @@
import { atom } from 'jotai';
import { Resolution } from '@/types';
// remote host screen resolution
export const resolutionAtom = atom<Resolution | null>(null);

View File

@@ -0,0 +1,4 @@
import { atom } from 'jotai';
// is settings popover opened
export const isSettingsOpenAtom = atom(false);

23
web/src/lib/cookie.ts Normal file
View File

@@ -0,0 +1,23 @@
import Cookies from 'js-cookie';
const COOKIE_TOKEN_KEY = 'nano-kvm-token';
export function existToken() {
const token = Cookies.get(COOKIE_TOKEN_KEY);
return !!token;
}
export function getToken() {
const token = Cookies.get(COOKIE_TOKEN_KEY);
if (!token) return null;
return token;
}
export function setToken(token: string) {
Cookies.set(COOKIE_TOKEN_KEY, token, { expires: 30 });
}
export function removeToken() {
Cookies.remove(COOKIE_TOKEN_KEY);
}

14
web/src/lib/encrypt.ts Normal file
View File

@@ -0,0 +1,14 @@
import CryptoJS from 'crypto-js';
const SECRET_KEY = 'nanokvm-sipeed-2024';
export function encrypt(data: string) {
const dataEncrypt = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
return encodeURIComponent(dataEncrypt);
}
export function decrypt(data: string) {
const bytes = CryptoJS.AES.decrypt(data, SECRET_KEY);
const dataDecrypt = bytes.toString(CryptoJS.enc.Utf8);
return decodeURIComponent(dataDecrypt);
}

74
web/src/lib/http.ts Normal file
View File

@@ -0,0 +1,74 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { removeToken } from '@/lib/cookie.ts';
type Response = {
code: number;
msg: string;
data: any;
};
class Http {
private instance: AxiosInstance;
constructor() {
this.instance = axios.create({
baseURL: this.getBaseURL(),
withCredentials: true,
timeout: 60 * 1000
});
this.setInterceptors();
}
private setInterceptors() {
this.instance.interceptors.request.use((config) => {
if (config.headers) {
config.headers.Accept = 'application/json';
}
return config;
});
this.instance.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
console.log(error);
const code = error.response?.status;
if (code === 401) {
removeToken();
window.location.reload();
}
return Promise.reject(error);
}
);
}
public getBaseURL() {
return `${window.location.protocol}//${window.location.host}`;
}
public get(url: string, params?: any): Promise<Response> {
return this.instance.request({
method: 'get',
url,
params
});
}
public post(url: string, data?: any): Promise<Response> {
return this.instance.request({
method: 'post',
url,
data
});
}
public request(config: AxiosRequestConfig): Promise<Response> {
return this.instance.request(config);
}
}
export const http = new Http();

123
web/src/lib/localstorage.ts Normal file
View File

@@ -0,0 +1,123 @@
import { Resolution } from '@/types';
const LANGUAGE_KEY = 'nano-kvm-language';
const RESOLUTION_KEY = 'nano-kvm-resolution';
const FPS_KEY = 'nano-kvm-fps';
const QUALITY_KEY = 'nano-kvm-quality';
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
type Item = {
value: string;
expiry: number;
};
// set the value with expiration time (unit: milliseconds)
function setWithExpiry(key: string, value: string, ttl: number) {
const now = new Date();
const item: Item = {
value: value,
expiry: now.getTime() + ttl
};
localStorage.setItem(key, JSON.stringify(item));
}
// get the value with expiration time
function getWithExpiry(key: string) {
const itemStr = localStorage.getItem(key);
if (!itemStr) return null;
const item: Item = JSON.parse(itemStr);
const now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(key);
return null;
}
return item.value;
}
export function getLanguage() {
const lang = localStorage.getItem(LANGUAGE_KEY);
if (lang) return lang;
const language = navigator.language;
switch (true) {
case language.indexOf('zh') > -1:
return 'zh';
case language.indexOf('fr') > -1:
return 'fr';
default:
return 'en'; // Default language
}
}
export function setLanguage(language: string) {
localStorage.setItem(LANGUAGE_KEY, language);
}
export function getResolution(): Resolution | null {
const resolution = localStorage.getItem(RESOLUTION_KEY);
if (resolution) {
const obj = JSON.parse(window.atob(resolution));
return obj as Resolution;
}
return null;
}
export function setResolution(resolution: Resolution) {
localStorage.setItem(RESOLUTION_KEY, window.btoa(JSON.stringify(resolution)));
}
export function getFps() {
const fps = localStorage.getItem(FPS_KEY);
return fps ? Number(fps) : null;
}
export function setFps(fps: number) {
localStorage.setItem(FPS_KEY, String(fps));
}
export function getQuality() {
const quality = localStorage.getItem(QUALITY_KEY);
return quality ? Number(quality) : null;
}
export function setQuality(quality: number) {
localStorage.setItem(QUALITY_KEY, String(quality));
}
export function getMouseStyle() {
return localStorage.getItem(MOUSE_STYLE_KEY);
}
export function setMouseStyle(mouse: string) {
localStorage.setItem(MOUSE_STYLE_KEY, mouse);
}
export function getMouseMode() {
return localStorage.getItem(MOUSE_MODE_KEY);
}
export function setMouseMode(mouse: string) {
localStorage.setItem(MOUSE_MODE_KEY, mouse);
}
export function getSkipUpdate() {
const skip = getWithExpiry(SKIP_UPDATE_KEY);
if (skip) {
return Boolean(skip);
}
return false;
}
export function setSkipUpdate(skip: boolean) {
const expiry = 3 * 24 * 60 * 60 * 1000; // 3天
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
}

39
web/src/lib/websocket.ts Normal file
View File

@@ -0,0 +1,39 @@
import { w3cwebsocket as W3cWebSocket } from 'websocket';
class WsClient {
private instance: W3cWebSocket;
constructor() {
const url = this.getUrl();
this.instance = new W3cWebSocket(url);
}
private getUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/api/ws`;
}
public connect() {
this.close();
const url = this.getUrl();
this.instance = new W3cWebSocket(url);
}
public send(data: number[]) {
if (this.instance.readyState !== W3cWebSocket.OPEN) {
return;
}
const message = JSON.stringify(data);
this.instance.send(message);
}
public close() {
if (this.instance.readyState === W3cWebSocket.OPEN) {
this.instance.close();
}
}
}
export const client = new WsClient();

32
web/src/main.tsx Normal file
View File

@@ -0,0 +1,32 @@
import React, { Suspense } from 'react';
import { ConfigProvider, Spin, theme } from 'antd';
import ReactDOM from 'react-dom/client';
import { ErrorBoundary } from 'react-error-boundary';
import { HelmetProvider } from 'react-helmet-async';
import { RouterProvider } from 'react-router-dom';
import { MainError } from './components/main-error.tsx';
import { router } from './router';
import './i18n';
import './assets/styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Suspense
fallback={
<div className="flex h-screen w-screen items-center justify-center">
<Spin size="large" />
</div>
}
>
<ErrorBoundary FallbackComponent={MainError}>
<HelmetProvider>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
<RouterProvider router={router} />
</ConfigProvider>
</HelmetProvider>
</ErrorBoundary>
</Suspense>
</React.StrictMode>
);

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { existToken, setToken } from '@/lib/cookie.ts';
import { encrypt } from '@/lib/encrypt.ts';
import { Head } from '@/components/head.tsx';
import { Tips } from './tips.tsx';
export const Login = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [msg, setMsg] = useState('');
useEffect(() => {
if (existToken()) {
navigate('/', { replace: true });
}
}, []);
useEffect(() => {
if (msg) {
setTimeout(() => setMsg(''), 3000);
}
}, [msg]);
function login(values: any) {
const username = values.username;
const password = encrypt(values.password);
api
.login(username, password)
.then((rsp: any) => {
if (rsp.code !== 0) {
if (rsp.code === -3) {
setMsg(t('auth.noAccount'));
} else if (rsp.code === -4) {
setMsg(t('auth.invalidUser'));
} else {
setMsg(t('auth.error'));
}
return;
}
setMsg('');
setToken(rsp.data.token);
navigate('/', { replace: true });
window.location.reload();
})
.catch(() => {
setMsg(t('auth.error'));
});
}
return (
<>
<Head title={t('head.login')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<h2 className="text-xl font-semibold text-neutral-100">{t('auth.login')}</h2>
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={login}
>
<Form.Item
name="username"
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
>
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder={t('auth.placeholderPassword')}
/>
</Form.Item>
<div className="text-red-500">{msg}</div>
<Form.Item>
<Button type="primary" htmlType="submit" className="w-full">
{t('auth.ok')}
</Button>
</Form.Item>
<div className="flex justify-end pb-4 text-sm">
<Tips />
</div>
</Form>
</div>
</>
);
};

View File

@@ -0,0 +1,57 @@
import { useState } from 'react';
import { Button, Modal, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export const Tips = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = () => {
setIsModalOpen(true);
};
const hideModal = () => {
setIsModalOpen(false);
};
return (
<>
<span
className="cursor-pointer text-neutral-500 underline underline-offset-4"
onClick={showModal}
>
{t('auth.forgetPassword')}
</span>
<Modal
title={t('auth.resetPassword')}
open={isModalOpen}
onCancel={hideModal}
closeIcon={null}
footer={null}
centered={true}
>
<div className="flex flex-col space-y-1 pt-1">
<div>{t('auth.reset1')}</div>
<div>{t('auth.reset2')}</div>
<div>
{t('auth.reset3')}
<Text code={true}>/etc/kvm/pwd</Text>
</div>
<div>
{t('auth.reset4')}
<Text code={true}>admin/admin</Text>
</div>
</div>
<div className="flex justify-center pb-3 pt-10">
<Button type="primary" className="w-24" onClick={hideModal}>
{t('auth.ok')}
</Button>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } 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 { encrypt } from '@/lib/encrypt.ts';
import { Head } from '@/components/head.tsx';
export const Password = () => {
const { t } = useTranslation();
const [msg, setMsg] = useState('');
const navigate = useNavigate();
useEffect(() => {
if (msg) {
setTimeout(() => setMsg(''), 3000);
}
}, [msg]);
function changePassword(values: any) {
if (values.password !== values.password2) {
setMsg(t('auth.differentPassword'));
return;
}
if (!validateString(values.username)) {
setMsg(t('auth.illegalUsername'));
return;
}
if (!validateString(values.password)) {
setMsg('auth.illegalPassword');
return;
}
const username = values.username;
const password = encrypt(values.password);
api
.changePassword(username, password)
.then((rsp: any) => {
if (rsp.code !== 0) {
setMsg(t('auth.error'));
return;
}
removeToken();
navigate('/auth/login', { replace: true });
})
.catch(() => {
setMsg(t('auth.error'));
});
}
function validateString(str: string) {
const regex = /['"\\/]/;
return !regex.test(str);
}
return (
<>
<Head title={t('head.changePassword')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<h2 className="text-xl font-semibold text-neutral-100">{t('auth.changePassword')}</h2>
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={changePassword}
>
<Form.Item
name="username"
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
>
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder={t('auth.placeholderPassword')}
/>
</Form.Item>
<Form.Item
name="password2"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder={t('auth.placeholderPassword2')}
/>
</Form.Item>
<span className="text-red-500">{msg}</span>
<Form.Item>
<div className="flex w-full space-x-2">
<Button type="primary" htmlType="submit" className="w-1/2">
{t('auth.ok')}
</Button>
<Button className="w-1/2" onClick={() => navigate('/')}>
{t('auth.cancel')}
</Button>
</div>
</Form.Item>
</Form>
</div>
</>
);
};

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { message, Spin } from 'antd';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
import * as api from '@/api/firmware.ts';
import { getResolution } from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { resolutionAtom } from '@/jotai/resolution.ts';
import { Head } from '@/components/head.tsx';
import { Keyboard } from './keyboard';
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
import { Menu } from './menu';
import { MenuPhone } from './menu-phone';
import { Mouse } from './mouse';
import { Screen } from './screen';
export const Desktop = () => {
const { t } = useTranslation();
const isBigScreen = useMediaQuery({ minWidth: 850 });
const [messageApi, contextHolder] = message.useMessage();
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
getLib();
const cookieResolution = getResolution();
setResolution(cookieResolution ? cookieResolution : { width: 1280, height: 720 });
const timer = setInterval(() => {
client.send([0]);
}, 1000 * 60);
return () => {
clearInterval(timer);
client.close();
};
}, []);
function getLib() {
api.getLib().then((rsp) => {
if (rsp.code !== 0) {
showMessage(t('checkLibFailed'));
return;
}
if (rsp.data.exist) {
return;
}
downloadLib();
});
}
// 更新 lib
function downloadLib() {
setIsUpdating(true);
api
.updateLib()
.then((rsp) => {
if (rsp.code !== 0) {
showMessage(t('updateLibFailed'));
return;
}
setTimeout(() => {
window.location.reload();
}, 6000);
})
.finally(() => {
setIsUpdating(false);
});
}
function showMessage(content: string) {
messageApi.open({
type: 'warning',
content,
duration: 10,
style: {
marginTop: '8vh'
}
});
}
return (
<>
<Head title={t('head.desktop')} />
{contextHolder}
<Spin spinning={isUpdating} tip={t('updatingLib')} size="large" fullscreen />
{resolution && (
<>
{isBigScreen ? <Menu /> : <MenuPhone />}
<Screen />
<Mouse />
{isKeyboardEnable && <Keyboard />}
</>
)}
<VirtualKeyboard isBigScreen={isBigScreen} />
</>
);
};

View File

@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import { client } from '@/lib/websocket.ts';
import { KeyboardCodes } from './mappings.ts';
export const Keyboard = () => {
const metaRef = useRef(false);
// listen keyboard events
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
// press button
function handleKeyDown(event: any) {
disableEvent(event);
if (event.code === 'MetaLeft' || event.code === 'MetaRight') {
metaRef.current = true;
return;
}
metaRef.current = false;
const code = KeyboardCodes.get(event.code);
if (!code) {
console.log('unknown code: ', event.code);
return;
}
const ctrl = event.ctrlKey ? 1 : 0;
const shift = event.shiftKey ? 1 : 0;
const alt = event.altKey ? 1 : 0;
const meta = event.metaKey ? 1 : 0;
const data = [1, code, ctrl, shift, alt, meta];
client.send(data);
}
// release button
function handleKeyUp(event: any) {
disableEvent(event);
if (metaRef.current) {
client.send([1, KeyboardCodes.get('MetaLeft')!, 0, 0, 0, 1]);
metaRef.current = false;
}
const data = [1, 0, 0, 0, 0, 0];
client.send(data);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
// disable the default keyboard events
function disableEvent(event: any) {
event.preventDefault();
event.stopPropagation();
}
return <></>;
};

View File

@@ -0,0 +1,119 @@
export const KeyboardCodes: Map<string, number> = new Map([
['KeyA', 4],
['KeyB', 5],
['KeyC', 6],
['KeyD', 7],
['KeyE', 8],
['KeyF', 9],
['KeyG', 10],
['KeyH', 11],
['KeyI', 12],
['KeyJ', 13],
['KeyK', 14],
['KeyL', 15],
['KeyM', 16],
['KeyN', 17],
['KeyO', 18],
['KeyP', 19],
['KeyQ', 20],
['KeyR', 21],
['KeyS', 22],
['KeyT', 23],
['KeyU', 24],
['KeyV', 25],
['KeyW', 26],
['KeyX', 27],
['KeyY', 28],
['KeyZ', 29],
['Digit1', 30],
['Digit2', 31],
['Digit3', 32],
['Digit4', 33],
['Digit5', 34],
['Digit6', 35],
['Digit7', 36],
['Digit8', 37],
['Digit9', 38],
['Digit0', 39],
['Enter', 40],
['Escape', 41],
['Backspace', 42],
['Tab', 43],
['Space', 44],
['Minus', 45],
['Equal', 46],
['BracketLeft', 47],
['BracketRight', 48],
['Backslash', 49],
['IntlBackslash', 49],
['Semicolon', 51],
['Quote', 52],
['Backquote', 53],
['KeyTilde', 53],
['Comma', 54],
['Period', 55],
['KeyDot', 55],
['Slash', 56],
['CapsLock', 57],
['F1', 58],
['F2', 59],
['F3', 60],
['F4', 61],
['F5', 62],
['F6', 63],
['F7', 64],
['F8', 65],
['F9', 66],
['F10', 67],
['F11', 68],
['F12', 69],
['F13', 70],
['PrintScreen', 70],
['ScrollLock', 71],
['Pause', 72],
['Insert', 73],
['Home', 74],
['PageUp', 75],
['Delete', 76],
['End', 77],
['PageDown', 78],
['ArrowRight', 79],
['ArrowLeft', 80],
['ArrowDown', 81],
['ArrowUp', 82],
['NumLock', 83],
['NumpadDivide', 84],
['NumpadMultiply', 85],
['NumpadSubtract', 86],
['NumpadAdd', 87],
['NumpadEnter', 88],
['Numpad1', 89],
['Numpad2', 90],
['Numpad3', 91],
['Numpad4', 92],
['Numpad5', 93],
['Numpad6', 94],
['Numpad7', 95],
['Numpad8', 96],
['Numpad9', 97],
['Numpad0', 98],
['NumpadDecimal', 99],
['KeyKpDot', 99],
['ControlLeft', 224],
['ShiftLeft', 225],
['AltLeft', 226],
['MetaLeft', 227],
['WinLeft', 227],
['ControlRight', 228],
['ShiftRight', 229],
['AltRight', 230],
['MetaRight', 231],
['WinRight', 231]
]);

View File

@@ -0,0 +1,185 @@
import { useRef, useState } from 'react';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { XIcon } from 'lucide-react';
import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard';
import { Drawer } from 'vaul';
import 'react-simple-keyboard/build/css/index.css';
import '@/assets/styles/keyboard.css';
import { client } from '@/lib/websocket.ts';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
import { KeyboardCodes } from './mappings.ts';
import {
doubleKeys,
keyboardArrowsOptions,
keyboardControlPadOptions,
keyboardOptions,
modifierKeys,
specialKeyMap
} from './virtual-keys.ts';
type KeyboardProps = {
isBigScreen: boolean;
};
export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps) => {
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
const [isCapsLock, setIsCapsLock] = useState(false);
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
const keyboardRef = useRef<any>(null);
function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
if (activeModifierKeys.includes(key)) {
sendModifierKeyDown();
sendModifierKeyUp();
} else {
setActiveModifierKeys([...activeModifierKeys, key]);
}
} else {
if (key === '{capslock}') {
setIsCapsLock(!isCapsLock);
}
sendKeydown(key);
}
}
function onKeyReleased(key: string) {
if (modifierKeys.includes(key)) {
return;
}
sendKeyup();
}
function sendKeydown(key: string) {
const specialKey = specialKeyMap.get(key);
const code = KeyboardCodes.get(specialKey ? specialKey : key);
if (!code) {
console.log('unknown code: ', key);
return;
}
const modifiers = sendModifierKeyDown();
client.send([1, code, ...modifiers]);
}
function sendKeyup() {
sendModifierKeyUp();
client.send([1, 0, 0, 0, 0, 0]);
}
function sendModifierKeyDown() {
let ctrl = 0;
let shift = 0;
let alt = 0;
let meta = 0;
activeModifierKeys.forEach((modifierKey) => {
const specialKey = specialKeyMap.get(modifierKey)!;
const code = KeyboardCodes.get(specialKey)!;
if ([224, 228].includes(code)) {
ctrl = 1;
} else if ([225, 229].includes(code)) {
shift = 1;
} else if ([226, 230].includes(code)) {
alt = 1;
} else if ([227, 231].includes(code)) {
meta = 1;
}
client.send([1, code, ctrl, shift, alt, meta]);
});
return [ctrl, shift, alt, meta];
}
function sendModifierKeyUp() {
if (activeModifierKeys.length === 0) return;
activeModifierKeys.forEach(() => {
client.send([1, 0, 0, 0, 0, 0]);
});
setActiveModifierKeys([]);
}
function getButtonTheme(): KeyboardButtonTheme[] {
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
const activeKeys = [...activeModifierKeys];
if (isCapsLock) {
activeKeys.push('{capslock}');
}
if (activeKeys.length > 0) {
theme.push({ class: 'hg-highlight', buttons: activeKeys.join(' ') });
}
return theme;
}
return (
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
<Drawer.Portal>
<Drawer.Content
className={clsx(
'fixed bottom-0 left-0 right-0 z-[999] mx-auto overflow-hidden rounded bg-white outline-none',
isBigScreen ? 'w-[820px]' : 'w-[650px]'
)}
>
{/* header */}
<div className="flex items-center justify-between px-3 py-1">
<div className="w-[100px] text-sm font-bold text-neutral-500">Keyboard</div>
<div className="h-1.5 w-12 flex-shrink-0 rounded-full bg-zinc-300" />
<div className="flex w-[100px] items-center justify-end">
<div
className="flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded text-neutral-600 hover:bg-neutral-300 hover:text-white"
onClick={() => setIsKeyboardOpen(false)}
>
<XIcon size={18} />
</div>
</div>
</div>
<div className="h-px flex-shrink-0 border-b bg-neutral-300" />
<div data-vaul-no-drag className="keyboardContainer w-full">
{/* main keyboard */}
<Keyboard
buttonTheme={getButtonTheme()}
keyboardRef={(r) => (keyboardRef.current = r)}
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardOptions}
/>
{/* control keyboard */}
{isBigScreen && (
<div className="controlArrows">
<Keyboard
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardControlPadOptions}
/>
<Keyboard
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardArrowsOptions}
/>
</div>
)}
</div>
</Drawer.Content>
<Drawer.Overlay />
</Drawer.Portal>
</Drawer.Root>
);
};

View File

@@ -0,0 +1,182 @@
// main keys
export const keyboardOptions = {
theme: 'simple-keyboard hg-theme-default hg-layout-default',
baseClass: 'simple-keyboard-main',
layout: {
default: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal {backspace}',
'{tab} KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote {enter}',
'{shiftleft} KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash {shiftright}',
'{controlleft} {altleft} {metaleft} {space} {metaright} {altright}'
]
},
display: {
'{escape}': 'Esc',
Backquote: '~<br/>`',
Digit1: '!<br/>1',
Digit2: '@<br/>2',
Digit3: '#<br/>3',
Digit4: '$<br/>4',
Digit5: '%<br/>5',
Digit6: '^<br/>6',
Digit7: '&<br/>7',
Digit8: '*<br/>8',
Digit9: '(<br/>9',
Digit0: ')<br/>0',
Minus: '_<br/>-',
Equal: '+<br/>=',
'{backspace}': 'Backspace',
'{tab}': 'Tab',
KeyQ: 'Q',
KeyW: 'W',
KeyE: 'E',
KeyR: 'R',
KeyT: 'T',
KeyY: 'Y',
KeyU: 'U',
KeyI: 'I',
KeyO: 'O',
KeyP: 'P',
BracketLeft: '{<br/>[',
BracketRight: '}<br/>]',
Backslash: '|<br>\\',
'{capslock}': 'Caps',
KeyA: 'A',
KeyS: 'S',
KeyD: 'D',
KeyF: 'F',
KeyG: 'G',
KeyH: 'H',
KeyJ: 'J',
KeyK: 'K',
KeyL: 'L',
Semicolon: ':<br/>;',
Quote: '"<br/>\'',
'{enter}': 'Enter',
'{shiftleft}': 'Shift',
KeyZ: 'Z',
KeyX: 'X',
KeyC: 'C',
KeyV: 'V',
KeyB: 'B',
KeyN: 'N',
KeyM: 'M',
Comma: '<<br/>,',
Period: '><br/>.',
Slash: '?<br/>/',
'{shiftright}': 'Shift',
'{controlleft}': 'Ctrl',
'{altleft}': 'Alt',
'{metaleft}': 'Cmd',
'{space}': 'Space',
'{metaright}': 'Cmd',
'{altright}': 'Alt'
}
};
// control keys
export const keyboardControlPadOptions = {
theme: 'simple-keyboard hg-theme-default hg-layout-default',
baseClass: 'simple-keyboard-control',
layout: {
default: [
'{prtscr} {scrolllock} {pause}',
'{insert} {home} {pageup}',
'{delete} {end} {pagedown}'
]
},
display: {
'{prtscr}': 'PrtScr',
'{scrolllock}': 'Lock',
'{pause}': 'Pause',
'{insert}': 'Ins',
'{home}': 'Home',
'{pageup}': 'PgUp',
'{delete}': 'Del',
'{end}': 'End',
'{pagedown}': 'PgDn'
}
};
// arrow keys
export const keyboardArrowsOptions = {
theme: 'simple-keyboard hg-theme-default hg-layout-default',
baseClass: 'simple-keyboard-arrows',
layout: {
default: ['{arrowup}', '{arrowleft} {arrowdown} {arrowright}']
}
};
// keys require special mapping
export const specialKeyMap = new Map([
['{escape}', 'Escape'],
['{backspace}', 'Backspace'],
['{tab}', 'Tab'],
['{capslock}', 'CapsLock'],
['{enter}', 'Enter'],
['{shiftleft}', 'ShiftLeft'],
['{shiftright}', 'ShiftRight'],
['{controlleft}', 'ControlLeft'],
['{altleft}', 'AltLeft'],
['{metaleft}', 'MetaLeft'],
['{space}', 'Space'],
['{metaright}', 'MetaRight'],
['{altright}', 'AltRight'],
['{prtscr}', 'PrintScreen'],
['{scrolllock}', 'ScrollLock'],
['{pause}', 'Pause'],
['{insert}', 'Insert'],
['{home}', 'Home'],
['{pageup}', 'PageUp'],
['{delete}', 'Delete'],
['{end}', 'End'],
['{pagedown}', 'PageDown'],
['{arrowright}', 'ArrowRight'],
['{arrowleft}', 'ArrowLeft'],
['{arrowdown}', 'ArrowDown'],
['{arrowup}', 'ArrowUp']
]);
// modifier keys
export const modifierKeys = [
'{shiftleft}',
'{controlleft}',
'{altleft}',
'{metaleft}',
'{shiftright}',
'{controlright}',
'{altright}',
'{metaright}'
];
// double line display buttons
export const doubleKeys = [
'Backquote',
'Digit1',
'Digit2',
'Digit3',
'Digit4',
'Digit5',
'Digit6',
'Digit7',
'Digit8',
'Digit9',
'Digit0',
'Minus',
'Equal',
'BracketLeft',
'BracketRight',
'Backslash',
'Semicolon',
'Quote',
'Comma',
'Period',
'Slash'
];

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
import clsx from 'clsx';
import { MenuIcon } from 'lucide-react';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { Power } from './power';
import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
import { Storage } from './storage';
import { Terminal } from './terminal';
import { Wol } from './wol';
export const MenuPhone = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div className="fixed left-[10px] top-[10px] z-[1000]">
<div className="sticky top-[10px]">
{!isMenuOpen && (
<div
className="flex h-[32px] items-center justify-center rounded bg-neutral-800/80 px-2 text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<MenuIcon size={18} />
</div>
)}
<div className={clsx('flex-col rounded bg-neutral-800/90', isMenuOpen ? 'flex' : 'hidden')}>
<div
className="flex h-[32px] rotate-90 items-center justify-center text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<MenuIcon size={18} />
</div>
<Settings />
<Screen />
<Keyboard />
<Mouse />
<Storage />
<Script />
<Terminal />
<Wol />
<Power />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { useSetAtom } from 'jotai';
import { KeyboardIcon } from 'lucide-react';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
export const Keyboard = () => {
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
return (
<>
<div
className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700"
onClick={() => setIsKeyboardOpen((o) => !o)}
>
<KeyboardIcon size={18} />
</div>
</>
);
};

View File

@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import {
CheckIcon,
EyeOffIcon,
HandIcon,
MouseIcon,
MousePointerIcon,
PlusIcon,
RefreshCwIcon,
SquareMousePointerIcon,
TextCursorIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import * as ls from '@/lib/localstorage';
import { client } from '@/lib/websocket';
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
export const Mouse = () => {
const { t } = useTranslation();
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const mouseStyles = [
{ name: t('cursor.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
{ name: t('cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
{ name: t('cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
{ name: t('cursor.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
{ name: t('cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
];
const mouseModes = [
{ name: t('cursor.absolute'), value: 'absolute' },
{ name: t('cursor.relative'), value: 'relative' }
];
useEffect(() => {
const style = ls.getMouseStyle();
if (style && style !== mouseStyle) {
setMouseStyle(style);
}
const mode = ls.getMouseMode();
if (mode && mode !== mouseMode) {
setMouseMode(mode);
}
}, []);
function updateStyle(style: string) {
setMouseStyle(style);
ls.setMouseStyle(style);
}
function updateMode(mode: string) {
setMouseMode(mode);
ls.setMouseMode(mode);
if (mode === 'relative') {
client.close();
setTimeout(() => {
client.connect();
}, 500);
}
}
function resetHid() {
setIsPopoverOpen(false);
client.close();
api.resetHid().finally(() => {
client.connect();
});
}
const content = (
<>
{mouseStyles.map((style) => (
<div
key={style.value}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600',
style.value === mouseStyle ? 'text-green-500' : 'text-white'
)}
onClick={() => updateStyle(style.value)}
>
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
<span>{style.name}</span>
</div>
))}
<Divider style={{ margin: '10px 0' }} />
<Popover
content={
<>
{mouseModes.map((mode) => (
<div
key={mode.value}
className="my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700"
onClick={() => updateMode(mode.value)}
>
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
</div>
<span>{mode.name}</span>
</div>
))}
</>
}
placement="rightTop"
arrow={true}
trigger="hover"
>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<div className="flex h-[14px] w-[20px] items-end">
<SquareMousePointerIcon size={14} />
</div>
<span>{t('cursor.mode')}</span>
</div>
</Popover>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={resetHid}
>
<div className="flex h-[14px] w-[20px] items-end">
<RefreshCwIcon size={14} />
</div>
<span>{t('cursor.resetHid')}</span>
</div>
</>
);
return (
<Popover
content={content}
placement="rightBottom"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<MouseIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { Popover, Slider, Tooltip } from 'antd';
import clsx from 'clsx';
import {
CirclePowerIcon,
HardDriveIcon,
LoaderCircleIcon,
PowerIcon,
RotateCcwIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm';
export const Power = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isPowerOn, setIsPowerOn] = useState(false);
const [isHddOn, setIsHddOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [powerDuration, setPowerDuration] = useState(8);
useEffect(() => {
getLed();
const interval = setInterval(getLed, 5000);
return () => {
clearInterval(interval);
};
}, []);
function getLed() {
api.getLedGpio().then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsPowerOn(rsp.data.pwr);
setIsHddOn(rsp.data.hdd);
});
}
function click(button: 'reset' | 'power', duration?: number) {
if (isLoading) return;
setIsLoading(true);
setIsPopoverOpen(false);
const millisecond = Math.floor((duration ? duration : powerDuration) * 1000);
api.setGpio(button, millisecond).finally(() => {
setIsLoading(false);
});
}
function onChange(value: number) {
setPowerDuration(value);
}
const content = (
<div className="flex flex-col space-y-1">
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('reset', 0.8)}
>
<RotateCcwIcon size={16} />
<span>{t('reset')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power', 0.8)}
>
<PowerIcon size={16} />
<span>{t('powerShort')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power')}
>
<CirclePowerIcon size={16} />
<span>{t('powerLong')}</span>
<div className="flex h-full items-start text-xs text-neutral-500">{powerDuration}s</div>
</div>
<div className="px-3">
<Slider
defaultValue={8}
min={1}
max={30}
tooltip={{ placement: 'bottom' }}
onChange={onChange}
/>
</div>
</div>
);
return (
<>
<Tooltip placement="rightTop" title={t('hddLed')}>
<div
className={clsx(
'flex h-[30px] w-[18px] items-center justify-center px-2',
isHddOn ? 'text-green-600' : 'text-neutral-500'
)}
>
<HardDriveIcon size={18} />
</div>
</Tooltip>
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700/80">
<div
className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (
<PowerIcon size={18} />
)}
</div>
</div>
</Popover>
</>
);
};

View File

@@ -0,0 +1,115 @@
import { useRef, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { CheckIcon, ScanBarcodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setFps as setCookie } from '@/lib/localstorage';
const fpsList = [
{ key: 60, label: '60Hz' },
{ key: 30, label: '30Hz' },
{ key: 24, label: '24Hz' },
{ key: 15, label: '15Hz' },
{ key: 10, label: '10Hz' }
];
const defaultFps = [60, 30, 24, 15, 10];
type FpsProps = {
fps: number;
setFps: (fps: number) => void;
};
export const Fps = ({ fps, setFps }: FpsProps) => {
const { t } = useTranslation();
const [isCustomize, setIsCustomize] = useState(false);
const customizeRef = useRef(0);
function showCustomize() {
customizeRef.current = fps;
setIsCustomize(true);
}
function onChange(value: any) {
const num = Number(value);
if (num > 0 && num <= 60) {
customizeRef.current = num;
}
}
async function update(value: number) {
if (isCustomize && customizeRef.current === fps) {
setIsCustomize(false);
return;
}
const rsp = await updateScreen('fps', value);
if (rsp.code !== 0) {
return;
}
setFps(value);
setCookie(value);
isCustomize && setIsCustomize(false);
}
const content = (
<>
{fpsList.map((item) => (
<div
key={item.key}
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === fps && <CheckIcon size={14} />}
</div>
<span>{item.label}</span>
</div>
))}
<div
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={showCustomize}
>
{defaultFps.includes(fps) ? (
<>
<div className="flex h-[14px] w-[20px] items-end"></div>
<span>{t('screen.customizeFps')}</span>
</>
) : (
<>
<div className="flex h-[14px] w-[20px] items-end">
<CheckIcon size={14} />
</div>
<span>{`Customize(${fps}Hz)`}</span>
</>
)}
</div>
{isCustomize && (
<div className="flex items-center space-x-1 py-1">
<InputNumber<number> defaultValue={fps} min={1} max={60} onChange={onChange} />
<Button
size="small"
icon={<CheckOutlined />}
onClick={() => update(customizeRef.current)}
/>
<Button size="small" icon={<CloseOutlined />} onClick={() => setIsCustomize(false)} />
</div>
)}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<ScanBarcodeIcon size={18} />
<span className="select-none text-sm">{t('screen.fps')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Tooltip } from 'antd';
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getFrameDetect, updateFrameDetect } from '@/api/stream.ts';
export const FrameDetect = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
getFrameDetect().then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
});
}, []);
function update() {
if (isLoading) return;
setIsLoading(true);
updateFrameDetect()
.then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<Tooltip placement="rightTop" title={t('screen.frameDetectTip')} color="#262626" arrow>
<div
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
onClick={update}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : isEnabled ? (
<Tally4Icon color="#22c55e" size={18} />
) : (
<Tally5Icon size={18} />
)}
<span className="select-none text-sm">{t('screen.frameDetect')}</span>
</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import { useAtomValue } from 'jotai';
import { MonitorIcon } from 'lucide-react';
import { updateScreen } from '@/api/vm';
import * as ls from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution';
import { Fps } from './fps';
import { FrameDetect } from './frame-detect';
import { Quality } from './quality';
import { Resolution } from './resolution';
export const Screen = () => {
const resolution = useAtomValue(resolutionAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(80);
useEffect(() => {
updateScreen('resolution', resolution!.height);
const cookieFps = ls.getFps();
if (cookieFps) {
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
const cookieQuality = ls.getQuality();
if (cookieQuality) {
updateScreen('quality', cookieQuality).then((rsp) => {
if (rsp.code === 0) {
setQuality(cookieQuality);
}
});
}
}, []);
return (
<Popover
content={
<div className="flex flex-col space-y-1">
<Resolution />
<Fps fps={fps} setFps={setFps} />
<Quality quality={quality} setQuality={setQuality} />
<FrameDetect />
</div>
}
placement="rightBottom"
trigger="click"
>
<div className="flex h-[32px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700/80">
<MonitorIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,61 @@
import { Popover } from 'antd';
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts';
const qualityList = [
{ key: 99, label: '100%' },
{ key: 90, label: '90%' },
{ key: 80, label: '80%' },
{ key: 70, label: '70%' },
{ key: 60, label: '60%' },
{ key: 51, label: '50%' }
];
type QualityProps = {
quality: number;
setQuality: (quality: number) => void;
};
export const Quality = ({ quality, setQuality }: QualityProps) => {
const { t } = useTranslation();
async function update(value: number) {
const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) {
return;
}
setQuality(value);
setCookie(value);
}
const content = (
<>
{qualityList.map((item) => (
<div
key={item.key}
className="flex h-[30px] cursor-pointer select-none items-center space-x-1 rounded pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === quality && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.label}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<SquareActivityIcon size={18} />
<span className="select-none text-sm">{t('screen.quality')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,59 @@
import { Popover } from 'antd';
import { useAtom } from 'jotai';
import { CheckIcon, RatioIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { Resolution as TypeResolution } from '@/types';
import { setResolution as setCookie } from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution.ts';
const resolutions: TypeResolution[] = [
{ width: 1920, height: 1080 },
{ width: 1280, height: 720 },
{ width: 800, height: 600 },
{ width: 640, height: 480 }
];
export const Resolution = () => {
const { t } = useTranslation();
const [resolution, setResolution] = useAtom(resolutionAtom);
async function update(item: TypeResolution) {
const rsp = await updateScreen('resolution', item.height);
if (rsp.code !== 0) {
return;
}
setResolution(item);
setCookie(item);
}
const content = (
<>
{resolutions.map((item) => (
<div
key={item.height}
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.height === resolution?.height && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.width}</span>
<span>x</span>
<span className="w-[36px]">{item.height}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<RatioIcon size={18} />
<span className="select-none text-sm">{t('screen.resolution')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,205 @@
import { ChangeEvent, useRef, useState } from 'react';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Divider, Popconfirm, Popover } from 'antd';
import clsx from 'clsx';
import { ChevronRightIcon, FileCodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script.ts';
import { Run } from './run';
export const Script = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [scripts, setScripts] = useState<string[]>(['test.sh']);
const [currentScript, setCurrentScript] = useState('');
const [isRunning, setIsRunning] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<any>(null);
function handleOpenChange(open: boolean) {
if (open) {
getScripts();
} else {
setCurrentScript('');
}
setIsPopoverOpen(open);
}
function selectFile() {
if (inputRef.current) {
inputRef.current.value = null;
}
inputRef.current?.click();
}
function uploadFile(e: ChangeEvent<HTMLInputElement>) {
if (!e.target?.files?.length) return;
const file = e.target.files[0];
if (isUploading) return;
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
api
.uploadScript(formData)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (!scripts.includes(rsp.data.file)) {
setScripts([...scripts, rsp.data.file]);
}
})
.finally(() => {
setIsUploading(false);
});
}
function runScript(type: string) {
if (!currentScript) return;
if (type === 'foreground') {
setIsRunning(true);
} else {
api.runScript(currentScript, type).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
});
}
setIsPopoverOpen(false);
}
function getScripts() {
api.getScripts().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data?.files?.length > 0) {
setScripts(rsp.data.files);
}
});
}
function deleteScript() {
if (!currentScript) return;
api.deleteScript(currentScript).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setScripts(scripts.filter((script) => script !== currentScript));
});
}
function activate(script: string) {
setCurrentScript(script === currentScript ? '' : script);
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
</div>
<input
ref={inputRef}
type="file"
accept="*.sh,*.py"
className="hidden"
onChange={uploadFile}
/>
<Button
ghost
type="primary"
size="small"
icon={<UploadOutlined />}
loading={isUploading}
onClick={selectFile}
>
{t('script.upload')}
</Button>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{scripts.map((script) => (
<div
key={script}
className={clsx(
'my-1 cursor-pointer rounded',
script === currentScript ? 'bg-neutral-700/50' : 'hover:bg-neutral-700/50'
)}
>
<div
className="flex items-center justify-between space-x-5 px-2 py-1.5"
onClick={() => activate(script)}
>
<div className="max-w-[300px] select-none truncate">{script}</div>
<div className={clsx('h-[16px] w-[16px]', script === currentScript && 'rotate-90')}>
<ChevronRightIcon size={16} />
</div>
</div>
{script === currentScript && (
<div className="flex items-center justify-end space-x-2 p-3">
<Button type="primary" size="small" onClick={() => runScript('foreground')}>
{t('script.run')}
</Button>
<Button type="primary" size="small" onClick={() => runScript('background')}>
{t('script.runBackground')}
</Button>
<Popconfirm
title={t('script.attention')}
description={t('script.delDesc')}
onConfirm={deleteScript}
onCancel={() => {}}
okText={t('script.confirm')}
cancelText={t('script.cancel')}
placement="bottom"
>
<Button danger type="primary" size="small">
{t('script.delete')}
</Button>
</Popconfirm>
</div>
)}
</div>
))}
</div>
);
return (
<>
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<FileCodeIcon size={18} />
</div>
</Popover>
{isRunning && <Run script={currentScript} setIsRunning={setIsRunning} />}
</>
);
};

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Modal, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script';
type RunProps = {
script: string;
setIsRunning: (isRunning: boolean) => void;
};
export const Run = ({ script, setIsRunning }: RunProps) => {
const { t } = useTranslation();
const [state, setState] = useState('');
const [log, setLog] = useState('');
useEffect(() => {
setState('running');
api
.runScript(script, 'foreground')
.then((rsp) => {
if (rsp.code !== 0) {
setLog(rsp.msg);
setState('failed');
return;
}
setState('success');
setLog(rsp.data.log);
})
.catch(() => {
setLog(t('script.runFailed'));
setState('failed');
});
}, []);
return (
<Modal
title={script}
width={350}
closable={false}
footer={null}
style={{ top: 60 }}
open={true}
>
{state === 'running' ? (
<div className="flex h-[300px] items-center justify-center">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : (
<Card className="h-[500px] overflow-auto whitespace-pre-line font-mono">{log}</Card>
)}
<div className="mt-5 flex justify-center">
<Button type="primary" onClick={() => setIsRunning(false)}>
{t('script.close')}
</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { BookOpenIcon, GithubIcon, LoaderCircleIcon, MessageSquareIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getInfo } from '@/api/vm';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type Info = {
ip: string;
mdns: string;
image: string;
firmware: string;
deviceKey: string;
};
export const About = () => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [info, setInfo] = useState<Info | undefined>();
const communities = [
{ name: 'wiki', icon: <BookOpenIcon size={24} />, url: 'https://wiki.sipeed.com/nanokvm' },
{ name: 'github', icon: <GithubIcon size={24} />, url: 'https://github.com/sipeed/NanoKVM' },
{
name: 'discussion',
icon: <MessageSquareIcon size={24} />,
url: 'https://maixhub.com/discussion/nanokvm'
}
];
useEffect(() => {
setLoading(true);
getInfo()
.then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
setInfo(undefined);
return;
}
setInfo(rsp.data);
})
.catch(() => {
setInfo(undefined);
})
.finally(() => {
setLoading(false);
});
}, []);
function showModal() {
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function openPage(url: string) {
window.open(url, '_blank');
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
{t('about.title')}
</div>
<Modal
title={t('about.title')}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
width={380}
footer={null}
centered
>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="flex w-full flex-col space-y-2">
<span className="text-neutral-400">{t('about.information')}</span>
{loading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : info ? (
<div className="flex w-full flex-col space-y-1">
{Object.entries(info).map(([key, value]) => (
<div key={key} className="flex w-full items-center justify-between">
<span>{t(`about.${key}`)}</span>
<span>{value}</span>
</div>
))}
</div>
) : (
<span>{t('about.queryFailed')}</span>
)}
</div>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="text-neutral-400">{t('about.community')}</div>
<div className="my-3 flex space-x-5">
{communities.map((community) => (
<div
key={community.name}
className="flex h-20 w-20 cursor-pointer flex-col items-center justify-center space-y-1 rounded text-neutral-400 outline outline-1 outline-neutral-700 hover:bg-neutral-800"
onClick={() => openPage(community.url)}
>
{community.icon}
<span className="text-xs text-neutral-300">{community.name}</span>
</div>
))}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export const ChangePassword = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function changePassword() {
navigate('/auth/password');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={changePassword}
>
{t('changePassword')}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import { Badge, Divider, Popover } from 'antd';
import { useAtom } from 'jotai';
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 { Update } from './update.tsx';
import { VirtualDevice } from './virtual-device.tsx';
export const Settings = () => {
const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom);
const [isBadgeVisible, setIsBadgeVisible] = useState(false);
const content = (
<>
<Language />
<About />
<Update setIsBadgeVisible={setIsBadgeVisible} />
<Divider style={{ margin: '10px 0' }} />
<VirtualDevice />
<Tailscale />
<Divider style={{ margin: '10px 0' }} />
<ChangePassword />
<Logout />
</>
);
return (
<>
<Popover
content={content}
placement="rightBottom"
trigger="click"
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700">
<Badge dot={isBadgeVisible} color="blue" offset={[1, 0]}>
<SettingsIcon size={18} />
</Badge>
</div>
</Popover>
</>
);
};

View File

@@ -0,0 +1,59 @@
import { Popover } from 'antd';
import clsx from 'clsx';
import { LanguagesIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setLanguage } from '@/lib/localstorage.ts';
export const Language = () => {
const { t, i18n } = useTranslation();
function changeLanguage(language: string) {
if (i18n.language === language) return;
i18n.changeLanguage(language).then();
setLanguage(language);
}
const content = (
<>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('en')}
>
English
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'fr' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('fr')}
>
Français
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('zh')}
>
中文
</div>
</>
);
return (
<Popover content={content} placement="right" trigger="hover" arrow>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 text-white hover:bg-neutral-600">
<LanguagesIcon size={16} />
<span>{t('language')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { removeToken } from '@/lib/cookie.ts';
export const Logout = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function logout() {
removeToken();
navigate('/auth/login');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={logout}
>
{t('logout')}
</div>
);
};

View File

@@ -0,0 +1,202 @@
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<State>('');
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 (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={openModal}
>
Tailscale
</div>
<Modal
title="Tailscale"
open={isModalOpen}
width={350}
footer={null}
centered={true}
maskClosable={false}
onCancel={() => setIsModalOpen(false)}
>
<div className="flex h-[180px] items-center justify-center">
{isLoading ? (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.loading')}</span>
</div>
) : (
<>
{state === '' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
<span>{t('tailscale.notInstalled')}</span>
<div className="flex justify-center">
<Button type="primary" onClick={install}>
{t('tailscale.install')}
</Button>
</div>
</div>
)}
{state === 'installing' && (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.installing')}</span>
</div>
)}
{state === 'installed' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
{loginUrl ? (
<>
<a href={loginUrl} target="_blank">
{loginUrl}
</a>
<span className="text-sm text-neutral-400">{t('tailscale.urlPeriod')}</span>
</>
) : (
<>
<span>{t('tailscale.installed')}</span>
<div className="flex justify-center">
<Button type="primary" loading={isStarting} onClick={run}>
{t('tailscale.login')}
</Button>
</div>
</>
)}
</div>
)}
{state === 'failed' && (
<div className="flex flex-col space-y-10">
<div className="flex flex-col items-center justify-center font-bold text-neutral-400">
<span>{t('tailscale.failed')}</span>
<span>{t('tailscale.retry')}</span>
</div>
<ul className="list-decimal">
<li>
{t('tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('tailscale.package')}
</a>
{t('tailscale.unzip')}
</li>
<li>{t('tailscale.upTailscale')}</li>
<li>{t('tailscale.upTailscaled')}</li>
<li>{t('tailscale.refresh')}</li>
</ul>
</div>
)}
</>
)}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { Badge, Button, Modal, Spin } from 'antd';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import semver from 'semver';
import * as api from '@/api/firmware.ts';
import { getSkipUpdate, setSkipUpdate } from '@/lib/localstorage.ts';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type UpdateProps = {
setIsBadgeVisible: (isBadgeVisible: boolean) => void;
};
export const Update = ({ setIsBadgeVisible }: UpdateProps) => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [current, setCurrent] = useState('');
const [latest, setLatest] = useState('');
const [isLatest, setIsLatest] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [errMsg, setErrMsg] = useState('');
useEffect(() => {
const skip = getSkipUpdate();
if (!skip) {
getVersion();
}
}, []);
function showModal() {
getVersion();
setErrMsg('');
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function getVersion() {
if (isLoading) return;
setIsLoading(true);
api
.getVersion()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.queryFailed'));
return;
}
setCurrent(rsp.data.current);
setLatest(rsp.data.latest);
if (semver.gt(rsp.data.latest, rsp.data.current)) {
setIsLatest(false);
setIsBadgeVisible(true);
}
})
.catch(() => {
setErrMsg(t('update.queryFailed'));
})
.finally(() => {
setIsLoading(false);
});
}
function confirm() {
if (isLatest || !latest) {
setIsModalOpen(false);
return;
}
if (isUpdating) return;
setIsUpdating(true);
api
.update()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
return;
}
setTimeout(() => {
window.location.reload();
}, 6000);
})
.catch(() => {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
});
}
function cancel() {
setIsBadgeVisible(false);
setIsLatest(true);
setIsModalOpen(false);
setSkipUpdate(true);
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
<Badge dot={!isLatest} color="blue" offset={[5, 5]}>
{t('update.title')}
</Badge>
</div>
<Modal
title={t('update.title')}
open={isModalOpen}
width={400}
footer={null}
closable={false}
centered
>
{isUpdating ? (
<div className="flex h-[300px] flex-col items-center justify-center space-y-5">
<Spin spinning={isUpdating} size="large" />
<span className="text-neutral-400">{t('update.updating')}</span>
</div>
) : (
<div className="flex flex-col items-center justify-center pb-5 pt-10">
{isLoading ? (
<Spin tip={t('update.loading')} />
) : (
<>
{errMsg !== '' ? (
<span>{errMsg}</span>
) : (
<>
{isLatest ? (
<div className="flex flex-col items-center space-y-1">
<span className="text-neutral-400">{current}</span>
<span>{t('update.isLatest')}</span>
</div>
) : (
<div className="flex flex-col items-center space-y-1">
<div className="flex space-x-2">
<span>{current}</span>
<span className="text-neutral-500">{'->'}</span>
<span>{latest}</span>
</div>
<span>{t('update.available')}</span>
</div>
)}
</>
)}
</>
)}
<div className="mt-10 flex items-center space-x-3">
<Button type="primary" onClick={confirm}>
{t('update.confirm')}
</Button>
{!isLatest && (
<Button type="default" onClick={cancel}>
{t('update.cancel')}
</Button>
)}
</div>
</div>
)}
</Modal>
</>
);
};

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { CheckIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/virtual-device';
export const VirtualDevice = () => {
const { t } = useTranslation();
const [isNetworkOn, setIsNetworkOn] = useState(false);
const [isUSBOn, setIsUSBOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
api.getVirtualDevice().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsNetworkOn(rsp.data.network);
setIsUSBOn(rsp.data.usb);
});
}, []);
function update(device: string) {
if (isLoading) return;
setIsLoading(true);
api
.updateVirtualDevice(device)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (device === 'network') {
setIsNetworkOn(rsp.data.on);
} else {
setIsUSBOn(rsp.data.on);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('network')}
>
{isNetworkOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.network')}</span>
</div>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('usb')}
>
{isUSBOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.usb')}</span>
</div>
</>
);
};

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import {
DiscIcon,
FileBoxIcon,
LoaderCircleIcon,
PackageIcon,
PackageSearchIcon,
XIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import { client } from '@/lib/websocket.ts';
export const Storage = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<string[]>([]);
const [mountingFile, setMountingFile] = useState('');
const [mountedFile, setMountedFile] = useState('');
useEffect(() => {
getFiles();
}, []);
function handleOpenChange(open: boolean) {
if (open) {
getFiles();
}
setIsPopoverOpen(open);
}
// 获取镜像列表
function getFiles() {
if (isLoading) return;
setIsLoading(true);
api
.getImages()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const images = rsp.data.files;
if (!images?.length) {
setFiles([]);
return;
}
setFiles(images);
// 获取已挂载的镜像
api.getMountedImage().then((rsp) => {
if (rsp.code === 0 && rsp.data?.file && images.includes(rsp.data.file)) {
setMountedFile(rsp.data.file);
}
});
})
.finally(() => {
setIsLoading(false);
});
}
// 挂载/取消挂载
function mountFile(file: string) {
if (mountingFile) return;
setMountingFile(file);
client.close();
const filename = mountedFile === file ? '' : file;
api
.mountImage(filename)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMountedFile(filename);
})
.finally(() => {
setMountingFile('');
client.connect();
});
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('images')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{isLoading ? (
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('loading')}</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('empty')}</span>
</div>
) : (
files.map((file) => (
<div
key={file}
className={clsx(
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
{ 'text-blue-500': mountedFile === file }
)}
onClick={() => mountFile(file)}
>
{mountedFile === file ? (
<>
<div className="h-[18px] w-[18px] group-hover:text-red-500">
<PackageIcon size={18} className="block group-hover:hidden" />
<XIcon size={18} className="hidden group-hover:block" />
</div>
</>
) : mountingFile === file ? (
<div className="h-[18px] w-[18px]">
<LoaderCircleIcon className="animate-spin" size={18} />
</div>
) : (
<div className="h-[18px] w-[18px]">
<FileBoxIcon size={18} />
</div>
)}
<span className="break-all">{file.replace(/^.*[\\/]/, '')}</span>
</div>
))
)}
</div>
);
return (
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div
className={clsx(
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
mountedFile ? 'text-blue-500' : 'text-neutral-300'
)}
>
<DiscIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Divider, Popover } from 'antd';
import { SquareTerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Nanokvm } from './nanokvm';
import { SerialPort } from './serial-port';
export const Terminal = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const content = (
<div className="min-w-[200px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('terminal.title')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
<Nanokvm setIsPopoverOpen={setIsPopoverOpen} />
<SerialPort setIsPopoverOpen={setIsPopoverOpen} />
</div>
);
return (
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<SquareTerminalIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,25 @@
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type NanokvmProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const Nanokvm = ({ setIsPopoverOpen }: NanokvmProps) => {
const { t } = useTranslation();
function openTerminal() {
setIsPopoverOpen(false);
window.open('/#terminal', '_blank');
}
return (
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openTerminal}
>
<TerminalIcon size={16} />
<span>{t('terminal.nanokvm')}</span>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import { ChangeEvent, useState } from 'react';
import { Button, Input, InputNumber, Modal, Radio, RadioChangeEvent } from 'antd';
import { useSetAtom } from 'jotai';
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
type SerialPortProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const SerialPort = ({ setIsPopoverOpen }: SerialPortProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [port, setPort] = useState('');
const [baudrate, setBaudrate] = useState(115200);
function openModal() {
setIsKeyboardEnable(false);
setIsModalOpen(true);
setIsPopoverOpen(false);
}
function closeModal() {
setIsKeyboardEnable(true);
setIsModalOpen(false);
}
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
setPort(e.target.value);
}
function handleRadioChange(e: RadioChangeEvent) {
setPort(e.target.value);
}
function onInputNumberChange(value: number | null) {
if (value === null) return;
setBaudrate(value);
}
function submit() {
if (!port || !baudrate) {
closeModal();
return;
}
setIsModalOpen(false);
window.open(`/#terminal?port=${port}&baud=${baudrate}`, '_blank');
}
return (
<>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openModal}
>
<TerminalIcon size={16} />
<span>{t('terminal.serial')}</span>
</div>
<Modal
open={isModalOpen}
title={t('terminal.serial')}
width={350}
footer={null}
onCancel={closeModal}
>
<div className="mt-10 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">
{t('terminal.serialPort')}
</div>
<div className="w-1/2">
<Input
value={port}
placeholder={t('terminal.serialPortPlaceholder')}
onChange={handleInputChange}
/>
</div>
</div>
<div className="mt-3 pl-[100px]">
<Radio.Group size="large" onChange={handleRadioChange}>
<Radio value="/dev/ttyS1">
<code>/dev/ttyS1</code>
</Radio>
<Radio value="/dev/ttyS2">
<code>/dev/ttyS2</code>
</Radio>
</Radio.Group>
</div>
<div className="mt-7 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.baudrate')}</div>
<InputNumber
controls={false}
min={1}
defaultValue={115200}
onChange={onInputNumberChange}
/>
</div>
<div className="mb-3 mt-12 flex justify-center">
<Button type="primary" onClick={submit}>
{t('terminal.confirm')}
</Button>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,159 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Divider, Input, List, Popover } from 'antd';
import type { InputRef } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { NetworkIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { deleteWolMac, getWolMacs, wol } from '@/api/network.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Wol = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [input, setInput] = useState('');
const [status, setStatus] = useState('');
const [log, setLog] = useState('');
const [macList, setMacList] = useState<string[]>([]);
const inputRef = useRef<InputRef>(null);
function handleOpenChange(open: boolean) {
if (open) {
getMacs();
setIsKeyboardEnable(false);
} else {
setInput('');
setStatus('');
setLog('');
setIsKeyboardEnable(true);
}
setIsPopoverOpen(open);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setInput(e.target.value);
}
function getMacs() {
getWolMacs().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMacList(rsp.data.macs.filter((mac: string) => mac !== ''));
});
}
function deleteMac(mac: string) {
deleteWolMac(mac).then((rsp) => {
if (rsp.code === 0) {
setMacList(macList.filter((item) => item !== mac));
}
});
}
function wake(mac?: string) {
if (status === 'loading') return;
const value = mac ? mac : input;
if (!value) return;
setStatus('loading');
setLog(t('wol.sending'));
wol(value)
.then((rsp) => {
if (rsp.code !== 0) {
setStatus('failed');
setLog(rsp.msg);
return;
}
setStatus('success');
setLog(t('wol.sent'));
})
.catch(() => {
setStatus('failed');
});
}
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">Wake-on-LAN</span>
</div>
<Divider style={{ margin: '10px 0 10px 0' }} />
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
<div className="flex items-center space-x-1">
<Input ref={inputRef} value={input} onChange={handleChange} />
<Button type="primary" onClick={() => wake()}>
{t('wol.ok')}
</Button>
</div>
<div className={clsx('py-2')}>
{status && (
<div
className={clsx(
'max-w-[300px] break-words text-sm',
status === 'failed' ? 'text-red-500' : 'text-green-500'
)}
>
{log}
</div>
)}
</div>
<List
itemLayout="horizontal"
locale={{ emptyText: ' ' }}
dataSource={macList}
renderItem={(mac) => (
<List.Item className="flex w-full items-center justify-between">
<div className="h-[24px] max-w-[200px]">{mac}</div>
<div className="flex items-center space-x-3">
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
onClick={() => wake(mac)}
>
<SendIcon size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
onClick={() => deleteMac(mac)}
>
<Trash2Icon size={16} />
</div>
</div>
</List.Item>
)}
/>
</div>
);
return (
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<NetworkIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { MaximizeIcon, MinimizeIcon } from 'lucide-react';
export const Fullscreen = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
function onFullscreenChange() {
setIsFullscreen(!!document.fullscreenElement);
}
onFullscreenChange();
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, []);
function handleFullscreen() {
if (!document.fullscreenElement) {
const element = document.documentElement;
element.requestFullscreen().then();
} else {
document.exitFullscreen().then();
}
}
return (
<div
className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700"
onClick={handleFullscreen}
>
{isFullscreen ? <MinimizeIcon size={18} /> : <MaximizeIcon size={18} />}
</div>
);
};

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import { Divider } from 'antd';
import clsx from 'clsx';
import { MenuIcon, XIcon } from 'lucide-react';
import { Fullscreen } from './fullscreen';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { Power } from './power';
import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
import { Storage } from './storage';
import { Terminal } from './terminal';
import { Wol } from './wol';
export const Menu = () => {
const [isMenuOpen, setIsMenuOpen] = useState(true);
return (
<div className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
<div className="sticky top-[10px]">
<div
className={clsx(
'h-[40px] items-center justify-between rounded bg-neutral-800/90',
isMenuOpen ? 'flex' : 'hidden'
)}
>
<div className="flex h-[30px] select-none items-center px-3">
<img src="/sipeed.ico" width={18} height={18} alt="sipeed" />
</div>
<Screen />
<Keyboard />
<Mouse />
<Divider type="vertical" />
<Storage />
<Script />
<Terminal />
<Wol />
<Divider type="vertical" />
<Power />
<Divider type="vertical" />
<Settings />
<Fullscreen />
<div
className="mr-2 flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700"
onClick={() => setIsMenuOpen((o) => !o)}
>
<XIcon size={20} />
</div>
</div>
{!isMenuOpen && (
<div
className="flex h-[30px] w-[50px] items-center justify-center rounded bg-neutral-800/80 text-neutral-300 hover:bg-neutral-800 hover:text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<MenuIcon />
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { useSetAtom } from 'jotai';
import { KeyboardIcon } from 'lucide-react';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
export const Keyboard = () => {
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
return (
<>
<div
className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700"
onClick={() => setIsKeyboardOpen((o) => !o)}
>
<KeyboardIcon size={18} />
</div>
</>
);
};

View File

@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import {
CheckIcon,
EyeOffIcon,
HandIcon,
MouseIcon,
MousePointerIcon,
PlusIcon,
RefreshCwIcon,
SquareMousePointerIcon,
TextCursorIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import * as ls from '@/lib/localstorage';
import { client } from '@/lib/websocket';
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
export const Mouse = () => {
const { t } = useTranslation();
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const mouseStyles = [
{ name: t('cursor.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
{ name: t('cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
{ name: t('cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
{ name: t('cursor.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
{ name: t('cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
];
const mouseModes = [
{ name: t('cursor.absolute'), value: 'absolute' },
{ name: t('cursor.relative'), value: 'relative' }
];
useEffect(() => {
const style = ls.getMouseStyle();
if (style && style !== mouseStyle) {
setMouseStyle(style);
}
const mode = ls.getMouseMode();
if (mode && mode !== mouseMode) {
setMouseMode(mode);
}
}, []);
function updateStyle(style: string) {
setMouseStyle(style);
ls.setMouseStyle(style);
}
function updateMode(mode: string) {
setMouseMode(mode);
ls.setMouseMode(mode);
if (mode === 'relative') {
client.close();
setTimeout(() => {
client.connect();
}, 500);
}
}
function resetHid() {
setIsPopoverOpen(false);
client.close();
api.resetHid().finally(() => {
client.connect();
});
}
const content = (
<>
{mouseStyles.map((style) => (
<div
key={style.value}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600',
style.value === mouseStyle ? 'text-green-500' : 'text-white'
)}
onClick={() => updateStyle(style.value)}
>
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
<span>{style.name}</span>
</div>
))}
<Divider style={{ margin: '10px 0' }} />
<Popover
content={
<>
{mouseModes.map((mode) => (
<div
key={mode.value}
className="my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700"
onClick={() => updateMode(mode.value)}
>
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
</div>
<span>{mode.name}</span>
</div>
))}
</>
}
placement="rightTop"
arrow={true}
trigger="hover"
>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<div className="flex h-[14px] w-[20px] items-end">
<SquareMousePointerIcon size={14} />
</div>
<span>{t('cursor.mode')}</span>
</div>
</Popover>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={resetHid}
>
<div className="flex h-[14px] w-[20px] items-end">
<RefreshCwIcon size={14} />
</div>
<span>{t('cursor.resetHid')}</span>
</div>
</>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<MouseIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { Popover, Slider, Tooltip } from 'antd';
import clsx from 'clsx';
import {
CirclePowerIcon,
HardDriveIcon,
LoaderCircleIcon,
PowerIcon,
RotateCcwIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm';
export const Power = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isPowerOn, setIsPowerOn] = useState(false);
const [isHddOn, setIsHddOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [powerDuration, setPowerDuration] = useState(8);
useEffect(() => {
getLed();
const interval = setInterval(getLed, 5000);
return () => {
clearInterval(interval);
};
}, []);
function getLed() {
api.getLedGpio().then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsPowerOn(rsp.data.pwr);
setIsHddOn(rsp.data.hdd);
});
}
function click(button: 'reset' | 'power', duration?: number) {
if (isLoading) return;
setIsLoading(true);
setIsPopoverOpen(false);
const millisecond = Math.floor((duration ? duration : powerDuration) * 1000);
api.setGpio(button, millisecond).finally(() => {
setIsLoading(false);
});
}
function onChange(value: number) {
setPowerDuration(value);
}
const content = (
<div className="flex flex-col space-y-1">
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('reset', 0.8)}
>
<RotateCcwIcon size={16} />
<span>{t('reset')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power', 0.8)}
>
<PowerIcon size={16} />
<span>{t('powerShort')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power')}
>
<CirclePowerIcon size={16} />
<span>{t('powerLong')}</span>
<div className="flex h-full items-start text-xs text-neutral-500">{powerDuration}s</div>
</div>
<div className="px-3">
<Slider
defaultValue={8}
min={1}
max={30}
tooltip={{ placement: 'bottom' }}
onChange={onChange}
/>
</div>
</div>
);
return (
<>
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700/80">
<div
className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (
<PowerIcon size={18} />
)}
</div>
</div>
</Popover>
<Tooltip placement="bottom" title={t('hddLed')}>
<div
className={clsx(
'flex h-[30px] w-[18px] items-center justify-center px-2',
isHddOn ? 'text-green-600' : 'text-neutral-500'
)}
>
<HardDriveIcon size={18} />
</div>
</Tooltip>
</>
);
};

View File

@@ -0,0 +1,117 @@
import { useRef, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { CheckIcon, ScanBarcodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setFps as setCookie } from '@/lib/localstorage';
const fpsList = [
{ key: 60, label: '60Hz' },
{ key: 30, label: '30Hz' },
{ key: 24, label: '24Hz' },
{ key: 15, label: '15Hz' },
{ key: 10, label: '10Hz' }
];
const defaultFps = [60, 30, 24, 15, 10];
type FpsProps = {
fps: number;
setFps: (fps: number) => void;
};
export const Fps = ({ fps, setFps }: FpsProps) => {
const { t } = useTranslation();
const [isCustomize, setIsCustomize] = useState(false);
const customizeRef = useRef(0);
function showCustomize() {
customizeRef.current = fps;
setIsCustomize(true);
}
function onChange(value: any) {
const num = Number(value);
if (num > 0 && num <= 60) {
customizeRef.current = num;
}
}
async function update(value: number) {
if (isCustomize && customizeRef.current === fps) {
setIsCustomize(false);
return;
}
const rsp = await updateScreen('fps', value);
if (rsp.code !== 0) {
return;
}
setFps(value);
setCookie(value);
isCustomize && setIsCustomize(false);
}
const content = (
<>
{/* default fps list */}
{fpsList.map((item) => (
<div
key={item.key}
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === fps && <CheckIcon size={14} />}
</div>
<span>{item.label}</span>
</div>
))}
{/* customize fps */}
<div
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={showCustomize}
>
{defaultFps.includes(fps) ? (
<>
<div className="flex h-[14px] w-[20px] items-end"></div>
<span>{t('screen.customizeFps')}</span>
</>
) : (
<>
<div className="flex h-[14px] w-[20px] items-end">
<CheckIcon size={14} />
</div>
<span>{`Customize(${fps}Hz)`}</span>
</>
)}
</div>
{isCustomize && (
<div className="flex items-center space-x-1 py-1">
<InputNumber<number> defaultValue={fps} min={1} max={60} onChange={onChange} />
<Button
size="small"
icon={<CheckOutlined />}
onClick={() => update(customizeRef.current)}
/>
<Button size="small" icon={<CloseOutlined />} onClick={() => setIsCustomize(false)} />
</div>
)}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<ScanBarcodeIcon size={18} />
<span className="select-none text-sm">{t('screen.fps')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Tooltip } from 'antd';
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getFrameDetect, updateFrameDetect } from '@/api/stream.ts';
export const FrameDetect = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
getFrameDetect().then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
});
}, []);
function update() {
if (isLoading) return;
setIsLoading(true);
updateFrameDetect()
.then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<Tooltip placement="rightTop" title={t('screen.frameDetectTip')} color="#262626" arrow>
<div
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
onClick={update}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : isEnabled ? (
<Tally4Icon color="#22c55e" size={18} />
) : (
<Tally5Icon size={18} />
)}
<span className="select-none text-sm">{t('screen.frameDetect')}</span>
</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import { useAtomValue } from 'jotai';
import { MonitorIcon } from 'lucide-react';
import { updateScreen } from '@/api/vm';
import * as ls from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution';
import { Fps } from './fps';
import { FrameDetect } from './frame-detect';
import { Quality } from './quality';
import { Resolution } from './resolution';
export const Screen = () => {
const resolution = useAtomValue(resolutionAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(80);
useEffect(() => {
updateScreen('resolution', resolution!.height);
const cookieFps = ls.getFps();
if (cookieFps) {
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
const cookieQuality = ls.getQuality();
if (cookieQuality) {
updateScreen('quality', cookieQuality).then((rsp) => {
if (rsp.code === 0) {
setQuality(cookieQuality);
}
});
}
}, []);
return (
<Popover
content={
<div className="flex flex-col space-y-1">
<Resolution />
<Fps fps={fps} setFps={setFps} />
<Quality quality={quality} setQuality={setQuality} />
<FrameDetect />
</div>
}
placement="bottomLeft"
trigger="click"
>
<div className="flex h-[32px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700/80">
<MonitorIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,61 @@
import { Popover } from 'antd';
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts';
const qualityList = [
{ key: 99, label: '100%' },
{ key: 90, label: '90%' },
{ key: 80, label: '80%' },
{ key: 70, label: '70%' },
{ key: 60, label: '60%' },
{ key: 51, label: '50%' }
];
type QualityProps = {
quality: number;
setQuality: (quality: number) => void;
};
export const Quality = ({ quality, setQuality }: QualityProps) => {
const { t } = useTranslation();
async function update(value: number) {
const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) {
return;
}
setQuality(value);
setCookie(value);
}
const content = (
<>
{qualityList.map((item) => (
<div
key={item.key}
className="flex h-[30px] cursor-pointer select-none items-center space-x-1 rounded pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === quality && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.label}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<SquareActivityIcon size={18} />
<span className="select-none text-sm">{t('screen.quality')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,59 @@
import { Popover } from 'antd';
import { useAtom } from 'jotai';
import { CheckIcon, RatioIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { Resolution as TypeResolution } from '@/types';
import { setResolution as setCookie } from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution.ts';
const resolutions: TypeResolution[] = [
{ width: 1920, height: 1080 },
{ width: 1280, height: 720 },
{ width: 800, height: 600 },
{ width: 640, height: 480 }
];
export const Resolution = () => {
const { t } = useTranslation();
const [resolution, setResolution] = useAtom(resolutionAtom);
async function update(item: TypeResolution) {
const rsp = await updateScreen('resolution', item.height);
if (rsp.code !== 0) {
return;
}
setResolution(item);
setCookie(item);
}
const content = (
<>
{resolutions.map((item) => (
<div
key={item.height}
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.height === resolution?.height && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.width}</span>
<span>x</span>
<span className="w-[36px]">{item.height}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<RatioIcon size={18} />
<span className="select-none text-sm">{t('screen.resolution')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,205 @@
import { ChangeEvent, useRef, useState } from 'react';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Divider, Popconfirm, Popover } from 'antd';
import clsx from 'clsx';
import { ChevronRightIcon, FileCodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script.ts';
import { Run } from './run';
export const Script = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [scripts, setScripts] = useState<string[]>([]);
const [currentScript, setCurrentScript] = useState('');
const [isRunning, setIsRunning] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<any>(null);
function handleOpenChange(open: boolean) {
if (open) {
getScripts();
} else {
setCurrentScript('');
}
setIsPopoverOpen(open);
}
function selectFile() {
if (inputRef.current) {
inputRef.current.value = null;
}
inputRef.current?.click();
}
function uploadFile(e: ChangeEvent<HTMLInputElement>) {
if (!e.target?.files?.length) return;
const file = e.target.files[0];
if (isUploading) return;
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
api
.uploadScript(formData)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (!scripts.includes(rsp.data.file)) {
setScripts([...scripts, rsp.data.file]);
}
})
.finally(() => {
setIsUploading(false);
});
}
function runScript(type: string) {
if (!currentScript) return;
if (type === 'foreground') {
setIsRunning(true);
} else {
api.runScript(currentScript, type).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
});
}
setIsPopoverOpen(false);
}
function getScripts() {
api.getScripts().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data?.files?.length > 0) {
setScripts(rsp.data.files);
}
});
}
function deleteScript() {
if (!currentScript) return;
api.deleteScript(currentScript).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setScripts(scripts.filter((script) => script !== currentScript));
});
}
function activate(script: string) {
setCurrentScript(script === currentScript ? '' : script);
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
</div>
<input
ref={inputRef}
type="file"
accept=".sh,.py"
className="hidden"
onChange={uploadFile}
/>
<Button
ghost
type="primary"
size="small"
icon={<UploadOutlined />}
loading={isUploading}
onClick={selectFile}
>
{t('script.upload')}
</Button>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{scripts.map((script) => (
<div
key={script}
className={clsx(
'my-1 cursor-pointer rounded',
script === currentScript ? 'bg-neutral-700/50' : 'hover:bg-neutral-700/50'
)}
>
<div
className="flex items-center justify-between space-x-5 px-2 py-1.5"
onClick={() => activate(script)}
>
<div className="max-w-[300px] select-none truncate">{script}</div>
<div className={clsx('h-[16px] w-[16px]', script === currentScript && 'rotate-90')}>
<ChevronRightIcon size={16} />
</div>
</div>
{script === currentScript && (
<div className="flex items-center justify-end space-x-2 p-3">
<Button type="primary" size="small" onClick={() => runScript('foreground')}>
{t('script.run')}
</Button>
<Button type="primary" size="small" onClick={() => runScript('background')}>
{t('script.runBackground')}
</Button>
<Popconfirm
title={t('script.attention')}
description={t('script.delDesc')}
onConfirm={deleteScript}
onCancel={() => {}}
okText={t('script.confirm')}
cancelText={t('script.cancel')}
placement="bottom"
>
<Button danger type="primary" size="small">
{t('script.delete')}
</Button>
</Popconfirm>
</div>
)}
</div>
))}
</div>
);
return (
<>
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<FileCodeIcon size={18} />
</div>
</Popover>
{isRunning && <Run script={currentScript} setIsRunning={setIsRunning} />}
</>
);
};

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Modal, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script';
type RunProps = {
script: string;
setIsRunning: (isRunning: boolean) => void;
};
export const Run = ({ script, setIsRunning }: RunProps) => {
const { t } = useTranslation();
const [state, setState] = useState('');
const [log, setLog] = useState('');
useEffect(() => {
setState('running');
api
.runScript(script, 'foreground')
.then((rsp) => {
if (rsp.code !== 0) {
setLog(rsp.msg);
setState('failed');
return;
}
setState('success');
setLog(rsp.data.log);
})
.catch(() => {
setLog(t('script.runFailed'));
setState('failed');
});
}, []);
return (
<Modal
title={script}
width={600}
closable={false}
footer={null}
style={{ top: 60 }}
open={true}
>
{state === 'running' ? (
<div className="flex h-[300px] items-center justify-center">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : (
<Card className="h-[600px] overflow-auto whitespace-pre-line font-mono">{log}</Card>
)}
<div className="mt-5 flex justify-center">
<Button type="primary" onClick={() => setIsRunning(false)}>
{t('script.close')}
</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { BookOpenIcon, GithubIcon, LoaderCircleIcon, MessageSquareIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getInfo } from '@/api/vm';
import { isSettingsOpenAtom } from '@/jotai/settings';
type Info = {
ip: string;
mdns: string;
image: string;
firmware: string;
deviceKey: string;
};
export const About = () => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [info, setInfo] = useState<Info | undefined>();
const communities = [
{ name: 'wiki', icon: <BookOpenIcon size={24} />, url: 'https://wiki.sipeed.com/nanokvm' },
{ name: 'github', icon: <GithubIcon size={24} />, url: 'https://github.com/sipeed/NanoKVM' },
{
name: 'discussion',
icon: <MessageSquareIcon size={24} />,
url: 'https://maixhub.com/discussion/nanokvm'
}
];
useEffect(() => {
setLoading(true);
getInfo()
.then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
setInfo(undefined);
return;
}
setInfo(rsp.data);
})
.catch(() => {
setInfo(undefined);
})
.finally(() => {
setLoading(false);
});
}, []);
function showModal() {
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function openPage(url: string) {
window.open(url, '_blank');
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
{t('about.title')}
</div>
<Modal
title={t('about.title')}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
width={450}
footer={null}
centered
>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="flex w-full flex-col space-y-2">
<span className="text-neutral-400">{t('about.information')}</span>
{loading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : info ? (
<div className="flex w-full flex-col space-y-1">
{Object.entries(info).map(([key, value]) => (
<div key={key} className="flex w-full items-center justify-between">
<span>{t(`about.${key}`)}</span>
<span>{value}</span>
</div>
))}
</div>
) : (
<span>{t('about.queryFailed')}</span>
)}
</div>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="text-neutral-400">{t('about.community')}</div>
<div className="my-3 flex space-x-5">
{communities.map((community) => (
<div
key={community.name}
className="flex h-20 w-20 cursor-pointer flex-col items-center justify-center space-y-1 rounded text-neutral-400 outline outline-1 outline-neutral-700 hover:bg-neutral-800"
onClick={() => openPage(community.url)}
>
{community.icon}
<span className="text-xs text-neutral-300">{community.name}</span>
</div>
))}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export const ChangePassword = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function changePassword() {
navigate('/auth/password');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={changePassword}
>
{t('changePassword')}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import { Badge, Divider, Popover } from 'antd';
import { useAtom } from 'jotai';
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 { Update } from './update.tsx';
import { VirtualDevice } from './virtual-device.tsx';
export const Settings = () => {
const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom);
const [isBadgeVisible, setIsBadgeVisible] = useState(false);
const content = (
<>
<Language />
<About />
<Update setIsBadgeVisible={setIsBadgeVisible} />
<Divider style={{ margin: '10px 0' }} />
<VirtualDevice />
<Tailscale />
<Divider style={{ margin: '10px 0' }} />
<ChangePassword />
<Logout />
</>
);
return (
<>
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700">
<Badge dot={isBadgeVisible} color="blue" offset={[1, 0]}>
<SettingsIcon size={18} />
</Badge>
</div>
</Popover>
</>
);
};

View File

@@ -0,0 +1,59 @@
import { Popover } from 'antd';
import clsx from 'clsx';
import { LanguagesIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setLanguage } from '@/lib/localstorage.ts';
export const Language = () => {
const { t, i18n } = useTranslation();
function changeLanguage(language: string) {
if (i18n.language === language) return;
i18n.changeLanguage(language).then();
setLanguage(language);
}
const content = (
<>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('en')}
>
English
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'fr' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('fr')}
>
Français
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('zh')}
>
中文
</div>
</>
);
return (
<Popover content={content} placement="right" trigger="hover" arrow>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 text-white hover:bg-neutral-600">
<LanguagesIcon size={16} />
<span>{t('language')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { removeToken } from '@/lib/cookie.ts';
export const Logout = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function logout() {
removeToken();
navigate('/auth/login');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={logout}
>
{t('logout')}
</div>
);
};

View File

@@ -0,0 +1,202 @@
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<State>('');
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 (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={openModal}
>
Tailscale
</div>
<Modal
title="Tailscale"
open={isModalOpen}
width={450}
footer={null}
centered={true}
maskClosable={false}
onCancel={() => setIsModalOpen(false)}
>
<div className="flex h-[200px] items-center justify-center">
{isLoading ? (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.loading')}</span>
</div>
) : (
<>
{state === '' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
<span>{t('tailscale.notInstalled')}</span>
<div className="flex justify-center">
<Button type="primary" onClick={install}>
{t('tailscale.install')}
</Button>
</div>
</div>
)}
{state === 'installing' && (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.installing')}</span>
</div>
)}
{state === 'installed' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
{loginUrl ? (
<>
<a href={loginUrl} target="_blank">
{loginUrl}
</a>
<span className="text-sm text-neutral-400">{t('tailscale.urlPeriod')}</span>
</>
) : (
<>
<span>{t('tailscale.installed')}</span>
<div className="flex justify-center">
<Button type="primary" loading={isStarting} onClick={run}>
{t('tailscale.login')}
</Button>
</div>
</>
)}
</div>
)}
{state === 'failed' && (
<div className="flex flex-col space-y-10">
<div className="flex flex-col items-center justify-center font-bold text-neutral-400">
<span>{t('tailscale.failed')}</span>
<span>{t('tailscale.retry')}</span>
</div>
<ul className="list-decimal">
<li>
{t('tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('tailscale.package')}
</a>
{t('tailscale.unzip')}
</li>
<li>{t('tailscale.upTailscale')}</li>
<li>{t('tailscale.upTailscaled')}</li>
<li>{t('tailscale.refresh')}</li>
</ul>
</div>
)}
</>
)}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { Badge, Button, Modal, Spin } from 'antd';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import semver from 'semver';
import * as api from '@/api/firmware.ts';
import { getSkipUpdate, setSkipUpdate } from '@/lib/localstorage.ts';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type UpdateProps = {
setIsBadgeVisible: (isBadgeVisible: boolean) => void;
};
export const Update = ({ setIsBadgeVisible }: UpdateProps) => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [current, setCurrent] = useState('');
const [latest, setLatest] = useState('');
const [isLatest, setIsLatest] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [errMsg, setErrMsg] = useState('');
useEffect(() => {
const skip = getSkipUpdate();
if (!skip) {
getVersion();
}
}, []);
function showModal() {
getVersion();
setErrMsg('');
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function getVersion() {
if (isLoading) return;
setIsLoading(true);
api
.getVersion()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.queryFailed'));
return;
}
setCurrent(rsp.data.current);
setLatest(rsp.data.latest);
if (semver.gt(rsp.data.latest, rsp.data.current)) {
setIsLatest(false);
setIsBadgeVisible(true);
}
})
.catch(() => {
setErrMsg(t('update.queryFailed'));
})
.finally(() => {
setIsLoading(false);
});
}
function confirm() {
if (isLatest || !latest) {
setIsModalOpen(false);
return;
}
if (isUpdating) return;
setIsUpdating(true);
api
.update()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
return;
}
setTimeout(() => {
window.location.reload();
}, 6000);
})
.catch(() => {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
});
}
function cancel() {
setIsBadgeVisible(false);
setIsLatest(true);
setIsModalOpen(false);
setSkipUpdate(true);
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
<Badge dot={!isLatest} color="blue" offset={[5, 5]}>
{t('update.title')}
</Badge>
</div>
<Modal
title={t('update.title')}
open={isModalOpen}
width={400}
footer={null}
closable={false}
centered
>
{isUpdating ? (
<div className="flex h-[300px] flex-col items-center justify-center space-y-5">
<Spin spinning={isUpdating} size="large" />
<span className="text-neutral-400">{t('update.updating')}</span>
</div>
) : (
<div className="flex flex-col items-center justify-center pb-5 pt-10">
{isLoading ? (
<Spin tip={t('update.loading')} />
) : (
<>
{errMsg !== '' ? (
<span>{errMsg}</span>
) : (
<>
{isLatest ? (
<div className="flex flex-col items-center space-y-1">
<span className="text-neutral-400">{current}</span>
<span>{t('update.isLatest')}</span>
</div>
) : (
<div className="flex flex-col items-center space-y-1">
<div className="flex space-x-2">
<span>{current}</span>
<span className="text-neutral-500">{'->'}</span>
<span>{latest}</span>
</div>
<span>{t('update.available')}</span>
</div>
)}
</>
)}
</>
)}
<div className="mt-10 flex items-center space-x-3">
<Button type="primary" onClick={confirm}>
{t('update.confirm')}
</Button>
{!isLatest && (
<Button type="default" onClick={cancel}>
{t('update.cancel')}
</Button>
)}
</div>
</div>
)}
</Modal>
</>
);
};

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { CheckIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/virtual-device';
export const VirtualDevice = () => {
const { t } = useTranslation();
const [isNetworkOn, setIsNetworkOn] = useState(false);
const [isUSBOn, setIsUSBOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
api.getVirtualDevice().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsNetworkOn(rsp.data.network);
setIsUSBOn(rsp.data.usb);
});
}, []);
function update(device: string) {
if (isLoading) return;
setIsLoading(true);
api
.updateVirtualDevice(device)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (device === 'network') {
setIsNetworkOn(rsp.data.on);
} else {
setIsUSBOn(rsp.data.on);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('network')}
>
{isNetworkOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.network')}</span>
</div>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('usb')}
>
{isUSBOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.usb')}</span>
</div>
</>
);
};

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import {
DiscIcon,
FileBoxIcon,
LoaderCircleIcon,
PackageIcon,
PackageSearchIcon,
XIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import { client } from '@/lib/websocket.ts';
export const Storage = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<string[]>([]);
const [mountingFile, setMountingFile] = useState('');
const [mountedFile, setMountedFile] = useState('');
useEffect(() => {
getFiles();
}, []);
function handleOpenChange(open: boolean) {
if (open) {
getFiles();
}
setIsPopoverOpen(open);
}
// 获取镜像列表
function getFiles() {
if (isLoading) return;
setIsLoading(true);
api
.getImages()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const images = rsp.data.files;
if (!images?.length) {
setFiles([]);
return;
}
setFiles(images);
// 获取已挂载的镜像
api.getMountedImage().then((rsp) => {
if (rsp.code === 0 && rsp.data?.file && images.includes(rsp.data.file)) {
setMountedFile(rsp.data.file);
}
});
})
.finally(() => {
setIsLoading(false);
});
}
// 挂载/取消挂载
function mountFile(file: string) {
if (mountingFile) return;
setMountingFile(file);
client.close();
const filename = mountedFile === file ? '' : file;
api
.mountImage(filename)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMountedFile(filename);
})
.finally(() => {
setMountingFile('');
client.connect();
});
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('images')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{isLoading ? (
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('loading')}</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('empty')}</span>
</div>
) : (
files.map((file) => (
<div
key={file}
className={clsx(
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
{ 'text-blue-500': mountedFile === file }
)}
onClick={() => mountFile(file)}
>
{mountedFile === file ? (
<>
<div className="h-[18px] w-[18px] group-hover:text-red-500">
<PackageIcon size={18} className="block group-hover:hidden" />
<XIcon size={18} className="hidden group-hover:block" />
</div>
</>
) : mountingFile === file ? (
<div className="h-[18px] w-[18px]">
<LoaderCircleIcon className="animate-spin" size={18} />
</div>
) : (
<div className="h-[18px] w-[18px]">
<FileBoxIcon size={18} />
</div>
)}
<span className="break-all">{file.replace(/^.*[\\/]/, '')}</span>
</div>
))
)}
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div
className={clsx(
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
mountedFile ? 'text-blue-500' : 'text-neutral-300'
)}
>
<DiscIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Divider, Popover } from 'antd';
import { SquareTerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Nanokvm } from './nanokvm';
import { SerialPort } from './serial-port';
export const Terminal = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const content = (
<div className="min-w-[200px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('terminal.title')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
<Nanokvm setIsPopoverOpen={setIsPopoverOpen} />
<SerialPort setIsPopoverOpen={setIsPopoverOpen} />
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<SquareTerminalIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,25 @@
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type NanokvmProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const Nanokvm = ({ setIsPopoverOpen }: NanokvmProps) => {
const { t } = useTranslation();
function openTerminal() {
setIsPopoverOpen(false);
window.open('/#terminal', '_blank');
}
return (
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openTerminal}
>
<TerminalIcon size={16} />
<span>{t('terminal.nanokvm')}</span>
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { ChangeEvent, useState } from 'react';
import { Button, Input, InputNumber, Modal, Radio, RadioChangeEvent } from 'antd';
import { useSetAtom } from 'jotai';
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
type SerialPortProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const SerialPort = ({ setIsPopoverOpen }: SerialPortProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [port, setPort] = useState('');
const [baudrate, setBaudrate] = useState(115200);
function openModal() {
setIsKeyboardEnable(false);
setIsModalOpen(true);
setIsPopoverOpen(false);
}
function closeModal() {
setIsKeyboardEnable(true);
setIsModalOpen(false);
}
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
setPort(e.target.value);
}
function handleRadioChange(e: RadioChangeEvent) {
setPort(e.target.value);
}
function onInputNumberChange(value: number | null) {
if (value === null) return;
setBaudrate(value);
}
function submit() {
if (!port || !baudrate) {
closeModal();
return;
}
setIsModalOpen(false);
window.open(`/#terminal?port=${port}&baud=${baudrate}`, '_blank');
}
return (
<>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openModal}
>
<TerminalIcon size={16} />
<span>{t('terminal.serial')}</span>
</div>
<Modal open={isModalOpen} title={t('terminal.serial')} footer={null} onCancel={closeModal}>
<div className="mt-10 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">
{t('terminal.serialPort')}
</div>
<div className="w-1/2">
<Input
value={port}
placeholder={t('terminal.serialPortPlaceholder')}
onChange={handleInputChange}
/>
</div>
</div>
<div className="mt-3 pl-[100px]">
<Radio.Group size="large" onChange={handleRadioChange}>
<Radio value="/dev/ttyS1">
<code>/dev/ttyS1</code>
</Radio>
<Radio value="/dev/ttyS2">
<code>/dev/ttyS2</code>
</Radio>
</Radio.Group>
</div>
<div className="mt-7 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.baudrate')}</div>
<InputNumber
controls={false}
min={1}
defaultValue={115200}
onChange={onInputNumberChange}
/>
</div>
<div className="mb-3 mt-12 flex justify-center">
<Button type="primary" onClick={submit}>
{t('terminal.confirm')}
</Button>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,159 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Divider, Input, List, Popover } from 'antd';
import type { InputRef } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { NetworkIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { deleteWolMac, getWolMacs, wol } from '@/api/network.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Wol = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [input, setInput] = useState('');
const [status, setStatus] = useState('');
const [log, setLog] = useState('');
const [macList, setMacList] = useState<string[]>([]);
const inputRef = useRef<InputRef>(null);
function handleOpenChange(open: boolean) {
if (open) {
getMacs();
setIsKeyboardEnable(false);
} else {
setInput('');
setStatus('');
setLog('');
setIsKeyboardEnable(true);
}
setIsPopoverOpen(open);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setInput(e.target.value);
}
function getMacs() {
getWolMacs().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMacList(rsp.data.macs.filter((mac: string) => mac !== ''));
});
}
function deleteMac(mac: string) {
deleteWolMac(mac).then((rsp) => {
if (rsp.code === 0) {
setMacList(macList.filter((item) => item !== mac));
}
});
}
function wake(mac?: string) {
if (status === 'loading') return;
const value = mac ? mac : input;
if (!value) return;
setStatus('loading');
setLog(t('wol.sending'));
wol(value)
.then((rsp) => {
if (rsp.code !== 0) {
setStatus('failed');
setLog(rsp.msg);
return;
}
setStatus('success');
setLog(t('wol.sent'));
})
.catch(() => {
setStatus('failed');
});
}
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">Wake-on-LAN</span>
</div>
<Divider style={{ margin: '10px 0 10px 0' }} />
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
<div className="flex items-center space-x-1">
<Input ref={inputRef} value={input} onChange={handleChange} />
<Button type="primary" onClick={() => wake()}>
{t('wol.ok')}
</Button>
</div>
<div className={clsx('py-2')}>
{status && (
<div
className={clsx(
'max-w-[300px] break-words text-sm',
status === 'failed' ? 'text-red-500' : 'text-green-500'
)}
>
{log}
</div>
)}
</div>
<List
itemLayout="horizontal"
locale={{ emptyText: ' ' }}
dataSource={macList}
renderItem={(mac) => (
<List.Item className="flex w-full items-center justify-between">
<div className="h-[24px] max-w-[200px]">{mac}</div>
<div className="flex items-center space-x-3">
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
onClick={() => wake(mac)}
>
<SendIcon size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
onClick={() => deleteMac(mac)}
>
<Trash2Icon size={16} />
</div>
</div>
</List.Item>
)}
/>
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<NetworkIcon size={18} />
</div>
</Popover>
);
};

Some files were not shown because too many files have changed in this diff Show More