Merge pull request #673 from imguoguo/add-autostart-upstream

feat: add autostart feature
This commit is contained in:
wenjie
2025-12-23 14:59:28 +08:00
committed by GitHub
8 changed files with 348 additions and 1 deletions

View File

@@ -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"`

View File

@@ -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
}

View 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
View 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);
}

View File

@@ -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',

View File

@@ -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盘',

View File

@@ -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>
);
};

View File

@@ -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>
);