Merge pull request #692 from Alexander-Ger-Reich/main

Upload lokal ISO files and Offline Updater
This commit is contained in:
wenjie
2025-12-24 09:48:02 +08:00
committed by GitHub
10 changed files with 845 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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