mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
Merge pull request #692 from Alexander-Ger-Reich/main
Upload lokal ISO files and Offline Updater
This commit is contained in:
@@ -12,7 +12,9 @@ func applicationRouter(r *gin.Engine) {
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
api.GET("/application/version", service.GetVersion) // get application version
|
||||
api.GET("/application/currentversion", service.GetCurrentVersion) // get application current version
|
||||
api.POST("/application/update", service.Update) // update application
|
||||
api.POST("/application/uploadupdate", service.UploadUpdate) // upload update application
|
||||
|
||||
api.GET("/application/preview", service.GetPreview) // get preview updates state
|
||||
api.POST("/application/preview", service.SetPreview) // set preview updates state
|
||||
|
||||
@@ -14,4 +14,5 @@ func downloadRouter(r *gin.Engine) {
|
||||
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
|
||||
api.POST("/download/file", service.DownloadImageFile) // download image
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"regexp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -18,6 +21,8 @@ import (
|
||||
"NanoKVM-Server/utils"
|
||||
)
|
||||
|
||||
var sentinelPath = "/tmp/.download_in_progress"
|
||||
|
||||
const (
|
||||
maxTries = 3
|
||||
)
|
||||
@@ -27,6 +32,207 @@ var (
|
||||
isUpdating bool
|
||||
)
|
||||
|
||||
func (s *Service) UploadUpdate(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
updateMutex.Lock()
|
||||
if isUpdating {
|
||||
updateMutex.Unlock()
|
||||
rsp.ErrRsp(c, -1, "update already in progress")
|
||||
return
|
||||
}
|
||||
isUpdating = true
|
||||
updateMutex.Unlock()
|
||||
|
||||
defer func() {
|
||||
updateMutex.Lock()
|
||||
isUpdating = false
|
||||
updateMutex.Unlock()
|
||||
}()
|
||||
|
||||
if err := uploadupdate(rsp, c); err != nil {
|
||||
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("update application success")
|
||||
|
||||
// Sleep for a second before restarting the device
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
|
||||
}
|
||||
|
||||
func uploadupdate(rsp proto.Response, c *gin.Context) error {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
_ = os.MkdirAll(CacheDir, 0o755)
|
||||
defer func() {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
}()
|
||||
|
||||
// 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 err
|
||||
}
|
||||
|
||||
// Create the sentinel file
|
||||
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
return err
|
||||
}
|
||||
|
||||
// Multipart Reader direkt nutzen (keine FormFile!)
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
log.Error("invalid multipart data")
|
||||
rsp.ErrRsp(c, -1, "invalid multipart data")
|
||||
defer os.Remove(sentinelPath)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
var lw *loggingWriter
|
||||
var outPath = ""
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return err
|
||||
}
|
||||
|
||||
if part.FormName() != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := part.FileName()
|
||||
if filename == "" {
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return fmt.Errorf("no filename")
|
||||
}
|
||||
|
||||
filename = filepath.Base(filename)
|
||||
|
||||
if filename != part.FileName() {
|
||||
defer os.Remove(sentinelPath)
|
||||
return fmt.Errorf("path detected in filename")
|
||||
}
|
||||
|
||||
if strings.Contains(filename, "..") {
|
||||
log.Warn("path traversal attempt")
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return fmt.Errorf("path traversal attempt")
|
||||
}
|
||||
|
||||
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
if !valid.MatchString(filename) {
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return fmt.Errorf("err4")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return err
|
||||
}
|
||||
|
||||
outPath = "/data/" + filename
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if strings.Contains(string(data), "start") {
|
||||
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
|
||||
if err != nil {
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return err
|
||||
}
|
||||
|
||||
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
|
||||
lw.startTicker()
|
||||
} else {
|
||||
if !strings.Contains(string(data), filename) {
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return fmt.Errorf("failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
|
||||
_, err = io.Copy(lw, part)
|
||||
if err != nil {
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return err
|
||||
}
|
||||
}
|
||||
lw.stopTicker()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "idle",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
|
||||
defer os.Remove(sentinelPath)
|
||||
|
||||
// decompress
|
||||
dir, err := utils.UnTarGz(outPath, CacheDir)
|
||||
if err != nil {
|
||||
fmt.Errorf("decompress app failed: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// backup old version
|
||||
if err := os.RemoveAll(BackupDir); err != nil {
|
||||
fmt.Errorf("remove backup failed: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
|
||||
fmt.Errorf("backup app failed: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// update
|
||||
if err := utils.MoveFilesRecursively(dir, AppDir); err != nil {
|
||||
fmt.Errorf("failed to move update back in place: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// modify permissions
|
||||
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
|
||||
fmt.Errorf("chmod failed: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
defer os.Remove(outPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -172,3 +378,54 @@ func checksum(filePath string, expectedHash string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -48,6 +48,24 @@ func (s *Service) GetVersion(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) GetCurrentVersion(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// current version
|
||||
currentVersion := "1.0.0"
|
||||
|
||||
versionFile := fmt.Sprintf("%s/version", AppDir)
|
||||
if version, err := os.ReadFile(versionFile); err == nil {
|
||||
currentVersion = strings.ReplaceAll(string(version), "\n", "")
|
||||
}
|
||||
|
||||
log.Debugf("current version: %s", currentVersion)
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetVersionRsp{
|
||||
Current: currentVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func getLatest() (*Latest, error) {
|
||||
baseURL := StableURL
|
||||
if isPreviewEnabled() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
@@ -51,6 +52,28 @@ func (s *Service) ImageEnabled(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func isISO9660(path string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// ISO-9660 Magic "CD001" bei Offset 32769
|
||||
_, err = f.Seek(0x8001, io.SeekStart)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 5)
|
||||
_, err = io.ReadFull(f, buf)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return string(buf) == "CD001", nil
|
||||
}
|
||||
|
||||
func (s *Service) StatusImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -93,6 +116,170 @@ func (s *Service) StatusImage(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImage")
|
||||
|
||||
// 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("start"), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
return
|
||||
}
|
||||
|
||||
// Multipart Reader direkt nutzen (keine FormFile!)
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
log.Error("invalid multipart data")
|
||||
rsp.ErrRsp(c, -1, "invalid multipart data")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var lw *loggingWriter
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("failed to read part")
|
||||
rsp.ErrRsp(c, -1, "failed to read part")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
if part.FormName() != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := part.FileName()
|
||||
if filename == "" {
|
||||
log.Error("no filename")
|
||||
rsp.ErrRsp(c, -1, "no filename")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
filename = filepath.Base(filename)
|
||||
|
||||
if filename != part.FileName() {
|
||||
log.Warn("path detected in filename")
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(filename, "..") {
|
||||
log.Warn("path traversal attempt")
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
|
||||
rsp.ErrRsp(c, -1, "only .iso files allowed")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
if !valid.MatchString(filename) {
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
log.Error("Read failed")
|
||||
rsp.ErrRsp(c, -1, "Read failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
outPath := "/data/" + filename
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
log.Error("cannot create file")
|
||||
rsp.ErrRsp(c, -1, "cannot create file")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if strings.Contains(string(data), "start") {
|
||||
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
|
||||
lw.startTicker()
|
||||
} else {
|
||||
if !strings.Contains(string(data), filename) {
|
||||
log.Error("failed")
|
||||
rsp.ErrRsp(c, -1, "failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
|
||||
_, err = io.Copy(lw, part)
|
||||
if err != nil {
|
||||
log.Error("write failed")
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := isISO9660(outPath)
|
||||
if err != nil || !ok {
|
||||
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
lw.stopTicker()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "idle",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImage(c *gin.Context) {
|
||||
var req proto.MountImageReq
|
||||
var rsp proto.Response
|
||||
|
||||
@@ -5,6 +5,11 @@ export function getVersion() {
|
||||
return http.get('/api/application/version');
|
||||
}
|
||||
|
||||
// get application Current version
|
||||
export function getCurrentVersion() {
|
||||
return http.get('/api/application/currentversion');
|
||||
}
|
||||
|
||||
// update application to latest version
|
||||
export function update() {
|
||||
return http.request({
|
||||
|
||||
@@ -169,7 +169,10 @@ const de = {
|
||||
title: 'Systemabbild Downloader',
|
||||
input: 'Bitte geben Sie die URL für das Remote-Systemabbild ein',
|
||||
ok: 'Ok',
|
||||
disabled: '/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden'
|
||||
disabled: '/data Partition ist nur-lesbar, daher kann das Systemabbild nicht heruntergeladen werden',
|
||||
uploadbox: "Datei hier ablegen oder klicken zum Auswählen",
|
||||
inputfile: "Bitte geben Sie die Datei für das Systemabbild an",
|
||||
NoISO: "Keine ISO"
|
||||
},
|
||||
power: {
|
||||
title: 'Power',
|
||||
@@ -314,8 +317,19 @@ const de = {
|
||||
cancel: 'Abbrechen',
|
||||
preview: 'Vorab-Versionen',
|
||||
previewDesc: 'Erhalten Sie vorab Zugriff auf neue Funktionen und Verbesserungen',
|
||||
previewTip:
|
||||
'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!'
|
||||
previewTip: 'Bitte beachten Sie, dass Vorab-Versionen womöglich noch Fehler oder unvollständige Funktionen enthalten!'
|
||||
},
|
||||
offlineupdate: {
|
||||
title: 'Offline Aktualisierung',
|
||||
queryFailed: 'Version konnte nicht abgefragt werden',
|
||||
updateFailed: 'Aktualisierung fehlgeschlagen. Bitte versuchen Sie es erneut.',
|
||||
updating: 'Aktualisierung gestartet. Bitte warten...',
|
||||
confirm: 'Bestätigen',
|
||||
cancel: 'Abbrechen',
|
||||
inputfile: "Bitte geben Sie die Datei für das Update an",
|
||||
noupdatefile: "Keine Update Datei",
|
||||
uploadbox: "Datei hier ablegen oder zum Auswählen klicken",
|
||||
ok: 'Ok'
|
||||
},
|
||||
account: {
|
||||
title: 'Konto',
|
||||
|
||||
@@ -185,7 +185,10 @@ const en = {
|
||||
title: 'Image Downloader',
|
||||
input: 'Please enter a remote image URL',
|
||||
ok: 'Ok',
|
||||
disabled: '/data partition is RO, so we cannot download the image'
|
||||
disabled: '/data partition is RO, so we cannot download the image',
|
||||
uploadbox: "Drop file here or click to select",
|
||||
inputfile: "Please enter the image File",
|
||||
NoISO: "No ISO"
|
||||
},
|
||||
power: {
|
||||
title: 'Power',
|
||||
@@ -361,6 +364,18 @@ const en = {
|
||||
previewTip:
|
||||
'Please be aware that preview releases may contain bugs or incomplete functionality!'
|
||||
},
|
||||
offlineupdate: {
|
||||
title: 'Offline Updates',
|
||||
queryFailed: 'Get version failed',
|
||||
updateFailed: 'Update failed. Please retry.',
|
||||
updating: 'Update started. Please wait...',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
inputfile: "Please enter the Update File",
|
||||
noupdatefile: "No update file",
|
||||
uploadbox: "Drop file here or click to select",
|
||||
ok: 'Ok'
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
|
||||
@@ -21,6 +21,8 @@ export const DownloadImage = () => {
|
||||
const [popoverKey, setPopoverKey] = useState(0);
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const intervalId = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
|
||||
@@ -110,6 +112,58 @@ export const DownloadImage = () => {
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
|
||||
setStatus('failed');
|
||||
setLog(t('download.NoISO'));
|
||||
return;
|
||||
}
|
||||
setStatus('idle');
|
||||
setLog('');
|
||||
setSelectedFile(file);
|
||||
clearInterval(intervalId.current);
|
||||
intervalId.current = undefined;
|
||||
}
|
||||
|
||||
function upload(file: File | null) {
|
||||
if (!file) return;
|
||||
|
||||
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
|
||||
setStatus('failed');
|
||||
setLog(t('download.NoISO'));
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('in_progress');
|
||||
setLog('Downloading: ' + file.name);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
fetch("/api/download/file", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}).catch(() => {
|
||||
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
|
||||
setStatus('failed');
|
||||
setLog('Failed');
|
||||
}).then(() => {
|
||||
clearInterval(intervalId.current); // Clear the interval when the download is complete or fails
|
||||
setStatus('idle');
|
||||
setLog('');
|
||||
});
|
||||
|
||||
// Start the interval to check the download status
|
||||
if (!intervalId.current) {
|
||||
getDownloadStatus();
|
||||
setTimeout(() => {
|
||||
intervalId.current = setInterval(getDownloadStatus, 2500);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div key={popoverKey} className="min-w-[300px]">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
@@ -122,21 +176,83 @@ export const DownloadImage = () => {
|
||||
<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="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>
|
||||
<div>
|
||||
<div className="pb-1 text-neutral-500">{t('download.inputfile')}</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col items-center justify-center w-full h-10 border-2 border-solid rounded-xl transition css-9118ya ant-input-outlined",
|
||||
isDragging ? "bg-neutral-500 border-blue-500" : "",
|
||||
status === "in_progress" ? "opacity-50 cursor-not-allowed pointer-events-none border-neutral-600 bg-neutral-700" : "cursor-pointer hover:bg-neutral-500"
|
||||
)}
|
||||
onDrop={(e) => {
|
||||
if (status === "in_progress") return; // deaktiviert
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0] ?? null;
|
||||
if (!file || !file.name.toLowerCase().endsWith(".iso")) {
|
||||
setStatus('failed');
|
||||
setLog(t('download.NoISO'));
|
||||
return;
|
||||
}
|
||||
setStatus('idle');
|
||||
setLog('');
|
||||
setSelectedFile(file);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (status === "in_progress") return; // deaktiviert
|
||||
e.preventDefault();
|
||||
setIsDragging(true); // Datei wird über den Bereich gezogen
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (status === "in_progress") return; // deaktiviert
|
||||
e.preventDefault();
|
||||
setIsDragging(false); // Maus verlässt Bereich
|
||||
}}
|
||||
onClick={() => {
|
||||
if (status === "in_progress") return; // deaktiviert
|
||||
document.getElementById("file-upload")?.click()
|
||||
}}
|
||||
>
|
||||
<span className="text-neutral-100 text-sm p-1">
|
||||
{selectedFile ? selectedFile.name : t('download.uploadbox')}
|
||||
</span>
|
||||
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
disabled={status === 'in_progress'}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
className="h-10 border-2"
|
||||
onClick={() => upload(selectedFile)}
|
||||
disabled={status === 'in_progress' || !selectedFile}
|
||||
>
|
||||
{t('download.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
211
web/src/pages/desktop/menu/settings/offlineupdate/index.tsx
Normal file
211
web/src/pages/desktop/menu/settings/offlineupdate/index.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingOutlined, SmileOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Result, Spin } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import * as api from '@/api/application.ts';
|
||||
|
||||
type UpdateProps = {
|
||||
setIsLocked: (isClosable: boolean) => void;
|
||||
};
|
||||
|
||||
type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'latests' | 'failed';
|
||||
|
||||
export const OfflineUpdate = ({ setIsLocked }: UpdateProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [status, setStatus] = useState<Status>('');
|
||||
const [currentVersion, setCurrentVersion] = useState('');
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkForUpdates();
|
||||
}, []);
|
||||
|
||||
function checkForUpdates() {
|
||||
if (status === 'loading') return;
|
||||
setStatus('loading');
|
||||
|
||||
setIsLocked(true);
|
||||
|
||||
api
|
||||
.getCurrentVersion()
|
||||
.then((rsp: any) => {
|
||||
if (rsp.code !== 0 || !rsp.data) {
|
||||
setStatus('failed');
|
||||
setErrMsg(t('settings.offlineupdate.queryFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('latest');
|
||||
setCurrentVersion(rsp.data.current);
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus('failed');
|
||||
setErrMsg(t('settings.offlineupdate.queryFailed'));
|
||||
});
|
||||
|
||||
setIsLocked(false);
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
if (!file || !file.name.toLowerCase().endsWith(".tar.gz")) {
|
||||
setStatus('failed');
|
||||
setErrMsg(t('settings.offlineupdate.noupdatefile'));
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
|
||||
function upload(file: File | null) {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".tar.gz")) {
|
||||
setStatus('failed');
|
||||
setErrMsg(t('settings.offlineupdate.noupdatefile'));
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('updating');
|
||||
setErrMsg('');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
fetch("/api/application/uploadupdate", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((rsp: Response) => {
|
||||
// Prüfen ob HTTP OK
|
||||
if (!rsp.ok) throw new Error(`HTTP error ${rsp.status}`);
|
||||
return rsp.json(); // JSON-Payload parsen
|
||||
})
|
||||
.then((rspj: any) => {
|
||||
// Jetzt rspj ist das tatsächliche JSON
|
||||
if (rspj.code !== 0 || !rspj.data) {
|
||||
setStatus('failed');
|
||||
setErrMsg(rspj.msg || 'Unknown error');
|
||||
console.log(rspj);
|
||||
return;
|
||||
}
|
||||
checkForUpdates();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setStatus('failed');
|
||||
setErrMsg(err?.message || 'Failed');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-base font-bold">{t('settings.offlineupdate.title') + ", " + t('settings.about.application') + ": " + currentVersion}</div>
|
||||
|
||||
<div className="my-[20px] h-px bg-neutral-500/10" />
|
||||
|
||||
<div className="flex min-h-[150px] flex-col justify-between">
|
||||
{status === 'loading' && (
|
||||
<div className="flex justify-center pt-24">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'latest' && (
|
||||
<div>
|
||||
<div className="pb-1 text-neutral-500">{t('settings.offlineupdate.inputfile')}</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col items-center justify-center w-full h-20 border-2 border-solid rounded-xl transition css-9118ya ant-input-outlined cursor-pointer border-color hover:bg-neutral-500",
|
||||
isDragging ? "bg-neutral-500 border-blue-500" : ""
|
||||
)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0] ?? null;
|
||||
if (!file || !file.name.toLowerCase().endsWith(".tar.gz")) {
|
||||
setStatus('failed');
|
||||
setErrMsg(t('settings.offlineupdate.noupdatefile'));
|
||||
return;
|
||||
}
|
||||
setStatus('latest');
|
||||
setErrMsg('');
|
||||
setSelectedFile(file);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true); // Datei wird über den Bereich gezogen
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false); // Maus verlässt Bereich
|
||||
}}
|
||||
onClick={() => {
|
||||
document.getElementById("file-upload")?.click()
|
||||
}}
|
||||
>
|
||||
<span className="text-neutral-100 text-sm p-1">
|
||||
{selectedFile ? selectedFile.name : t('settings.offlineupdate.uploadbox')}
|
||||
</span>
|
||||
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
className="h-20 border-2 w-[5rem]"
|
||||
onClick={() => upload(selectedFile)}
|
||||
disabled={!selectedFile}
|
||||
>
|
||||
{t('settings.offlineupdate.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'updating' && (
|
||||
<div className="flex flex-col items-center justify-center space-y-10 pb-10 pt-24">
|
||||
<Spin size="large" />
|
||||
<span className="text-blue-600">{t('settings.offlineupdate.updating')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'latests' && (
|
||||
<Result
|
||||
status="success"
|
||||
icon={<SmileOutlined />}
|
||||
title={currentVersion}
|
||||
subTitle={t('settings.offlineupdate.isLatest')}
|
||||
extra={[
|
||||
<Button key="confirm" onClick={checkForUpdates}>
|
||||
{t('settings.offlineupdate.title')}
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'failed' && <Result subTitle={errMsg} />}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
href="https://github.com/sipeed/NanoKVM/blob/main/CHANGELOG.md"
|
||||
target="_blank"
|
||||
>
|
||||
CHANGELOG
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user