mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Merge pull request #673 from imguoguo/add-autostart-upstream
feat: add autostart feature
This commit is contained in:
@@ -55,6 +55,15 @@ type DeleteScriptReq struct {
|
||||
Name string `validate:"required"`
|
||||
}
|
||||
|
||||
// autostart
|
||||
type GetAutostartRsp struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
type UploadAutostartReq struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type GetVirtualDeviceRsp struct {
|
||||
Network bool `json:"network"`
|
||||
Media bool `json:"media"`
|
||||
|
||||
@@ -67,5 +67,10 @@ func vmRouter(r *gin.Engine) {
|
||||
|
||||
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
|
||||
|
||||
api.GET("/vm/autostart", service.GetAutostart) // get autostart list
|
||||
api.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
|
||||
api.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
|
||||
api.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
|
||||
|
||||
api.POST("/vm/system/reboot", service.Reboot) // reboot system
|
||||
}
|
||||
|
||||
104
server/service/vm/autostart.go
Normal file
104
server/service/vm/autostart.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const autostartDirectory = "/etc/kvm/autostart"
|
||||
|
||||
func (s *Service) GetAutostart(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
var files []string
|
||||
err := filepath.Walk(autostartDirectory, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
files = append(files, info.Name())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "get autostart directory fail")
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.GetAutostartRsp{
|
||||
Files: files,
|
||||
})
|
||||
|
||||
log.Debugf("get autostart total %d", len(files))
|
||||
}
|
||||
|
||||
func (s *Service) UploadAutostart(c *gin.Context) {
|
||||
var req proto.UploadAutostartReq
|
||||
var rsp proto.Response
|
||||
|
||||
fileName := c.Param("name")
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "parse form request fail")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(autostartDirectory); err != nil {
|
||||
_ = os.MkdirAll(autostartDirectory, 0o755)
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
|
||||
|
||||
f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "create file fail")
|
||||
return
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(req.Content)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "write content fail")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, fileName)
|
||||
|
||||
log.Debugf("upload autostart %s success", fileName)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAutostart(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
fileName := c.Param("name")
|
||||
|
||||
file := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
|
||||
if err := os.Remove(file); err != nil {
|
||||
log.Errorf("delete autostart file %s fail", fileName)
|
||||
rsp.ErrRsp(c, -3, "remove file fail")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete autostart %s success", fileName)
|
||||
}
|
||||
|
||||
func (s *Service) GetAutostartContent(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
fileName := c.Param("name")
|
||||
file := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "read file fail")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, string(content))
|
||||
log.Debugf("get autostart content %s success", fileName)
|
||||
}
|
||||
20
web/src/api/autostart.ts
Normal file
20
web/src/api/autostart.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
export function getAutostart() {
|
||||
return http.get('/api/vm/autostart');
|
||||
}
|
||||
|
||||
export function uploadAutostart(name: string, content: string) {
|
||||
return http.post('/api/vm/autostart/' + name, { content });
|
||||
}
|
||||
|
||||
export function deleteAutostart(name: string) {
|
||||
return http.request({
|
||||
url: '/api/vm/autostart/' + name,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export function getAutostartContent(name: string) {
|
||||
return http.get('/api/vm/autostart/' + name);
|
||||
}
|
||||
@@ -277,6 +277,17 @@ const en = {
|
||||
hdmi: {
|
||||
description: 'Enable HDMI/monitor output'
|
||||
},
|
||||
autostart: {
|
||||
title: 'Autostart Scripts Settings',
|
||||
description: 'Manage scripts that run automatically on system startup',
|
||||
new: 'New',
|
||||
deleteConfirm: 'Are you sure you want to delete this file?',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
scriptName: 'Autostart Script Name',
|
||||
scriptContent: 'Autostart Script Content',
|
||||
settings: 'Settings'
|
||||
},
|
||||
hidOnly: 'HID-Only Mode',
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount SD card on the remote host',
|
||||
|
||||
@@ -251,6 +251,17 @@ const zh = {
|
||||
hdmi: {
|
||||
description: '启用 HDMI/显示器 输出功能'
|
||||
},
|
||||
autostart: {
|
||||
title: '自动启动脚本设置',
|
||||
description: '管理能够在 NanoKVM 启动时自动运行的脚本文件',
|
||||
new: '创建新脚本',
|
||||
deleteConfirm: '确定要删除该文件吗?',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
scriptName: '自动启动脚本名称',
|
||||
scriptContent: '自动启动脚本内容',
|
||||
settings: '设置'
|
||||
},
|
||||
hidOnly: 'HID-Only 模式',
|
||||
disk: '虚拟U盘',
|
||||
diskDesc: '在远程主机中挂载虚拟U盘',
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Modal, Popconfirm, Space, Table } from 'antd';
|
||||
import TextArea from 'antd/es/input/TextArea';
|
||||
import type { TableProps } from 'antd/es/table';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/autostart.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
export const Autostart = () => {
|
||||
interface AutostartItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
|
||||
const [isEditAutostartOpen, setIsEditAutostartOpen] = useState(false);
|
||||
const [isManageAutostartOpen, setIsManageAutostartOpen] = useState(false);
|
||||
const [isAutostartNameEditable, setIsAutostartNameEditable] = useState(true);
|
||||
const [autostartItems, setAutostartItems] = useState<AutostartItem[]>([]);
|
||||
const [autostartName, setAutostartName] = useState('');
|
||||
const [autostartContent, setAutostartContent] = useState('');
|
||||
|
||||
const autostartColumns: TableProps<AutostartItem>['columns'] = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => editAutostart(record.name)} />
|
||||
|
||||
<Popconfirm
|
||||
title={t('settings.device.autostart.deleteConfirm')}
|
||||
onConfirm={() => deleteAutostart(record.name)}
|
||||
okText={t('settings.device.autostart.yes')}
|
||||
cancelText={t('settings.device.autostart.no')}
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setIsKeyboardEnable(false);
|
||||
getAutostart();
|
||||
|
||||
return () => {
|
||||
setIsKeyboardEnable(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getAutostart() {
|
||||
api.getAutostart().then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
setAutostartItems([]);
|
||||
if (rsp.data?.files?.length > 0) {
|
||||
rsp.data.files.forEach((item: string) => {
|
||||
setAutostartItems((prevItems) => [...prevItems, { name: item }]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function uploadAutostart() {
|
||||
api.uploadAutostart(autostartName, autostartContent).then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
getAutostart();
|
||||
setAutostartContent('');
|
||||
setAutostartName('');
|
||||
setIsEditAutostartOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
function editAutostart(name: string) {
|
||||
api.getAutostartContent(name).then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
setAutostartName(name);
|
||||
setAutostartContent(rsp.data);
|
||||
setIsEditAutostartOpen(true);
|
||||
setIsAutostartNameEditable(false);
|
||||
});
|
||||
}
|
||||
|
||||
function closeEditAutostart() {
|
||||
setAutostartContent('');
|
||||
setAutostartName('');
|
||||
setIsAutostartNameEditable(true);
|
||||
setIsEditAutostartOpen(false);
|
||||
}
|
||||
|
||||
function deleteAutostart(name: string) {
|
||||
api.deleteAutostart(name).then((rsp) => {
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
getAutostart();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{t('settings.device.autostart.title')}</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t('settings.device.autostart.description')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setIsManageAutostartOpen(true);
|
||||
}}
|
||||
icon={<SettingOutlined />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('settings.device.autostart.title')}
|
||||
open={isEditAutostartOpen}
|
||||
onOk={uploadAutostart}
|
||||
onCancel={closeEditAutostart}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Input
|
||||
placeholder={t('settings.device.autostart.scriptName')}
|
||||
value={autostartName}
|
||||
disabled={!isAutostartNameEditable}
|
||||
onChange={(e) => setAutostartName(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder={t('settings.device.autostart.scriptContent')}
|
||||
value={autostartContent}
|
||||
onChange={(e) => setAutostartContent(e.target.value)}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={isManageAutostartOpen}
|
||||
footer=""
|
||||
onCancel={() => setIsManageAutostartOpen(false)}
|
||||
title={t('settings.device.autostart.title')}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setIsEditAutostartOpen(true);
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
{t('settings.device.autostart.new')}
|
||||
</Button>
|
||||
</div>
|
||||
<Table<AutostartItem> columns={autostartColumns} dataSource={autostartItems} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,12 @@ import { Collapse } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Swap } from './swap.tsx';
|
||||
import { Autostart } from './autostart.tsx';
|
||||
|
||||
const children = (
|
||||
<div className="py-3">
|
||||
<div className="py-3 space-y-6">
|
||||
<Swap />
|
||||
<Autostart />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user