diff --git a/.drone.yml b/.drone.yml index 84cd1f5..f379b26 100644 --- a/.drone.yml +++ b/.drone.yml @@ -14,5 +14,5 @@ steps: image: golangci/golangci-lint:latest commands: - go mod download - - golangci-lint run -v --timeout 2m + - golangci-lint run --timeout 5m - go test -v ./src/... diff --git a/.vscode/launch.json b/.vscode/launch.json index 69ed1d3..1123069 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,7 +2,7 @@ "version": "0.2.0", "configurations": [ { - "name": "Launch SEBRAUC server", + "name": "SEBRAUC server", "type": "go", "request": "launch", "mode": "auto", diff --git a/go.mod b/go.mod index 7d1b400..dd949c7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module code.thetadev.de/TSGRain/SEBRAUC go 1.16 require ( - code.thetadev.de/ThetaDev/gotry v0.3.2 github.com/davecgh/go-spew v1.1.1 // indirect github.com/gofiber/fiber/v2 v2.21.0 github.com/gofiber/websocket/v2 v2.0.12 diff --git a/go.sum b/go.sum index ef5350b..fe6435e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -code.thetadev.de/ThetaDev/gotry v0.3.2 h1:x5JOBszLbCo4FDe9V8ynHsV6EfvALV7wUqnJ/5vtjbw= -code.thetadev.de/ThetaDev/gotry v0.3.2/go.mod h1:lKo6abOTMy5uO25ifG7JsGG3DYZd0XZd0xqa6y41BoU= github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E= github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/openapi.yml b/openapi.yml index 4daeb3e..a9332ce 100644 --- a/openapi.yml +++ b/openapi.yml @@ -45,6 +45,22 @@ paths: schema: $ref: "#/components/schemas/StatusMessage" + /reboot: + post: + responses: + "200": + description: "OK" + content: + "application/json": + schema: + $ref: "#/components/schemas/StatusMessage" + default: + description: "Server error" + content: + "application/json": + schema: + $ref: "#/components/schemas/StatusMessage" + components: schemas: RaucStatus: diff --git a/src/fixtures/testutil.go b/src/fixtures/testutil.go index 3646a5d..ee2f352 100644 --- a/src/fixtures/testutil.go +++ b/src/fixtures/testutil.go @@ -3,8 +3,6 @@ package fixtures import ( "os" "path/filepath" - - "code.thetadev.de/ThetaDev/gotry/try" ) func doesFileExist(filepath string) bool { @@ -13,7 +11,10 @@ func doesFileExist(filepath string) bool { } func getProjectRoot() string { - p := try.String(os.Getwd()) + p, err := os.Getwd() + if err != nil { + panic(err) + } for i := 0; i < 10; i++ { if doesFileExist(filepath.Join(p, "go.mod")) { @@ -27,7 +28,10 @@ func getProjectRoot() string { func CdProjectRoot() { root := getProjectRoot() - try.Check(os.Chdir(root)) + err := os.Chdir(root) + if err != nil { + panic(err) + } } func GetTestfilesDir() string { diff --git a/src/fixtures/testutil_test.go b/src/fixtures/testutil_test.go index 8cca85c..44b32ef 100644 --- a/src/fixtures/testutil_test.go +++ b/src/fixtures/testutil_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "testing" - "code.thetadev.de/ThetaDev/gotry/try" "github.com/stretchr/testify/assert" ) @@ -17,7 +16,10 @@ func TestGetProjectRoot(t *testing.T) { t.Run("subdir", func(t *testing.T) { root1 := getProjectRoot() - try.Check(os.Chdir(filepath.Join(root1, "src/rauc"))) + err := os.Chdir(filepath.Join(root1, "src/rauc")) + if err != nil { + panic(err) + } root := getProjectRoot() assert.True(t, doesFileExist(filepath.Join(root, "go.sum"))) @@ -26,7 +28,10 @@ func TestGetProjectRoot(t *testing.T) { func TestCdProjectRoot(t *testing.T) { CdProjectRoot() - try.Check(os.Chdir("src/rauc")) + err := os.Chdir("src/rauc") + if err != nil { + panic(err) + } CdProjectRoot() assert.True(t, doesFileExist("go.sum")) } diff --git a/src/rauc/rauc.go b/src/rauc/rauc.go index 246f308..7d2f6d7 100644 --- a/src/rauc/rauc.go +++ b/src/rauc/rauc.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + "os" "os/exec" "regexp" "strconv" @@ -33,9 +34,11 @@ type RaucStatus struct { Log string `json:"log"` } -func (r *Rauc) completed() { +func (r *Rauc) completed(updateFile string) { r.status.Installing = false r.Broadcast <- r.GetStatusJson() + + _ = os.Remove(updateFile) } func (r *Rauc) RunRauc(updateFile string) error { @@ -98,7 +101,7 @@ func (r *Rauc) RunRauc(updateFile string) error { err := cmd.Start() if err != nil { - r.completed() + r.completed(updateFile) return err } @@ -107,7 +110,7 @@ func (r *Rauc) RunRauc(updateFile string) error { if err != nil { fmt.Printf("RAUC failed with %s\n", err) } - r.completed() + r.completed(updateFile) }() return nil diff --git a/src/server/server.go b/src/server/server.go index 63ef036..ee2d50b 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -2,8 +2,10 @@ package server import ( "errors" + "fmt" "net/http" "strings" + "time" "code.thetadev.de/TSGRain/SEBRAUC/src/rauc" "code.thetadev.de/TSGRain/SEBRAUC/src/util" @@ -19,6 +21,8 @@ type SEBRAUCServer struct { address string raucUpdater *rauc.Rauc hub *MessageHub + tmpdir string + currentId int } type statusMessage struct { @@ -33,23 +37,30 @@ func NewServer(address string) *SEBRAUCServer { Command: "go", Args: []string{ "run", - "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock", "fail", + "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock", }, Broadcast: hub.Broadcast, } + tmpdir, err := util.NewTmpdir() + if err != nil { + panic(err) + } + return &SEBRAUCServer{ address: address, raucUpdater: raucUpdater, hub: hub, + tmpdir: tmpdir, } } func (srv *SEBRAUCServer) Run() error { app := fiber.New(fiber.Config{ - AppName: "SEBRAUC", - BodyLimit: 1024 * 1024 * 1024, - ErrorHandler: errorHandler, + AppName: "SEBRAUC", + BodyLimit: 1024 * 1024 * 1024, + ErrorHandler: errorHandler, + DisableStartupMessage: true, }) app.Use(logger.New()) @@ -75,9 +86,9 @@ func (srv *SEBRAUCServer) Run() error { // ROUTES app.Get("/api/ws", websocket.New(srv.hub.Handler)) - app.Get("/api/test", srv.controllerTest) app.Post("/api/update", srv.controllerUpdate) app.Get("/api/status", srv.controllerStatus) + app.Post("/api/reboot", srv.controllerReboot) // Start messaging hub go srv.hub.Run() @@ -90,12 +101,16 @@ func (srv *SEBRAUCServer) controllerUpdate(c *fiber.Ctx) error { if err != nil { return err } - err = c.SaveFile(file, "./update.raucb") + + srv.currentId++ + updateFile := fmt.Sprintf("%s/update_%d.raucb", srv.tmpdir, srv.currentId) + + err = c.SaveFile(file, updateFile) if err != nil { return err } - err = srv.raucUpdater.RunRauc("./update.raucb") + err = srv.raucUpdater.RunRauc(updateFile) if err == nil { writeStatus(c, true, "Update started") } else if errors.Is(err, util.ErrAlreadyRunning) { @@ -112,15 +127,10 @@ func (srv *SEBRAUCServer) controllerStatus(c *fiber.Ctx) error { return nil } -func (srv *SEBRAUCServer) controllerTest(c *fiber.Ctx) error { - err := srv.raucUpdater.RunRauc("./update.raucb") - if err == nil { - writeStatus(c, true, "Update started") - } else if errors.Is(err, util.ErrAlreadyRunning) { - return fiber.NewError(fiber.StatusConflict, "already running") - } else { - return err - } +func (srv *SEBRAUCServer) controllerReboot(c *fiber.Ctx) error { + go util.Reboot(5 * time.Second) + + writeStatus(c, true, "System is rebooting") return nil } @@ -128,7 +138,6 @@ func errorHandler(c *fiber.Ctx, err error) error { // API error handling if strings.HasPrefix(c.Path(), "/api") { writeStatus(c, false, err.Error()) - return nil } return err } diff --git a/src/util/util.go b/src/util/util.go index 486b648..147ca57 100644 --- a/src/util/util.go +++ b/src/util/util.go @@ -1,8 +1,70 @@ package util -import "os" +import ( + "crypto/rand" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const tmpdirPrefix = "sebrauc" func DoesFileExist(filepath string) bool { _, err := os.Stat(filepath) return !os.IsNotExist(err) } + +func CreateDirIfNotExists(dirpath string) error { + if _, err := os.Stat(dirpath); os.IsNotExist(err) { + createErr := os.MkdirAll(dirpath, 0o777) + if createErr != nil { + return createErr + } + } + return nil +} + +func NewTmpdir() (tmpdir string, err error) { + for { + bts := make([]byte, 16) + _, err = rand.Read(bts) + if err != nil { + return "", err + } + + tmpdir = filepath.Join(os.TempDir(), fmt.Sprintf("%s_%x", tmpdirPrefix, bts)) + + if !DoesFileExist(tmpdir) { + break + } + } + + err = CreateDirIfNotExists(tmpdir) + return +} + +func PurgeTmpdirs() (count int) { + dirs, _ := os.ReadDir(os.TempDir()) + + for _, de := range dirs { + if !de.IsDir() { + continue + } + if strings.HasPrefix(de.Name(), tmpdirPrefix+"_") { + err := os.RemoveAll(filepath.Join(os.TempDir(), de.Name())) + if err == nil { + count++ + } + } + } + return +} + +func Reboot(t time.Duration) { + time.Sleep(t) + cmd := exec.Command("shutdown", "-r", "0") + _ = cmd.Run() +} diff --git a/src/util/util_test.go b/src/util/util_test.go index 30b2ab0..c616e40 100644 --- a/src/util/util_test.go +++ b/src/util/util_test.go @@ -1,9 +1,12 @@ package util import ( + "os" + "path/filepath" "testing" "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures" + "github.com/stretchr/testify/assert" ) func TestDoesFileExist(t *testing.T) { @@ -36,3 +39,30 @@ func TestDoesFileExist(t *testing.T) { }) } } + +func TestTmpdir(t *testing.T) { + td, err := NewTmpdir() + if err != nil { + panic(err) + } + + tfile := filepath.Join(td, "test.txt") + f, err := os.Create(tfile) + if err != nil { + panic(err) + } + + _, err = f.WriteString("Hello") + if err != nil { + panic(err) + } + err = f.Close() + if err != nil { + panic(err) + } + + assert.FileExists(t, tfile) + + assert.Equal(t, 1, PurgeTmpdirs()) + assert.NoFileExists(t, tfile) +} diff --git a/ui/.env.development b/ui/.env.development new file mode 100644 index 0000000..029df76 --- /dev/null +++ b/ui/.env.development @@ -0,0 +1 @@ +VITE_API_HOST=127.0.0.1:8080 diff --git a/ui/package.json b/ui/package.json index 3db08ef..ad6ef6c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,6 +7,8 @@ "serve": "vite preview" }, "dependencies": { + "@mdi/js": "^6.5.95", + "axios": "^0.24.0", "preact": "^10.5.15" }, "devDependencies": { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 05b5ef9..155f163 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -1,7 +1,9 @@ lockfileVersion: 5.3 specifiers: + "@mdi/js": ^6.5.95 "@preact/preset-vite": ^2.1.5 + axios: ^0.24.0 preact: ^10.5.15 prettier: ^2.4.1 sass: ^1.43.4 @@ -9,6 +11,8 @@ specifiers: vite: ^2.6.14 dependencies: + "@mdi/js": 6.5.95 + axios: 0.24.0 preact: 10.5.15 devDependencies: @@ -347,6 +351,13 @@ packages: to-fast-properties: 2.0.0 dev: true + /@mdi/js/6.5.95: + resolution: + { + integrity: sha512-x/bwEoAGP+Mo10Dfk5audNIPi7Yz8ZBrILcbXLW3ShOI/njpgodzpgpC2WYK3D2ZSC392peRRemIFb/JsyzzYQ==, + } + dev: false + /@preact/preset-vite/2.1.5_preact@10.5.15+vite@2.6.14: resolution: { @@ -445,6 +456,17 @@ packages: picomatch: 2.3.0 dev: true + /axios/0.24.0: + resolution: + { + integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==, + } + dependencies: + follow-redirects: 1.14.5 + transitivePeerDependencies: + - debug + dev: false + /babel-plugin-transform-hook-names/1.0.2: resolution: { @@ -812,6 +834,19 @@ packages: to-regex-range: 5.0.1 dev: true + /follow-redirects/1.14.5: + resolution: + { + integrity: sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==, + } + engines: {node: ">=4.0"} + peerDependencies: + debug: "*" + peerDependenciesMeta: + debug: + optional: true + dev: false + /fsevents/2.3.2: resolution: { diff --git a/ui/src/components/Dropzone/Dropzone.scss b/ui/src/components/Dropzone/Dropzone.scss new file mode 100644 index 0000000..9e705ae --- /dev/null +++ b/ui/src/components/Dropzone/Dropzone.scss @@ -0,0 +1,9 @@ +.dropzone { + &.highlight { + filter: brightness(0.5); + } + + .fileholder { + display: none; + } +} diff --git a/ui/src/components/Dropzone/Dropzone.tsx b/ui/src/components/Dropzone/Dropzone.tsx new file mode 100644 index 0000000..c977dd4 --- /dev/null +++ b/ui/src/components/Dropzone/Dropzone.tsx @@ -0,0 +1,119 @@ +import {Component, createRef, JSX, ComponentChild, ComponentChildren} from "preact" +import "./Dropzone.scss" + +type Props = { + disabled: boolean + clickable: boolean + multiple: boolean + accept?: string + onFilesAdded?: (files: File[]) => void + + children?: ComponentChild | ComponentChildren +} + +type State = { + highlight: boolean +} + +export default class Dropzone extends Component { + static defaultProps = { + disabled: false, + clickable: false, + multiple: false, + } + + private fileInputRef = createRef() + + openFileDialog() { + this.fileInputRef.current?.click() + } + + reset() { + const input = this.fileInputRef.current + if (input === null) return + input.value = "" + } + + private onClick = () => { + if (this.props.disabled || !this.props.clickable) return + this.openFileDialog() + } + + private onFilesAdded = (evt: JSX.TargetedEvent) => { + if (this.props.disabled || evt.target === null) return + + const input = evt.target as HTMLInputElement + const files = input.files + + if (this.props.onFilesAdded) { + const array = this.fileListToArray(files) + this.props.onFilesAdded(array) + } + } + + private onDragOver = (evt: DragEvent) => { + evt.preventDefault() + + if (this.props.disabled) return + + this.setState({highlight: true}) + } + + private onDragLeave = () => { + this.setState({highlight: false}) + } + + private onDrop = (evt: DragEvent) => { + evt.preventDefault() + this.setState({highlight: false}) + + if (this.props.disabled || evt.dataTransfer === null) return + + const files = evt.dataTransfer.files + + if (!this.props.multiple && files.length > 1) return + + if (this.props.onFilesAdded) { + const array = this.fileListToArray(files) + this.props.onFilesAdded(array) + } + } + + private fileListToArray(list: FileList | null): File[] { + const array: File[] = [] + if (list === null) return array + + for (var i = 0; i < list.length; i++) { + array.push(list.item(i)!) + } + return array + } + + render() { + return ( +
+ + {this.props.children} +
+ ) + } +} diff --git a/ui/src/components/Icon/Icon.scss b/ui/src/components/Icon/Icon.scss new file mode 100644 index 0000000..d4796e7 --- /dev/null +++ b/ui/src/components/Icon/Icon.scss @@ -0,0 +1,8 @@ +.icon { + vertical-align: sub; + + > svg { + color: inherit; + fill: currentColor; + } +} diff --git a/ui/src/components/Icon/Icon.tsx b/ui/src/components/Icon/Icon.tsx new file mode 100644 index 0000000..824f56c --- /dev/null +++ b/ui/src/components/Icon/Icon.tsx @@ -0,0 +1,29 @@ +import {Component} from "preact" +import "./Icon.scss" + +type Props = { + icon: string + size: number + color?: string +} + +export default class Icon extends Component { + static defaultProps = { + size: 24, + } + + render() { + return ( + + + + ) + } +} diff --git a/ui/src/components/LoadingText.tsx b/ui/src/components/LoadingText.tsx deleted file mode 100644 index 5e31b3a..0000000 --- a/ui/src/components/LoadingText.tsx +++ /dev/null @@ -1,7 +0,0 @@ -interface Props { - isLoading: boolean -} - -export default function LoadingText(props: Props) { - return
{props.isLoading ?

Loading...

:

Fertig geladen

}
-} diff --git a/ui/src/components/ProgressCircle/ProgressCircle.scss b/ui/src/components/ProgressCircle/ProgressCircle.scss new file mode 100644 index 0000000..4878cb9 --- /dev/null +++ b/ui/src/components/ProgressCircle/ProgressCircle.scss @@ -0,0 +1,42 @@ +.progress-box { + width: 250px; + height: 250px; + position: relative; + + svg { + width: 100%; + height: auto; + + .progress-path { + transition: stroke-dasharray 0.5s; + } + + circle { + transition: fill 0.5s; + } + } + + button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + display: flex; + justify-content: center; + align-items: center; + + height: 64px; + width: 64px; + + color: white; + background: transparent; + border: none; + cursor: pointer; + border-radius: 50%; + + &:hover { + background-color: #444; + } + } +} diff --git a/ui/src/components/ProgressCircle/ProgressCircle.tsx b/ui/src/components/ProgressCircle/ProgressCircle.tsx new file mode 100644 index 0000000..507dcd5 --- /dev/null +++ b/ui/src/components/ProgressCircle/ProgressCircle.tsx @@ -0,0 +1,60 @@ +import {Component, ComponentChild, ComponentChildren} from "preact" +import "./ProgressCircle.scss" + +type Props = { + ready: boolean + progress: number + color: string + + children?: ComponentChild | ComponentChildren +} + +export default class ProgressCircle extends Component { + static defaultProps = { + ready: false, + progress: 0, + color: "#FDB900", + } + + render() { + const percentage = this.props.ready ? 0 : this.props.progress + const visible = !this.props.ready && this.props.progress > 0 + + return ( +
+ + + + {visible ? ( + + ) : null} + + {visible ? ( + + {percentage}% + + ) : null} + + {visible ? null : this.props.children} +
+ ) + } +} diff --git a/ui/src/components/Upload/Alert.tsx b/ui/src/components/Upload/Alert.tsx new file mode 100644 index 0000000..2d6f9dd --- /dev/null +++ b/ui/src/components/Upload/Alert.tsx @@ -0,0 +1,44 @@ +import {Component} from "preact" +import {mdiCheckCircleOutline, mdiTriangleOutline} from "@mdi/js" +import Icon from "../Icon/Icon" + +type Props = { + type: string + source?: string + message: string + onClick?: () => void +} + +export default class Alert extends Component { + static defaultProps = { + type: "error", + } + + private stripMessage(message: string): string { + return message.replace(/^error:/i, "").trim() + } + + render() { + let msg = "" + if (this.props.source !== undefined) msg += `${this.props.source} error: ` + msg += this.stripMessage(this.props.message) + + return ( +

+ {(() => { + switch (this.props.type) { + case "success": + return + default: + return + } + })()} + + {msg} +

+ ) + } +} diff --git a/ui/src/components/Upload/Upload.scss b/ui/src/components/Upload/Upload.scss new file mode 100644 index 0000000..c17e8ef --- /dev/null +++ b/ui/src/components/Upload/Upload.scss @@ -0,0 +1,47 @@ +.uploader { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + margin: 0 auto; + max-width: 500px; + width: 90%; + + > * { + width: 100%; + } + + .card { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + padding: 15px 8px; + + text-align: center; + + border: 3px solid #fff; + border-radius: 16px; + + .top { + font-size: 1.5em; + } + } + + .alert { + margin: 0.6em 0; + // border: 2px solid transparent; + border-radius: 16px; + + .icon { + margin-right: 0.4em; + } + + &.clickable:hover { + cursor: pointer; + background: #ffffff66; + } + } +} diff --git a/ui/src/components/Upload/Upload.tsx b/ui/src/components/Upload/Upload.tsx new file mode 100644 index 0000000..d23387b --- /dev/null +++ b/ui/src/components/Upload/Upload.tsx @@ -0,0 +1,267 @@ +import {Component, createRef} from "preact" +import {mdiUpload} from "@mdi/js" +import Dropzone from "../Dropzone/Dropzone" +import ProgressCircle from "../ProgressCircle/ProgressCircle" +import Icon from "../Icon/Icon" +import "./Upload.scss" +import axios from "axios" +import Alert from "./Alert" + +class UploadStatus { + uploading = false + total = 0 + loaded = 0 + lastError = "" + + constructor(uploading: boolean, total = 0, loaded = 0, lastError = "") { + this.uploading = uploading + this.total = total + this.loaded = loaded + this.lastError = lastError + } + + static fromProgressEvent(progressEvent: { + loaded: number + total: number + }): UploadStatus { + return new UploadStatus(true, progressEvent.total, progressEvent.loaded) + } +} + +class RaucStatus { + installing = false + percent = 0 + message = "" + last_error = "" + log = "" +} + +type Props = {} + +type State = { + uploadStatus: UploadStatus + uploadFilename: string + raucStatus: RaucStatus + wsConnected: boolean +} + +export default class Upload extends Component { + private apiUrl: string + private wsUrl: string + + private dropzoneRef = createRef() + private conn: WebSocket | undefined + + constructor(props?: Props | undefined, context?: any) { + super(props, context) + + // Get API urls + let apiHost = document.location.host + const httpProto = document.location.protocol + const wsProto = httpProto === "https:" ? "wss:" : "ws:" + + if (import.meta.env.VITE_API_HOST !== undefined) { + apiHost = import.meta.env.VITE_API_HOST as string + } + + this.apiUrl = `${httpProto}//${apiHost}/api` + this.wsUrl = `${wsProto}//${apiHost}/api/ws` + + this.state = { + uploadStatus: new UploadStatus(false), + uploadFilename: "", + raucStatus: new RaucStatus(), + wsConnected: false, + } + + this.connectWebsocket() + } + + private buttonClick = () => { + if (!this.acceptUploads()) return + + this.dropzoneRef.current?.openFileDialog() + } + + private onFilesAdded = (files: File[]) => { + if (files.length === 0) return + const newFile = files[0] + + const formData = new FormData() + formData.append("updateFile", newFile) + + this.setState({ + uploadStatus: new UploadStatus(true, newFile.size, 0), + uploadFilename: newFile.name, + }) + + axios + .post(this.apiUrl + "/update", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: (progressEvent: {loaded: number; total: number}) => { + this.setState({ + uploadStatus: UploadStatus.fromProgressEvent(progressEvent), + }) + }, + }) + .then(() => { + this.resetUpload() + }) + .catch((reason: any) => { + this.resetUpload(String(reason)) + }) + } + + private resetUpload = (lastError = "") => { + this.setState({ + uploadStatus: new UploadStatus(false, 0, 0, lastError), + }) + this.dropzoneRef.current?.reset() + } + + private connectWebsocket = () => { + if (window.WebSocket) { + this.conn = new WebSocket(this.wsUrl) + this.conn.onopen = () => { + this.setState({wsConnected: true}) + console.log("WS connected") + } + this.conn.onclose = () => { + this.setState({wsConnected: false}) + console.log("WS connection closed") + window.setTimeout(this.connectWebsocket, 3000) + } + this.conn.onmessage = (evt) => { + var messages = evt.data.split("\n") + for (var i = 0; i < messages.length; i++) { + this.setState({ + raucStatus: Object.assign( + new RaucStatus(), + JSON.parse(messages[i]) + ), + }) + + console.log(this.state.raucStatus) + } + } + } else { + console.log("Your browser does not support WebSockets") + } + } + + private triggerReboot = () => { + const res = confirm("Reboot the system?") + if (!res) return + + axios + .post(this.apiUrl + "/reboot") + .then(() => { + alert("System is rebooting") + }) + .catch((reason: any) => { + alert(String(reason)) + }) + } + + private acceptUploads(): boolean { + return !this.state.uploadStatus.uploading && !this.state.raucStatus.installing + } + + private uploadPercentage(): number { + if (this.state.uploadStatus.uploading && this.state.uploadStatus.total > 0) { + return Math.round( + (this.state.uploadStatus.loaded / this.state.uploadStatus.total) * 100 + ) + } + return 0 + } + + private circleColor(): string { + if (this.state.raucStatus.installing) return "#FF0039" + if (this.state.uploadStatus.uploading) return "#148420" + return "#1f85de" + } + + private circlePercentage(): number { + if (this.acceptUploads()) return 0 + if (this.state.raucStatus.installing) return this.state.raucStatus.percent + if (this.state.uploadStatus.uploading) return this.uploadPercentage() + return 0 + } + + render() { + const acceptUploads = this.acceptUploads() + const circleColor = this.circleColor() + const circlePercentage = this.circlePercentage() + + let topText = "" + let bottomText = "" + + if (this.state.uploadStatus.uploading) { + topText = "Uploading" + bottomText = `${this.state.uploadFilename} ${this.state.uploadStatus.loaded} / ${this.state.uploadStatus.total} bytes` + } else if (this.state.raucStatus.installing) { + topText = "Upadating firmware" + bottomText = this.state.raucStatus.message + } else { + topText = "Upload firmware package" + } + + return ( +
+
+
+ {topText} +
+ + + + + +
+ {bottomText} +
+
+
+ {this.state.wsConnected ? null : } + + {!this.state.raucStatus.installing && + this.state.raucStatus.percent === 100 && + this.state.raucStatus.last_error === "" ? ( + + ) : null} + + {this.state.uploadStatus.lastError ? ( + + ) : null} + {this.state.raucStatus.last_error ? ( + + ) : null} +
+
+ ) + } +} diff --git a/ui/src/components/app.tsx b/ui/src/components/app.tsx index bb17383..1ab3565 100644 --- a/ui/src/components/app.tsx +++ b/ui/src/components/app.tsx @@ -1,53 +1,12 @@ -import LoadingText from "./LoadingText" -import {useState} from "preact/hooks" +import {Component} from "preact" +import Upload from "./Upload/Upload" -type ImageDataT = { - id: string - author: string - width: number - height: number - url: string - download_url: string -} - -type ImageProps = { - image?: ImageDataT -} - -function Image(props: ImageProps) { - if (props.image === undefined) { +export default class App extends Component { + render() { return ( -
- +
+
) } - return ( -
- dog -

Fotograf: {props.image.author}

-
- ) -} - -export default function App() { - const [imageData, setImageData] = useState(undefined) - - const fetchImageData = async () => { - const response = await fetch("https://picsum.photos/id/237/info") - const data = await response.json() - - setImageData(data) - - console.log({data}) - } - - return ( -
-

React Tutorial

-

Time now: {new Date().toISOString()}

- - -
- ) } diff --git a/ui/src/components/logo.tsx b/ui/src/components/logo.tsx deleted file mode 100644 index d7cee17..0000000 --- a/ui/src/components/logo.tsx +++ /dev/null @@ -1,47 +0,0 @@ -export const Logo = () => ( - -) diff --git a/ui/src/preact.d.ts b/ui/src/preact.d.ts index edeaac5..ac79d62 100644 --- a/ui/src/preact.d.ts +++ b/ui/src/preact.d.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import JSX = preact.JSX diff --git a/ui/src/style/index.scss b/ui/src/style/index.scss index 6b25e27..426dadd 100644 --- a/ui/src/style/index.scss +++ b/ui/src/style/index.scss @@ -12,19 +12,11 @@ body { -moz-osx-font-smoothing: grayscale; } -* { - box-sizing: border-box; -} - #app { height: 100%; - text-align: center; - background-color: #673ab8; - color: #fff; - font-size: 1.5em; padding-top: 100px; - .link { - color: #fff; - } + background-color: #673ab8; + color: #fff; + font-size: 1.2em; }