mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Merge pull request #252 from Itxaka/download_image
Add a download action in the menu
This commit is contained in:
17
server/router/download.go
Normal file
17
server/router/download.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/service/download"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
)
|
||||
|
||||
func downloadRouter(r *gin.Engine) {
|
||||
service := download.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
api.POST("/download/image", service.DownloadImage) // download image
|
||||
api.GET("/download/image/status", service.StatusImage) // download image
|
||||
api.GET("/download/image/enabled", service.ImageEnabled) // download image
|
||||
}
|
||||
@@ -37,4 +37,5 @@ func server(r *gin.Engine) {
|
||||
networkRouter(r)
|
||||
hidRouter(r)
|
||||
wsRouter(r)
|
||||
downloadRouter(r)
|
||||
}
|
||||
|
||||
198
server/service/download/service.go
Normal file
198
server/service/download/service.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
var sentinelPath = "/tmp/.download_in_progress"
|
||||
|
||||
func NewService() *Service {
|
||||
// Clear sentinel
|
||||
// If we are starting from scratch, we need to remove the sentinel file as any downloads at this point are done or broken
|
||||
_ = os.Remove(sentinelPath)
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) ImageEnabled(c *gin.Context) {
|
||||
// Check if /data mount is RO/RW
|
||||
testFile := "/data/.testfile"
|
||||
file, err := os.Create(testFile)
|
||||
defer file.Close()
|
||||
defer os.Remove(testFile)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": false}) // Other error
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"enabled": true})
|
||||
}
|
||||
|
||||
func (s *Service) StatusImage(c *gin.Context) {
|
||||
// Check if the sentinel file exists
|
||||
log.Debug("StatusImage")
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
content, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to read sentinel file")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": ""})
|
||||
return
|
||||
}
|
||||
splitted := strings.Split(string(content), ";")
|
||||
if len(splitted) == 1 {
|
||||
// No percentage, just the URL
|
||||
c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": splitted[0]})
|
||||
} else {
|
||||
// Percentage is available
|
||||
c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": splitted[0], "percentage": splitted[1]})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "idle"})
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImage(c *gin.Context) {
|
||||
var req proto.MountImageReq
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImage")
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if req.File == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
// Parse the URI to see if its valid http/s
|
||||
u, err := url.Parse(req.File)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid url")
|
||||
return
|
||||
}
|
||||
|
||||
// Set a sentinel file to mark that there is a download in progress
|
||||
// This is to prevent multiple downloads at the same time
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
log.Debug("Download in progress")
|
||||
rsp.ErrRsp(c, -1, "download in progress")
|
||||
return
|
||||
}
|
||||
// Create the sentinel file
|
||||
err = os.WriteFile(sentinelPath, []byte(req.File), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it actually exists and fail if it doesn't
|
||||
resp, err := http.Head(req.File)
|
||||
if resp.StatusCode != http.StatusOK || err != nil {
|
||||
rsp.ErrRsp(c, resp.StatusCode, "failed when checking the url")
|
||||
log.Error("Failed to check the URL")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Download the image in a goroutine to not block the request
|
||||
go func() {
|
||||
defer os.Remove(sentinelPath)
|
||||
resp, err = http.Get(req.File)
|
||||
if err != nil {
|
||||
log.Error("Failed to download the file")
|
||||
rsp.ErrRsp(c, -1, "failed to download the file")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Create the destination file
|
||||
destPath := filepath.Join("/data", filepath.Base(u.Path))
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to create destination file")
|
||||
rsp.ErrRsp(c, -1, "failed to create destination file")
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
lw := &loggingWriter{writer: out, totalSize: resp.ContentLength}
|
||||
lw.startTicker()
|
||||
_, err = io.Copy(lw, resp.Body)
|
||||
if err != nil {
|
||||
log.Error("Failed to save the file")
|
||||
rsp.ErrRsp(c, -1, "failed to save the file")
|
||||
lw.stopTicker()
|
||||
return
|
||||
}
|
||||
lw.stopTicker()
|
||||
}()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "in_progress", "file": req.File})
|
||||
}
|
||||
|
||||
type loggingWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan bool
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) startTicker() {
|
||||
lw.ticker = time.NewTicker(2500 * time.Millisecond)
|
||||
lw.done = make(chan bool)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-lw.done:
|
||||
return
|
||||
case <-lw.ticker.C:
|
||||
lw.updateSentinel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) stopTicker() {
|
||||
lw.ticker.Stop()
|
||||
lw.done <- true
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) updateSentinel() {
|
||||
percentage := float64(lw.total) / float64(lw.totalSize) * 100
|
||||
content, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to read sentinel file")
|
||||
return
|
||||
}
|
||||
splitted := strings.Split(string(content), ";")
|
||||
if len(splitted) == 0 {
|
||||
return
|
||||
}
|
||||
err = os.WriteFile(sentinelPath, []byte(fmt.Sprintf("%s;%.2f%%", splitted[0], percentage)), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to update sentinel file")
|
||||
}
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) Write(p []byte) (int, error) {
|
||||
n, err := lw.writer.Write(p)
|
||||
lw.total += int64(n)
|
||||
return n, err
|
||||
}
|
||||
17
web/src/api/download.ts
Normal file
17
web/src/api/download.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { http } from '@/lib/http.ts';
|
||||
|
||||
// Download image
|
||||
export function downloadImage(file?: string) {
|
||||
const data = {
|
||||
file: file ? file : ''
|
||||
};
|
||||
return http.post('/api/download/image', data);
|
||||
}
|
||||
|
||||
export function statusImage() {
|
||||
return http.get('/api/download/image/status');
|
||||
}
|
||||
|
||||
export function imageEnabled() {
|
||||
return http.get('/api/download/image/enabled');
|
||||
}
|
||||
@@ -143,6 +143,12 @@ const en = {
|
||||
powerShort: 'Power (short click)',
|
||||
powerLong: 'Power (long click)'
|
||||
},
|
||||
download: {
|
||||
download: 'Download Image',
|
||||
input: 'Please enter a remote image URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partition is RO, so we cannot download the image',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
about: {
|
||||
|
||||
163
web/src/pages/desktop/menu/download.tsx
Normal file
163
web/src/pages/desktop/menu/download.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { ChangeEvent, useEffect, 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 { DownloadIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { downloadImage, statusImage, imageEnabled } from '@/api/download.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
export const DownloadImage = () => {
|
||||
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 [diskEnabled, setDiskEnabled] = useState(false);
|
||||
const [popoverKey, setPopoverKey] = useState(0);
|
||||
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const intervalId = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
checkDiskEnabled();
|
||||
}, []);
|
||||
|
||||
function checkDiskEnabled() {
|
||||
imageEnabled()
|
||||
.then((res) => {
|
||||
console.log(res.enabled);
|
||||
setDiskEnabled(res.enabled);
|
||||
})
|
||||
.catch((err) => {
|
||||
setDiskEnabled(false);
|
||||
});
|
||||
}
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
clearInterval(intervalId);
|
||||
checkDiskEnabled();
|
||||
getDownloadStatus();
|
||||
if (!intervalId.current) {
|
||||
intervalId.current = setInterval(getDownloadStatus, 2500);
|
||||
};
|
||||
setIsKeyboardEnable(false);
|
||||
setPopoverKey(prevKey => prevKey + 1); // Force re-render
|
||||
} else {
|
||||
setInput('');
|
||||
setStatus('');
|
||||
setLog('');
|
||||
|
||||
setIsKeyboardEnable(true);
|
||||
clearInterval(intervalId.current);
|
||||
intervalId.current = undefined;
|
||||
}
|
||||
|
||||
setIsPopoverOpen(open);
|
||||
}
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
setInput(e.target.value);
|
||||
}
|
||||
|
||||
function getDownloadStatus() {
|
||||
statusImage().then((rsp) => {
|
||||
if (rsp.status) {
|
||||
setStatus(rsp.status);
|
||||
if (rsp.status === 'in_progress') {
|
||||
// Check if rsp has a percentage value
|
||||
if (rsp.percentage) {
|
||||
setLog('Downloading ('+ rsp.percentage + ')' + ': ' + rsp.file);
|
||||
} else {
|
||||
setLog('Downloading' + ': ' + rsp.file);
|
||||
}
|
||||
setInput(rsp.file);
|
||||
};
|
||||
if (rsp.status === 'failed') {
|
||||
setLog('Failed');
|
||||
clearInterval(intervalId.current);
|
||||
};
|
||||
if (rsp.status === 'idle') {
|
||||
setLog(''); // Clear the log
|
||||
clearInterval(intervalId.current);
|
||||
};
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function download(url?: string) {
|
||||
setStatus('in_progress');
|
||||
setLog('Downloading: ' + url);
|
||||
// start the getDownloadStatus to tick every 5 seconds
|
||||
|
||||
downloadImage(url).then((rsp) => {
|
||||
getDownloadStatus();
|
||||
// Start the interval to check the download status
|
||||
if (!intervalId.current) {
|
||||
intervalId.current = setInterval(getDownloadStatus, 2500);}
|
||||
}).catch((err) => {
|
||||
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
|
||||
setStatus('failed');
|
||||
setLog('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">{t('download.download')}</span>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '10px 0 10px 0' }} />
|
||||
|
||||
{!diskEnabled ? (
|
||||
<div className="text-red-500">{t('download.disabled')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pb-1 text-neutral-500">{t('download.input')}</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Input ref={inputRef} value={input} onChange={handleChange} disabled={status === 'in_progress'} />
|
||||
<Button type="primary" onClick={() => download(input)} disabled={status === 'in_progress'}>
|
||||
{t('download.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>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
key={popoverKey}
|
||||
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">
|
||||
<DownloadIcon size={18} />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import { Screen } from './screen';
|
||||
import { Script } from './script';
|
||||
import { Settings } from './settings';
|
||||
import { Terminal } from './terminal';
|
||||
import { DownloadImage } from './download.tsx';
|
||||
import { Wol } from './wol';
|
||||
|
||||
export const Menu = () => {
|
||||
@@ -50,6 +51,7 @@ export const Menu = () => {
|
||||
{!menuDisabledItems.includes('script') && <Script />}
|
||||
{!menuDisabledItems.includes('terminal') && <Terminal />}
|
||||
{!menuDisabledItems.includes('wol') && <Wol />}
|
||||
<DownloadImage />
|
||||
|
||||
{['image', 'script', 'terminal', 'wol'].some(
|
||||
(key) => !menuDisabledItems.includes(key)
|
||||
|
||||
Reference in New Issue
Block a user