Merge pull request #252 from Itxaka/download_image

Add a download action in the menu
This commit is contained in:
wenjie
2025-01-16 11:33:24 +08:00
committed by GitHub
7 changed files with 404 additions and 0 deletions

17
server/router/download.go Normal file
View 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
}

View File

@@ -37,4 +37,5 @@ func server(r *gin.Engine) {
networkRouter(r)
hidRouter(r)
wsRouter(r)
downloadRouter(r)
}

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

View File

@@ -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: {

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

View File

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