Compare commits
3 commits
75a3d67402
...
51eb9c0cac
Author | SHA1 | Date | |
---|---|---|---|
51eb9c0cac | |||
191d74076e | |||
2f893e458c |
28 changed files with 831 additions and 146 deletions
|
@ -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/...
|
||||
|
|
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
@ -2,7 +2,7 @@
|
|||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch SEBRAUC server",
|
||||
"name": "SEBRAUC server",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
|
|
1
go.mod
1
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
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
16
openapi.yml
16
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:
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,15 +37,21 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,6 +60,7 @@ func (srv *SEBRAUCServer) Run() error {
|
|||
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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
1
ui/.env.development
Normal file
1
ui/.env.development
Normal file
|
@ -0,0 +1 @@
|
|||
VITE_API_HOST=127.0.0.1:8080
|
|
@ -7,6 +7,8 @@
|
|||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^6.5.95",
|
||||
"axios": "^0.24.0",
|
||||
"preact": "^10.5.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -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:
|
||||
{
|
||||
|
|
9
ui/src/components/Dropzone/Dropzone.scss
Normal file
9
ui/src/components/Dropzone/Dropzone.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
.dropzone {
|
||||
&.highlight {
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
|
||||
.fileholder {
|
||||
display: none;
|
||||
}
|
||||
}
|
119
ui/src/components/Dropzone/Dropzone.tsx
Normal file
119
ui/src/components/Dropzone/Dropzone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
8
ui/src/components/Icon/Icon.scss
Normal file
8
ui/src/components/Icon/Icon.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
.icon {
|
||||
vertical-align: sub;
|
||||
|
||||
> svg {
|
||||
color: inherit;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
29
ui/src/components/Icon/Icon.tsx
Normal file
29
ui/src/components/Icon/Icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
}
|
42
ui/src/components/ProgressCircle/ProgressCircle.scss
Normal file
42
ui/src/components/ProgressCircle/ProgressCircle.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
60
ui/src/components/ProgressCircle/ProgressCircle.tsx
Normal file
60
ui/src/components/ProgressCircle/ProgressCircle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
44
ui/src/components/Upload/Alert.tsx
Normal file
44
ui/src/components/Upload/Alert.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
47
ui/src/components/Upload/Upload.scss
Normal file
47
ui/src/components/Upload/Upload.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
267
ui/src/components/Upload/Upload.tsx
Normal file
267
ui/src/components/Upload/Upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
<div class="Image">
|
||||
<LoadingText isLoading={true} />
|
||||
<div>
|
||||
<Upload />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
1
ui/src/preact.d.ts
vendored
|
@ -1,2 +1 @@
|
|||
// eslint-disable-next-line
|
||||
import JSX = preact.JSX
|
||||
|
|
|
@ -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 {
|
||||
background-color: #673ab8;
|
||||
color: #fff;
|
||||
}
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue