Compare commits

...

3 commits

Author SHA1 Message Date
51eb9c0cac finished uploader component
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-21 01:31:38 +01:00
191d74076e Fix api urls for prod 2021-11-20 20:35:02 +01:00
2f893e458c Add uploader component 2021-11-20 20:32:29 +01:00
28 changed files with 831 additions and 146 deletions

View file

@ -14,5 +14,5 @@ steps:
image: golangci/golangci-lint:latest image: golangci/golangci-lint:latest
commands: commands:
- go mod download - go mod download
- golangci-lint run -v --timeout 2m - golangci-lint run --timeout 5m
- go test -v ./src/... - go test -v ./src/...

2
.vscode/launch.json vendored
View file

@ -2,7 +2,7 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Launch SEBRAUC server", "name": "SEBRAUC server",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",

1
go.mod
View file

@ -3,7 +3,6 @@ module code.thetadev.de/TSGRain/SEBRAUC
go 1.16 go 1.16
require ( require (
code.thetadev.de/ThetaDev/gotry v0.3.2
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gofiber/fiber/v2 v2.21.0 github.com/gofiber/fiber/v2 v2.21.0
github.com/gofiber/websocket/v2 v2.0.12 github.com/gofiber/websocket/v2 v2.0.12

2
go.sum
View file

@ -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 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= 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= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -45,6 +45,22 @@ paths:
schema: schema:
$ref: "#/components/schemas/StatusMessage" $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: components:
schemas: schemas:
RaucStatus: RaucStatus:

View file

@ -3,8 +3,6 @@ package fixtures
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"code.thetadev.de/ThetaDev/gotry/try"
) )
func doesFileExist(filepath string) bool { func doesFileExist(filepath string) bool {
@ -13,7 +11,10 @@ func doesFileExist(filepath string) bool {
} }
func getProjectRoot() string { func getProjectRoot() string {
p := try.String(os.Getwd()) p, err := os.Getwd()
if err != nil {
panic(err)
}
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
if doesFileExist(filepath.Join(p, "go.mod")) { if doesFileExist(filepath.Join(p, "go.mod")) {
@ -27,7 +28,10 @@ func getProjectRoot() string {
func CdProjectRoot() { func CdProjectRoot() {
root := getProjectRoot() root := getProjectRoot()
try.Check(os.Chdir(root)) err := os.Chdir(root)
if err != nil {
panic(err)
}
} }
func GetTestfilesDir() string { func GetTestfilesDir() string {

View file

@ -5,7 +5,6 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"code.thetadev.de/ThetaDev/gotry/try"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -17,7 +16,10 @@ func TestGetProjectRoot(t *testing.T) {
t.Run("subdir", func(t *testing.T) { t.Run("subdir", func(t *testing.T) {
root1 := getProjectRoot() 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() root := getProjectRoot()
assert.True(t, doesFileExist(filepath.Join(root, "go.sum"))) assert.True(t, doesFileExist(filepath.Join(root, "go.sum")))
@ -26,7 +28,10 @@ func TestGetProjectRoot(t *testing.T) {
func TestCdProjectRoot(t *testing.T) { func TestCdProjectRoot(t *testing.T) {
CdProjectRoot() CdProjectRoot()
try.Check(os.Chdir("src/rauc")) err := os.Chdir("src/rauc")
if err != nil {
panic(err)
}
CdProjectRoot() CdProjectRoot()
assert.True(t, doesFileExist("go.sum")) assert.True(t, doesFileExist("go.sum"))
} }

View file

@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"regexp" "regexp"
"strconv" "strconv"
@ -33,9 +34,11 @@ type RaucStatus struct {
Log string `json:"log"` Log string `json:"log"`
} }
func (r *Rauc) completed() { func (r *Rauc) completed(updateFile string) {
r.status.Installing = false r.status.Installing = false
r.Broadcast <- r.GetStatusJson() r.Broadcast <- r.GetStatusJson()
_ = os.Remove(updateFile)
} }
func (r *Rauc) RunRauc(updateFile string) error { func (r *Rauc) RunRauc(updateFile string) error {
@ -98,7 +101,7 @@ func (r *Rauc) RunRauc(updateFile string) error {
err := cmd.Start() err := cmd.Start()
if err != nil { if err != nil {
r.completed() r.completed(updateFile)
return err return err
} }
@ -107,7 +110,7 @@ func (r *Rauc) RunRauc(updateFile string) error {
if err != nil { if err != nil {
fmt.Printf("RAUC failed with %s\n", err) fmt.Printf("RAUC failed with %s\n", err)
} }
r.completed() r.completed(updateFile)
}() }()
return nil return nil

View file

@ -2,8 +2,10 @@ package server
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"code.thetadev.de/TSGRain/SEBRAUC/src/rauc" "code.thetadev.de/TSGRain/SEBRAUC/src/rauc"
"code.thetadev.de/TSGRain/SEBRAUC/src/util" "code.thetadev.de/TSGRain/SEBRAUC/src/util"
@ -19,6 +21,8 @@ type SEBRAUCServer struct {
address string address string
raucUpdater *rauc.Rauc raucUpdater *rauc.Rauc
hub *MessageHub hub *MessageHub
tmpdir string
currentId int
} }
type statusMessage struct { type statusMessage struct {
@ -33,15 +37,21 @@ func NewServer(address string) *SEBRAUCServer {
Command: "go", Command: "go",
Args: []string{ Args: []string{
"run", "run",
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock", "fail", "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock",
}, },
Broadcast: hub.Broadcast, Broadcast: hub.Broadcast,
} }
tmpdir, err := util.NewTmpdir()
if err != nil {
panic(err)
}
return &SEBRAUCServer{ return &SEBRAUCServer{
address: address, address: address,
raucUpdater: raucUpdater, raucUpdater: raucUpdater,
hub: hub, hub: hub,
tmpdir: tmpdir,
} }
} }
@ -50,6 +60,7 @@ func (srv *SEBRAUCServer) Run() error {
AppName: "SEBRAUC", AppName: "SEBRAUC",
BodyLimit: 1024 * 1024 * 1024, BodyLimit: 1024 * 1024 * 1024,
ErrorHandler: errorHandler, ErrorHandler: errorHandler,
DisableStartupMessage: true,
}) })
app.Use(logger.New()) app.Use(logger.New())
@ -75,9 +86,9 @@ func (srv *SEBRAUCServer) Run() error {
// ROUTES // ROUTES
app.Get("/api/ws", websocket.New(srv.hub.Handler)) app.Get("/api/ws", websocket.New(srv.hub.Handler))
app.Get("/api/test", srv.controllerTest)
app.Post("/api/update", srv.controllerUpdate) app.Post("/api/update", srv.controllerUpdate)
app.Get("/api/status", srv.controllerStatus) app.Get("/api/status", srv.controllerStatus)
app.Post("/api/reboot", srv.controllerReboot)
// Start messaging hub // Start messaging hub
go srv.hub.Run() go srv.hub.Run()
@ -90,12 +101,16 @@ func (srv *SEBRAUCServer) controllerUpdate(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err 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 { if err != nil {
return err return err
} }
err = srv.raucUpdater.RunRauc("./update.raucb") err = srv.raucUpdater.RunRauc(updateFile)
if err == nil { if err == nil {
writeStatus(c, true, "Update started") writeStatus(c, true, "Update started")
} else if errors.Is(err, util.ErrAlreadyRunning) { } else if errors.Is(err, util.ErrAlreadyRunning) {
@ -112,15 +127,10 @@ func (srv *SEBRAUCServer) controllerStatus(c *fiber.Ctx) error {
return nil return nil
} }
func (srv *SEBRAUCServer) controllerTest(c *fiber.Ctx) error { func (srv *SEBRAUCServer) controllerReboot(c *fiber.Ctx) error {
err := srv.raucUpdater.RunRauc("./update.raucb") go util.Reboot(5 * time.Second)
if err == nil {
writeStatus(c, true, "Update started") writeStatus(c, true, "System is rebooting")
} else if errors.Is(err, util.ErrAlreadyRunning) {
return fiber.NewError(fiber.StatusConflict, "already running")
} else {
return err
}
return nil return nil
} }
@ -128,7 +138,6 @@ func errorHandler(c *fiber.Ctx, err error) error {
// API error handling // API error handling
if strings.HasPrefix(c.Path(), "/api") { if strings.HasPrefix(c.Path(), "/api") {
writeStatus(c, false, err.Error()) writeStatus(c, false, err.Error())
return nil
} }
return err return err
} }

View file

@ -1,8 +1,70 @@
package util package util
import "os" import (
"crypto/rand"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const tmpdirPrefix = "sebrauc"
func DoesFileExist(filepath string) bool { func DoesFileExist(filepath string) bool {
_, err := os.Stat(filepath) _, err := os.Stat(filepath)
return !os.IsNotExist(err) 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()
}

View file

@ -1,9 +1,12 @@
package util package util
import ( import (
"os"
"path/filepath"
"testing" "testing"
"code.thetadev.de/TSGRain/SEBRAUC/src/fixtures" "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures"
"github.com/stretchr/testify/assert"
) )
func TestDoesFileExist(t *testing.T) { 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)
}

1
ui/.env.development Normal file
View file

@ -0,0 +1 @@
VITE_API_HOST=127.0.0.1:8080

View file

@ -7,6 +7,8 @@
"serve": "vite preview" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^6.5.95",
"axios": "^0.24.0",
"preact": "^10.5.15" "preact": "^10.5.15"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,7 +1,9 @@
lockfileVersion: 5.3 lockfileVersion: 5.3
specifiers: specifiers:
"@mdi/js": ^6.5.95
"@preact/preset-vite": ^2.1.5 "@preact/preset-vite": ^2.1.5
axios: ^0.24.0
preact: ^10.5.15 preact: ^10.5.15
prettier: ^2.4.1 prettier: ^2.4.1
sass: ^1.43.4 sass: ^1.43.4
@ -9,6 +11,8 @@ specifiers:
vite: ^2.6.14 vite: ^2.6.14
dependencies: dependencies:
"@mdi/js": 6.5.95
axios: 0.24.0
preact: 10.5.15 preact: 10.5.15
devDependencies: devDependencies:
@ -347,6 +351,13 @@ packages:
to-fast-properties: 2.0.0 to-fast-properties: 2.0.0
dev: true 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: /@preact/preset-vite/2.1.5_preact@10.5.15+vite@2.6.14:
resolution: resolution:
{ {
@ -445,6 +456,17 @@ packages:
picomatch: 2.3.0 picomatch: 2.3.0
dev: true 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: /babel-plugin-transform-hook-names/1.0.2:
resolution: resolution:
{ {
@ -812,6 +834,19 @@ packages:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
dev: true 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: /fsevents/2.3.2:
resolution: resolution:
{ {

View file

@ -0,0 +1,9 @@
.dropzone {
&.highlight {
filter: brightness(0.5);
}
.fileholder {
display: none;
}
}

View file

@ -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<Props, State> {
static defaultProps = {
disabled: false,
clickable: false,
multiple: false,
}
private fileInputRef = createRef<HTMLInputElement>()
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 (
<div
class={`dropzone ${this.state.highlight ? "highlight" : ""}`}
onDragOver={this.onDragOver}
onDragLeave={this.onDragLeave}
onDrop={this.onDrop}
onClick={this.onClick}
style={{
cursor:
!this.props.disabled && this.props.clickable
? "pointer"
: "default",
}}
>
<input
ref={this.fileInputRef}
class="fileholder"
type="file"
multiple={this.props.multiple}
accept={this.props.accept}
onInput={this.onFilesAdded}
/>
{this.props.children}
</div>
)
}
}

View file

@ -0,0 +1,8 @@
.icon {
vertical-align: sub;
> svg {
color: inherit;
fill: currentColor;
}
}

View file

@ -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<Props> {
static defaultProps = {
size: 24,
}
render() {
return (
<span class="icon" style={{color: this.props.color}}>
<svg
xmlns="http://www.w3.org/2000/svg"
style={`height: ${this.props.size}px; width: ${this.props.size}px;`}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d={this.props.icon} />
</svg>
</span>
)
}
}

View file

@ -1,7 +0,0 @@
interface Props {
isLoading: boolean
}
export default function LoadingText(props: Props) {
return <div>{props.isLoading ? <p>Loading...</p> : <h2>Fertig geladen</h2>}</div>
}

View file

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

View file

@ -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<Props> {
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 (
<div class="progress-box">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill={this.props.color} />
{visible ? (
<path
class="progress-path"
stroke-linecap="round"
stroke-width="5"
stroke="#fff"
fill="none"
d="M50 10
a 40 40 0 0 1 0 80
a 40 40 0 0 1 0 -80"
stroke-dasharray={`${percentage * 2.512}, 251.2`}
/>
) : null}
{visible ? (
<text
id="count"
x="50"
y="50"
text-anchor="middle"
dy="7"
font-size="20"
fill="#fff"
>
{percentage}%
</text>
) : null}
</svg>
{visible ? null : this.props.children}
</div>
)
}
}

View file

@ -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<Props> {
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 (
<p
class={`alert ${this.props.onClick !== undefined ? "clickable" : ""}`}
onClick={this.props.onClick}
>
{(() => {
switch (this.props.type) {
case "success":
return <Icon icon={mdiCheckCircleOutline} color="#148420" />
default:
return <Icon icon={mdiTriangleOutline} color="#FF0039" />
}
})()}
{msg}
</p>
)
}
}

View file

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

View file

@ -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<Props, State> {
private apiUrl: string
private wsUrl: string
private dropzoneRef = createRef<Dropzone>()
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 (
<div class="uploader">
<div class="card">
<div>
<span class="top">{topText}</span>
</div>
<Dropzone
ref={this.dropzoneRef}
onFilesAdded={this.onFilesAdded}
disabled={!acceptUploads}
accept=".raucb"
>
<ProgressCircle
ready={acceptUploads}
progress={circlePercentage}
color={circleColor}
>
<button onClick={this.buttonClick}>
<Icon icon={mdiUpload} size={50} />
</button>
</ProgressCircle>
</Dropzone>
<div>
<span>{bottomText}</span>
</div>
</div>
<div>
{this.state.wsConnected ? null : <Alert message="No connection" />}
{!this.state.raucStatus.installing &&
this.state.raucStatus.percent === 100 &&
this.state.raucStatus.last_error === "" ? (
<Alert
type="success"
message="Update completed. Click to reboot."
onClick={this.triggerReboot}
/>
) : null}
{this.state.uploadStatus.lastError ? (
<Alert
source="Upload"
message={this.state.uploadStatus.lastError}
/>
) : null}
{this.state.raucStatus.last_error ? (
<Alert
source="Update"
message={this.state.raucStatus.last_error}
/>
) : null}
</div>
</div>
)
}
}

View file

@ -1,53 +1,12 @@
import LoadingText from "./LoadingText" import {Component} from "preact"
import {useState} from "preact/hooks" import Upload from "./Upload/Upload"
type ImageDataT = { export default class App extends Component {
id: string render() {
author: string
width: number
height: number
url: string
download_url: string
}
type ImageProps = {
image?: ImageDataT
}
function Image(props: ImageProps) {
if (props.image === undefined) {
return ( return (
<div class="Image"> <div>
<LoadingText isLoading={true} /> <Upload />
</div> </div>
) )
} }
return (
<div class="Image">
<img alt="dog" src={props.image.download_url} width={350} />
<p>Fotograf: {props.image.author}</p>
</div>
)
}
export default function App() {
const [imageData, setImageData] = useState<ImageDataT | undefined>(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 (
<div class="App">
<h1>React Tutorial</h1>
<h2>Time now: {new Date().toISOString()}</h2>
<Image image={imageData} />
<button onClick={fetchImageData}>Hier klicken</button>
</div>
)
} }

View file

@ -1,47 +0,0 @@
export const Logo = () => (
<svg
class="logo"
height="180px"
viewBox="-256 -256 1800 512"
title="Preact"
style="
display: inline-block;
margin: -0.25em 0px 0px;
vertical-align: middle;
"
>
<path
d="M0,-256 221.7025033688164,-128 221.7025033688164,128 0,256 -221.7025033688164,128 -221.7025033688164,-128z"
fill="white"
/>
<ellipse
cx="0"
cy="0"
rx="75px"
ry="196px"
stroke-width="16px"
stroke-dasharray="365.2604060943886 81.7395939056114"
stroke-dashoffset="1131.9689975248618"
fill="none"
stroke="#673ab8"
transform="rotate(52)"
/>
<ellipse
cx="0"
cy="0"
rx="75px"
ry="196px"
stroke-width="16px"
stroke-dasharray="403.1225208830963 43.87747911690373"
stroke-dashoffset="-963.0079812482553"
fill="none"
stroke="#673ab8"
transform="rotate(-52)"
/>
<circle cx="0" cy="0" r="34" fill="#673ab8" />
<path
fill="white"
d="M289.85 25.25L289.85 125L272 125L272-122.63L335.88-122.63Q379.45-122.63 401.59-103.55Q423.73-84.48 423.73-49.13Q423.73-32.85 417.69-19.20Q411.65-5.55 400.27 4.34Q388.90 14.22 372.63 19.74Q356.35 25.25 335.88 25.25L289.85 25.25M289.85 10.90L335.88 10.90Q352.33 10.90 365.27 6.35Q378.23 1.80 387.24-6.25Q396.25-14.30 401.06-25.24Q405.88-36.18 405.88-49.13Q405.88-77.65 388.29-93.05Q370.70-108.45 335.88-108.45L289.85-108.45L289.85 10.90ZM497.58 13.00L497.58 125L479.73 125L479.73-122.63L542.90-122.63Q585.78-122.63 606.95-106.09Q628.13-89.55 628.13-57.53Q628.13-43.35 623.23-31.63Q618.33-19.90 609.14-11.06Q599.95-2.23 587 3.46Q574.05 9.15 557.78 10.90Q561.98 13.52 565.30 17.90L650.53 125L634.95 125Q632.15 125 630.14 123.95Q628.13 122.90 626.20 120.45L546.93 20.00Q543.95 16.15 540.54 14.57Q537.13 13.00 529.95 13.00L497.58 13.00M497.58-0.30L540.63-0.30Q557.08-0.30 570.11-4.24Q583.15-8.18 592.16-15.53Q601.18-22.88 605.90-33.20Q610.63-43.53 610.63-56.48Q610.63-82.90 593.30-95.68Q575.98-108.45 542.90-108.45L497.58-108.45L497.58-0.30ZM843.73-122.63L843.73-107.75L713.35-107.75L713.35-7.65L821.85-7.65L821.85 6.87L713.35 6.87L713.35 110.13L843.73 110.13L843.73 125L695.33 125L695.33-122.63L843.73-122.63ZM1088.55 125L1074.73 125Q1072.28 125 1070.70 123.69Q1069.13 122.38 1068.25 120.28L1039.03 48.35L917.40 48.35L888.35 120.28Q887.65 122.20 885.90 123.60Q884.15 125 881.70 125L868.05 125L969.38-122.63L987.23-122.63L1088.55 125M922.83 35.05L1033.78 35.05L983.20-90.08Q981.98-93.05 980.75-96.81Q979.53-100.58 978.30-104.78Q977.08-100.58 975.85-96.81Q974.63-93.05 973.40-89.90L922.83 35.05ZM1302.40 83.35Q1304.15 83.35 1305.38 84.57L1312.38 92.10Q1304.67 100.33 1295.58 106.89Q1286.47 113.45 1275.71 118.09Q1264.95 122.72 1252.09 125.26Q1239.22 127.80 1223.83 127.80Q1198.10 127.80 1176.66 118.79Q1155.22 109.78 1139.91 93.24Q1124.60 76.70 1116.03 53.25Q1107.45 29.80 1107.45 1.10Q1107.45-27.08 1116.29-50.35Q1125.13-73.63 1141.14-90.34Q1157.15-107.05 1179.46-116.24Q1201.78-125.43 1228.72-125.43Q1242.20-125.43 1253.40-123.41Q1264.60-121.40 1274.31-117.64Q1284.03-113.88 1292.60-108.28Q1301.17-102.68 1309.40-95.33L1303.97-87.45Q1302.58-85.35 1299.60-85.35Q1298.03-85.35 1295.58-87.19Q1293.13-89.03 1289.36-91.74Q1285.60-94.45 1280.26-97.69Q1274.92-100.93 1267.58-103.64Q1260.22-106.35 1250.60-108.19Q1240.97-110.03 1228.72-110.03Q1206.15-110.03 1187.25-102.24Q1168.35-94.45 1154.70-80.01Q1141.05-65.58 1133.44-45.01Q1125.83-24.45 1125.83 1.10Q1125.83 27.35 1133.35 48.00Q1140.88 68.65 1154.17 82.91Q1167.47 97.17 1185.59 104.79Q1203.70 112.40 1224.88 112.40Q1238.17 112.40 1248.59 110.65Q1259 108.90 1267.75 105.40Q1276.50 101.90 1284.03 96.82Q1291.55 91.75 1298.90 84.92Q1299.78 84.22 1300.56 83.79Q1301.35 83.35 1302.40 83.35ZM1530.42-122.63L1530.42-107.40L1443.45-107.40L1443.45 125L1425.60 125L1425.60-107.40L1338.10-107.40L1338.10-122.63L1530.42-122.63Z"
/>
</svg>
)

1
ui/src/preact.d.ts vendored
View file

@ -1,2 +1 @@
// eslint-disable-next-line
import JSX = preact.JSX import JSX = preact.JSX

View file

@ -12,19 +12,11 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
* {
box-sizing: border-box;
}
#app { #app {
height: 100%; height: 100%;
text-align: center;
background-color: #673ab8;
color: #fff;
font-size: 1.5em;
padding-top: 100px; padding-top: 100px;
.link { background-color: #673ab8;
color: #fff; color: #fff;
} font-size: 1.2em;
} }