From 85c0073651ae74cfffc2e07a3ea55cacc7224bd6 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Fri, 3 Dec 2021 00:49:20 +0100 Subject: [PATCH 1/3] Add sysinfo api endpoint --- go.sum | 1 + openapi.yml | 4 +- src/fixtures/rauc_mock/main.go | 28 +++-- src/fixtures/testfiles/os-release | 5 + src/rauc/rauc.go | 2 +- src/server/server.go | 13 +++ src/sysinfo/sysinfo.go | 169 ++++++++++++++++++++++++++++++ src/sysinfo/sysinfo_test.go | 120 +++++++++++++++++++++ src/util/commands.go | 2 +- src/util/commands_mock.go | 2 +- 10 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 src/fixtures/testfiles/os-release create mode 100644 src/sysinfo/sysinfo.go create mode 100644 src/sysinfo/sysinfo_test.go diff --git a/go.sum b/go.sum index 6bcb5c4..4188720 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4 h1:ocK/D6lCgLji37Z2so4xhMl46se1ntReQQCUIU4BWI8= github.com/savsgio/gotils v0.0.0-20210921075833-21a6215cb0e4/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/openapi.yml b/openapi.yml index f55550d..55c362c 100644 --- a/openapi.yml +++ b/openapi.yml @@ -150,9 +150,9 @@ components: description: List of RAUC root filesystems type: object additionalProperties: - $ref: "#/components/schemas/RaucFS" + $ref: "#/components/schemas/Rootfs" - RaucFS: + Rootfs: type: object properties: device: diff --git a/src/fixtures/rauc_mock/main.go b/src/fixtures/rauc_mock/main.go index 4c9b884..b87aadb 100644 --- a/src/fixtures/rauc_mock/main.go +++ b/src/fixtures/rauc_mock/main.go @@ -41,22 +41,34 @@ LastError: Failed to check bundle identifier: Invalid identifier. ` + idle Installing ` + "/app/demo` failed" +const statusJson = `{"compatible":"TSGRain","variant":"","booted":"A",` + + `"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` + + `"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` + + `"parent":null,"mountpoint":null,"boot_status":"good"}},{"rootfs.0":` + + `{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` + + `"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}` + +func printLinesWithDelay(lines string) { + for _, line := range strings.Split(lines, "\n") { + fmt.Println(line) + time.Sleep(500 * time.Millisecond) + } +} + func main() { arg := "" if len(os.Args) > 1 { arg = os.Args[1] } - var lines string switch arg { case "fail": - lines = outputFailure + printLinesWithDelay(outputFailure) + case "install": + printLinesWithDelay(outputSuccess) + case "status": + fmt.Println(statusJson) default: - lines = outputSuccess - } - - for _, line := range strings.Split(lines, "\n") { - fmt.Println(line) - time.Sleep(500 * time.Millisecond) + os.Exit(1) } } diff --git a/src/fixtures/testfiles/os-release b/src/fixtures/testfiles/os-release new file mode 100644 index 0000000..84a41a1 --- /dev/null +++ b/src/fixtures/testfiles/os-release @@ -0,0 +1,5 @@ +ID=tsgrain +NAME="TSGRain distro" +VERSION="0.0.1" +VERSION_ID=0.0.1 +PRETTY_NAME="TSGRain distro 0.0.1" diff --git a/src/rauc/rauc.go b/src/rauc/rauc.go index 7585285..3e021fe 100644 --- a/src/rauc/rauc.go +++ b/src/rauc/rauc.go @@ -70,7 +70,7 @@ func (r *Rauc) RunRauc(updateFile string) error { } r.broadcast <- r.GetStatusJson() - cmd := util.CommandFromString(fmt.Sprintf("%s %s", util.UpdateCmd, updateFile)) + cmd := util.CommandFromString(fmt.Sprintf("%s install %s", util.RaucCmd, updateFile)) readPipe, _ := cmd.StdoutPipe() cmd.Stderr = cmd.Stdout diff --git a/src/server/server.go b/src/server/server.go index b9ca070..46522a3 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -8,6 +8,7 @@ import ( "time" "code.thetadev.de/TSGRain/SEBRAUC/src/rauc" + "code.thetadev.de/TSGRain/SEBRAUC/src/sysinfo" "code.thetadev.de/TSGRain/SEBRAUC/src/util" "code.thetadev.de/TSGRain/SEBRAUC/ui" "github.com/gofiber/fiber/v2" @@ -88,6 +89,7 @@ func (srv *SEBRAUCServer) Run() error { app.Get("/api/ws", websocket.New(srv.hub.Handler)) app.Post("/api/update", srv.controllerUpdate) app.Get("/api/status", srv.controllerStatus) + app.Get("/api/info", srv.controllerInfo) app.Post("/api/reboot", srv.controllerReboot) // Start messaging hub @@ -131,6 +133,17 @@ func (srv *SEBRAUCServer) controllerStatus(c *fiber.Ctx) error { return nil } +func (srv *SEBRAUCServer) controllerInfo(c *fiber.Ctx) error { + info, err := sysinfo.GetSysinfo() + if err != nil { + return err + } + + c.Context().SetStatusCode(200) + _ = c.JSON(info) + return nil +} + func (srv *SEBRAUCServer) controllerReboot(c *fiber.Ctx) error { go util.Reboot(5 * time.Second) diff --git a/src/sysinfo/sysinfo.go b/src/sysinfo/sysinfo.go new file mode 100644 index 0000000..dc1df3b --- /dev/null +++ b/src/sysinfo/sysinfo.go @@ -0,0 +1,169 @@ +package sysinfo + +import ( + "encoding/json" + "os" + "regexp" + "strconv" + + "code.thetadev.de/TSGRain/SEBRAUC/src/util" +) + +type SystemInfo struct { + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` + Uptime int `json:"uptime"` + RaucCompatible string `json:"rauc_compatible"` + RaucVariant string `json:"rauc_variant"` + RaucBooted string `json:"rauc_booted"` + RaucBootPrimary string `json:"rauc_boot_primary"` + RaucRootfs map[string]Rootfs `json:"rauc_rootfs"` +} + +type Rootfs struct { + Device string `json:"device"` + Type string `json:"type"` + bootname string + State string `json:"state"` + Mountpoint string `json:"mountpoint"` + Bootable bool `json:"bootable"` +} + +type raucInfo struct { + Compatible string `json:"compatible"` + Variant string `json:"variant"` + Booted string `json:"booted"` + BootPrimary string `json:"boot_primary"` + Slots []map[string]raucFS `json:"slots"` +} + +type raucFS struct { + Class string `json:"class"` + Device string `json:"device"` + Type string `json:"type"` + Bootname string `json:"bootname"` + State string `json:"state"` + Mountpoint string `json:"mountpoint"` + BootStatus string `json:"boot_status"` +} + +type osRelease struct { + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` +} + +var ( + rexpOsName = regexp.MustCompile(`(?m)^NAME="(.+)"`) + rexpOsVersion = regexp.MustCompile(`(?m)^VERSION="(.+)"`) + rexpUptime = regexp.MustCompile(`^\d+`) +) + +func parseRaucInfo(raucInfoJson []byte) (raucInfo, error) { + res := raucInfo{} + err := json.Unmarshal(raucInfoJson, &res) + return res, err +} + +func parseOsRelease(osReleaseFile string) (osRelease, error) { + osReleaseTxt, err := os.ReadFile(osReleaseFile) + if err != nil { + return osRelease{}, err + } + + nameMatch := rexpOsName.FindSubmatch(osReleaseTxt) + versionMatch := rexpOsVersion.FindSubmatch(osReleaseTxt) + + name := "" + if nameMatch != nil { + name = string(nameMatch[1]) + } + + version := "" + if versionMatch != nil { + version = string(versionMatch[1]) + } + + return osRelease{ + OsName: name, + OsVersion: version, + }, nil +} + +func mapRootfs(slots []map[string]raucFS) map[string]Rootfs { + res := make(map[string]Rootfs) + + for _, slot := range slots { + for name, fs := range slot { + if fs.Class == "rootfs" { + res[name] = Rootfs{ + Device: fs.Device, + Type: fs.Type, + bootname: fs.Bootname, + State: fs.State, + Mountpoint: fs.Mountpoint, + Bootable: fs.BootStatus == "good", + } + } + } + } + return res +} + +func getFSNameFromBootname(rfslist map[string]Rootfs, bootname string) string { + for name, rfs := range rfslist { + if rfs.bootname == bootname { + return name + } + } + return "n/a" +} + +func mapSysinfo(rinf raucInfo, osr osRelease, uptime int) SystemInfo { + rfslist := mapRootfs(rinf.Slots) + + return SystemInfo{ + OsName: osr.OsName, + OsVersion: osr.OsVersion, + Uptime: uptime, + RaucCompatible: rinf.Compatible, + RaucVariant: rinf.Variant, + RaucBooted: getFSNameFromBootname(rfslist, rinf.Booted), + RaucBootPrimary: rinf.BootPrimary, + RaucRootfs: rfslist, + } +} + +func getUptime() (int, error) { + uptimeRaw, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0, err + } + + uptimeChars := rexpUptime.Find(uptimeRaw) + return strconv.Atoi(string(uptimeChars)) +} + +func GetSysinfo() (SystemInfo, error) { + cmd := util.CommandFromString(util.RaucCmd + " status") + rinfJson, err := cmd.Output() + if err != nil { + return SystemInfo{}, err + } + + rinf, err := parseRaucInfo(rinfJson) + if err != nil { + return SystemInfo{}, err + } + + osinf, err := parseOsRelease("/etc/os-release") + if err != nil { + return SystemInfo{}, err + } + + uptime, err := getUptime() + if err != nil { + return SystemInfo{}, err + } + + return mapSysinfo(rinf, osinf, uptime), nil +} diff --git a/src/sysinfo/sysinfo_test.go b/src/sysinfo/sysinfo_test.go new file mode 100644 index 0000000..76bf2e0 --- /dev/null +++ b/src/sysinfo/sysinfo_test.go @@ -0,0 +1,120 @@ +package sysinfo + +import ( + "path/filepath" + "testing" + + "code.thetadev.de/TSGRain/SEBRAUC/src/fixtures" + "github.com/stretchr/testify/assert" +) + +const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` + + `"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` + + `"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` + + `"parent":null,"mountpoint":null,"boot_status":"good"}},{"rootfs.0":` + + `{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` + + `"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}` + +var expectedRaucInfo = raucInfo{ + Compatible: "TSGRain", + Variant: "dev", + Booted: "A", + BootPrimary: "rootfs.0", + Slots: []map[string]raucFS{ + { + "rootfs.1": { + Class: "rootfs", + Device: "/dev/mmcblk0p3", + Type: "ext4", + Bootname: "B", + State: "inactive", + Mountpoint: "", + BootStatus: "good", + }, + }, + { + "rootfs.0": { + Class: "rootfs", + Device: "/dev/mmcblk0p2", + Type: "ext4", + Bootname: "A", + State: "booted", + Mountpoint: "/", + BootStatus: "good", + }, + }, + }, +} + +var expectedRootfsList = map[string]Rootfs{ + "rootfs.0": { + Device: "/dev/mmcblk0p2", + Type: "ext4", + bootname: "A", + State: "booted", + Mountpoint: "/", + Bootable: true, + }, + "rootfs.1": { + Device: "/dev/mmcblk0p3", + Type: "ext4", + bootname: "B", + State: "inactive", + Mountpoint: "", + Bootable: true, + }, +} + +func TestParseRaucInfo(t *testing.T) { + info, err := parseRaucInfo([]byte(statusJson)) + if err != nil { + panic(err) + } + + assert.Equal(t, expectedRaucInfo, info) +} + +func TestParseOsRelease(t *testing.T) { + testfiles := fixtures.GetTestfilesDir() + osReleaseFile := filepath.Join(testfiles, "os-release") + + osRel, err := parseOsRelease(osReleaseFile) + if err != nil { + panic(err) + } + + expected := osRelease{ + OsName: "TSGRain distro", + OsVersion: "0.0.1", + } + + assert.Equal(t, expected, osRel) +} + +func TestMapRootfsList(t *testing.T) { + rootfsList := mapRootfs(expectedRaucInfo.Slots) + + assert.Equal(t, expectedRootfsList, rootfsList) +} + +func TestGetFSNameFromBootname(t *testing.T) { + rootfsList := mapRootfs(expectedRaucInfo.Slots) + + assert.Equal(t, "rootfs.0", getFSNameFromBootname(rootfsList, "A")) + assert.Equal(t, "rootfs.1", getFSNameFromBootname(rootfsList, "B")) + assert.Equal(t, "n/a", getFSNameFromBootname(rootfsList, "C")) +} + +func TestGetSysinfo(t *testing.T) { + sysinfo, err := GetSysinfo() + if err != nil { + panic(err) + } + + assert.Greater(t, sysinfo.Uptime, 0) + assert.Equal(t, "TSGRain", sysinfo.RaucCompatible) + assert.Equal(t, "", sysinfo.RaucVariant) + assert.Equal(t, "rootfs.0", sysinfo.RaucBooted) + assert.Equal(t, "rootfs.0", sysinfo.RaucBootPrimary) + assert.Equal(t, expectedRootfsList, sysinfo.RaucRootfs) +} diff --git a/src/util/commands.go b/src/util/commands.go index a17f2e6..a157fa0 100644 --- a/src/util/commands.go +++ b/src/util/commands.go @@ -5,7 +5,7 @@ package util const ( RebootCmd = "shutdown -r 0" - UpdateCmd = "rauc install" + RaucCmd = "rauc" TestMode = false ) diff --git a/src/util/commands_mock.go b/src/util/commands_mock.go index 1790b46..d4d1392 100644 --- a/src/util/commands_mock.go +++ b/src/util/commands_mock.go @@ -5,7 +5,7 @@ package util const ( RebootCmd = "touch /tmp/sebrauc_reboot_test" - UpdateCmd = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock" + RaucCmd = "go run code.thetadev.de/TSGRain/SEBRAUC/src/fixtures/rauc_mock" TestMode = true ) From 7465ef3380c8ecba4125cb76d6bf21ee2376e2eb Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Mon, 6 Dec 2021 00:52:14 +0100 Subject: [PATCH 2/3] add system info, openapi-generated client --- Makefile | 3 + openapi.yml | 34 +- src/fixtures/rauc_mock/main.go | 2 +- src/sysinfo/sysinfo.go | 58 +- src/sysinfo/sysinfo_test.go | 24 +- ui/src/components/Updater/Alert.tsx | 3 +- ui/src/components/Updater/Reboot.tsx | 11 +- ui/src/components/Updater/SysinfoCard.tsx | 159 ++++++ ui/src/components/Updater/Updater.scss | 22 +- .../Updater/{Updater.tsx => UpdaterCard.tsx} | 31 +- ui/src/components/Updater/UpdaterView.tsx | 48 ++ ui/src/components/app.tsx | 4 +- .../sebrauc-client/.openapi-generator-ignore | 27 + .../sebrauc-client/.openapi-generator/FILES | 5 + .../sebrauc-client/.openapi-generator/VERSION | 1 + ui/src/sebrauc-client/api.ts | 533 ++++++++++++++++++ ui/src/sebrauc-client/base.ts | 74 +++ ui/src/sebrauc-client/common.ts | 181 ++++++ ui/src/sebrauc-client/configuration.ts | 123 ++++ ui/src/sebrauc-client/index.ts | 16 + ui/src/style/index.scss | 2 + ui/src/style/table.scss | 47 ++ ui/src/util/apiUrls.ts | 10 +- ui/src/util/colors.ts | 7 + ui/src/util/functions.ts | 18 + 25 files changed, 1355 insertions(+), 88 deletions(-) create mode 100644 ui/src/components/Updater/SysinfoCard.tsx rename ui/src/components/Updater/{Updater.tsx => UpdaterCard.tsx} (90%) create mode 100644 ui/src/components/Updater/UpdaterView.tsx create mode 100644 ui/src/sebrauc-client/.openapi-generator-ignore create mode 100644 ui/src/sebrauc-client/.openapi-generator/FILES create mode 100644 ui/src/sebrauc-client/.openapi-generator/VERSION create mode 100644 ui/src/sebrauc-client/api.ts create mode 100644 ui/src/sebrauc-client/base.ts create mode 100644 ui/src/sebrauc-client/common.ts create mode 100644 ui/src/sebrauc-client/configuration.ts create mode 100644 ui/src/sebrauc-client/index.ts create mode 100644 ui/src/style/table.scss create mode 100644 ui/src/util/colors.ts create mode 100644 ui/src/util/functions.ts diff --git a/Makefile b/Makefile index 9861398..c058ed0 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ build-server: build: build-ui build-server +generate-apiclient: + openapi-generator generate -i openapi.yml -g typescript-axios -o ${UI_DIR}/src/sebrauc-client -p "supportsES6=true" + clean: rm -f build/* rm -rf ${UI_DIR}/dist/** diff --git a/openapi.yml b/openapi.yml index 55c362c..d343569 100644 --- a/openapi.yml +++ b/openapi.yml @@ -138,19 +138,18 @@ components: description: Compatible firmware variant type: string example: "rpi-prod" - rauc_booted: - description: Currently booted rootfs - type: string - example: "rootfs.0" - rauc_boot_primary: - description: Primary rootfs to boot from - type: string - example: "rootfs.1" rauc_rootfs: description: List of RAUC root filesystems type: object additionalProperties: $ref: "#/components/schemas/Rootfs" + required: + - os_name + - os_version + - uptime + - rauc_compatible + - rauc_variant + - rauc_rootfs Rootfs: type: object @@ -163,11 +162,6 @@ components: description: Filesystem type: string example: ext4 - state: - description: Current state of filesystem - type: string - enum: [active, inactive, booted] - example: booted mountpoint: description: Mount path (null when not mounted) type: string @@ -176,6 +170,19 @@ components: bootable: description: "Is the filesystem bootable" type: boolean + booted: + description: "Is the filesystem booted" + type: boolean + primary: + description: "Is the filesystem the next boot target" + type: boolean + required: + - device + - type + - mountpoint + - bootable + - booted + - primary StatusMessage: type: object @@ -188,4 +195,5 @@ components: type: string example: Update started required: + - success - msg diff --git a/src/fixtures/rauc_mock/main.go b/src/fixtures/rauc_mock/main.go index b87aadb..58986ff 100644 --- a/src/fixtures/rauc_mock/main.go +++ b/src/fixtures/rauc_mock/main.go @@ -41,7 +41,7 @@ LastError: Failed to check bundle identifier: Invalid identifier. ` + idle Installing ` + "/app/demo` failed" -const statusJson = `{"compatible":"TSGRain","variant":"","booted":"A",` + +const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` + `"boot_primary":"rootfs.0","slots":[{"rootfs.1":{"class":"rootfs",` + `"device":"/dev/mmcblk0p3","type":"ext4","bootname":"B","state":"inactive",` + `"parent":null,"mountpoint":null,"boot_status":"good"}},{"rootfs.0":` + diff --git a/src/sysinfo/sysinfo.go b/src/sysinfo/sysinfo.go index dc1df3b..57caafe 100644 --- a/src/sysinfo/sysinfo.go +++ b/src/sysinfo/sysinfo.go @@ -10,23 +10,22 @@ import ( ) type SystemInfo struct { - OsName string `json:"os_name"` - OsVersion string `json:"os_version"` - Uptime int `json:"uptime"` - RaucCompatible string `json:"rauc_compatible"` - RaucVariant string `json:"rauc_variant"` - RaucBooted string `json:"rauc_booted"` - RaucBootPrimary string `json:"rauc_boot_primary"` - RaucRootfs map[string]Rootfs `json:"rauc_rootfs"` + OsName string `json:"os_name"` + OsVersion string `json:"os_version"` + Uptime int `json:"uptime"` + RaucCompatible string `json:"rauc_compatible"` + RaucVariant string `json:"rauc_variant"` + RaucRootfs map[string]Rootfs `json:"rauc_rootfs"` } type Rootfs struct { Device string `json:"device"` Type string `json:"type"` bootname string - State string `json:"state"` - Mountpoint string `json:"mountpoint"` - Bootable bool `json:"bootable"` + Mountpoint *string `json:"mountpoint"` + Bootable bool `json:"bootable"` + Booted bool `json:"booted"` + Primary bool `json:"primary"` } type raucInfo struct { @@ -38,13 +37,13 @@ type raucInfo struct { } type raucFS struct { - Class string `json:"class"` - Device string `json:"device"` - Type string `json:"type"` - Bootname string `json:"bootname"` - State string `json:"state"` - Mountpoint string `json:"mountpoint"` - BootStatus string `json:"boot_status"` + Class string `json:"class"` + Device string `json:"device"` + Type string `json:"type"` + Bootname string `json:"bootname"` + State string `json:"state"` + Mountpoint *string `json:"mountpoint"` + BootStatus string `json:"boot_status"` } type osRelease struct { @@ -89,19 +88,20 @@ func parseOsRelease(osReleaseFile string) (osRelease, error) { }, nil } -func mapRootfs(slots []map[string]raucFS) map[string]Rootfs { +func mapRootfs(rinf raucInfo) map[string]Rootfs { res := make(map[string]Rootfs) - for _, slot := range slots { + for _, slot := range rinf.Slots { for name, fs := range slot { if fs.Class == "rootfs" { res[name] = Rootfs{ Device: fs.Device, Type: fs.Type, bootname: fs.Bootname, - State: fs.State, Mountpoint: fs.Mountpoint, Bootable: fs.BootStatus == "good", + Booted: fs.State == "booted", + Primary: rinf.BootPrimary == name, } } } @@ -119,17 +119,15 @@ func getFSNameFromBootname(rfslist map[string]Rootfs, bootname string) string { } func mapSysinfo(rinf raucInfo, osr osRelease, uptime int) SystemInfo { - rfslist := mapRootfs(rinf.Slots) + rfslist := mapRootfs(rinf) return SystemInfo{ - OsName: osr.OsName, - OsVersion: osr.OsVersion, - Uptime: uptime, - RaucCompatible: rinf.Compatible, - RaucVariant: rinf.Variant, - RaucBooted: getFSNameFromBootname(rfslist, rinf.Booted), - RaucBootPrimary: rinf.BootPrimary, - RaucRootfs: rfslist, + OsName: osr.OsName, + OsVersion: osr.OsVersion, + Uptime: uptime, + RaucCompatible: rinf.Compatible, + RaucVariant: rinf.Variant, + RaucRootfs: rfslist, } } diff --git a/src/sysinfo/sysinfo_test.go b/src/sysinfo/sysinfo_test.go index 76bf2e0..3628435 100644 --- a/src/sysinfo/sysinfo_test.go +++ b/src/sysinfo/sysinfo_test.go @@ -15,6 +15,8 @@ const statusJson = `{"compatible":"TSGRain","variant":"dev","booted":"A",` + `{"class":"rootfs","device":"/dev/mmcblk0p2","type":"ext4","bootname":"A",` + `"state":"booted","parent":null,"mountpoint":"/","boot_status":"good"}}]}` +var mountRoot = "/" + var expectedRaucInfo = raucInfo{ Compatible: "TSGRain", Variant: "dev", @@ -28,7 +30,7 @@ var expectedRaucInfo = raucInfo{ Type: "ext4", Bootname: "B", State: "inactive", - Mountpoint: "", + Mountpoint: nil, BootStatus: "good", }, }, @@ -39,7 +41,7 @@ var expectedRaucInfo = raucInfo{ Type: "ext4", Bootname: "A", State: "booted", - Mountpoint: "/", + Mountpoint: &mountRoot, BootStatus: "good", }, }, @@ -51,17 +53,19 @@ var expectedRootfsList = map[string]Rootfs{ Device: "/dev/mmcblk0p2", Type: "ext4", bootname: "A", - State: "booted", - Mountpoint: "/", + Mountpoint: &mountRoot, Bootable: true, + Booted: true, + Primary: true, }, "rootfs.1": { Device: "/dev/mmcblk0p3", Type: "ext4", bootname: "B", - State: "inactive", - Mountpoint: "", + Mountpoint: nil, Bootable: true, + Booted: false, + Primary: false, }, } @@ -92,13 +96,13 @@ func TestParseOsRelease(t *testing.T) { } func TestMapRootfsList(t *testing.T) { - rootfsList := mapRootfs(expectedRaucInfo.Slots) + rootfsList := mapRootfs(expectedRaucInfo) assert.Equal(t, expectedRootfsList, rootfsList) } func TestGetFSNameFromBootname(t *testing.T) { - rootfsList := mapRootfs(expectedRaucInfo.Slots) + rootfsList := mapRootfs(expectedRaucInfo) assert.Equal(t, "rootfs.0", getFSNameFromBootname(rootfsList, "A")) assert.Equal(t, "rootfs.1", getFSNameFromBootname(rootfsList, "B")) @@ -113,8 +117,6 @@ func TestGetSysinfo(t *testing.T) { assert.Greater(t, sysinfo.Uptime, 0) assert.Equal(t, "TSGRain", sysinfo.RaucCompatible) - assert.Equal(t, "", sysinfo.RaucVariant) - assert.Equal(t, "rootfs.0", sysinfo.RaucBooted) - assert.Equal(t, "rootfs.0", sysinfo.RaucBootPrimary) + assert.Equal(t, "dev", sysinfo.RaucVariant) assert.Equal(t, expectedRootfsList, sysinfo.RaucRootfs) } diff --git a/ui/src/components/Updater/Alert.tsx b/ui/src/components/Updater/Alert.tsx index 6eb16a9..204a691 100644 --- a/ui/src/components/Updater/Alert.tsx +++ b/ui/src/components/Updater/Alert.tsx @@ -1,6 +1,7 @@ import {Component} from "preact" import {mdiTriangleOutline} from "@mdi/js" import Icon from "../Icon/Icon" +import colors from "../../util/colors" type Props = { source?: string @@ -20,7 +21,7 @@ export default class Alert extends Component { return (
- + {msg}
diff --git a/ui/src/components/Updater/Reboot.tsx b/ui/src/components/Updater/Reboot.tsx index c53666e..49a0232 100644 --- a/ui/src/components/Updater/Reboot.tsx +++ b/ui/src/components/Updater/Reboot.tsx @@ -1,7 +1,6 @@ import {mdiCheckCircleOutline, mdiRestore} from "@mdi/js" -import axios, {AxiosError, AxiosResponse} from "axios" import {Component} from "preact" -import {apiUrl} from "../../util/apiUrls" +import {sebraucApi} from "../../util/apiUrls" import Icon from "../Icon/Icon" export default class Reboot extends Component { @@ -9,9 +8,9 @@ export default class Reboot extends Component { const res = confirm("Reboot the system?") if (!res) return - axios - .post(apiUrl + "/reboot") - .then((response: AxiosResponse) => { + sebraucApi + .startReboot() + .then((response) => { const msg = response.data.msg if (msg !== undefined) { @@ -20,7 +19,7 @@ export default class Reboot extends Component { alert("No response") } }) - .catch((error: AxiosError) => { + .catch((error) => { if (error.response) { const msg = error.response.data.msg diff --git a/ui/src/components/Updater/SysinfoCard.tsx b/ui/src/components/Updater/SysinfoCard.tsx new file mode 100644 index 0000000..56813a3 --- /dev/null +++ b/ui/src/components/Updater/SysinfoCard.tsx @@ -0,0 +1,159 @@ +import {Component} from "preact" +import {SystemInfo} from "../../sebrauc-client" +import {sebraucApi} from "../../util/apiUrls" +import {secondsToString} from "../../util/functions" +import Icon from "../Icon/Icon" +import { + mdiAlphaVCircleOutline, + mdiCheckCircleOutline, + mdiCircleOutline, + mdiClockOutline, + mdiCloseCircleOutline, + mdiMonitor, + mdiPenguin, + mdiTagMultipleOutline, + mdiTagOutline, +} from "@mdi/js" +import colors from "../../util/colors" + +type Props = {} + +type State = { + sysinfo: SystemInfo +} + +export default class SysinfoCard extends Component { + constructor(props?: Props | undefined, context?: any) { + super(props, context) + this.fetchInfo() + } + + private fetchInfo = () => { + sebraucApi + .getInfo() + .then((response) => { + if (response.status == 200) { + this.setState({sysinfo: response.data}) + } else { + console.log("error fetching info", response.data) + console.log("error fetching info", response.data) + window.setTimeout(this.fetchInfo, 3000) + } + }) + .catch((reason) => { + console.log("error fetching info", reason) + window.setTimeout(this.fetchInfo, 3000) + }) + } + + private renderSysinfo() { + return ( +
+
+

System information

+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Hostname + TODO
+ Operating system + {this.state.sysinfo.os_name}
+ OS version + {this.state.sysinfo.os_version}
+ Uptime + {secondsToString(this.state.sysinfo.uptime)}
+ Compatible FW + {this.state.sysinfo.rauc_compatible}
+ Compatible FW + variant + {this.state.sysinfo.rauc_variant}
+
+ +
+

Rootfs slots

+
+ + + + + + + + + + + {Object.keys(this.state.sysinfo.rauc_rootfs).map( + (k, i) => { + const rfs = this.state.sysinfo.rauc_rootfs[k] + let icon = mdiCircleOutline + let iconColor = colors.BLUE + + if (!rfs.bootable) { + icon = mdiCloseCircleOutline + iconColor = colors.RED + } else if (rfs.primary) { + icon = mdiCheckCircleOutline + iconColor = colors.GREEN + } + + return ( + + + + + + + ) + } + )} + +
NameDeviceMountpoint
+ + {k}{rfs.device}{rfs.mountpoint}
+
+
+
+ ) + } + + private renderLoadingAnimation() { + return ( +
+

loading sysinfo...

+
+ ) + } + + render() { + if (this.state.sysinfo) { + return this.renderSysinfo() + } + return this.renderLoadingAnimation() + } +} diff --git a/ui/src/components/Updater/Updater.scss b/ui/src/components/Updater/Updater.scss index 90b798e..fe3ace9 100644 --- a/ui/src/components/Updater/Updater.scss +++ b/ui/src/components/Updater/Updater.scss @@ -1,11 +1,11 @@ -.uploader { +.updater-view { display: flex; flex-direction: column; justify-content: center; align-items: center; margin: 0 auto; - max-width: 500px; + max-width: 600px; width: 90%; > * { @@ -18,8 +18,8 @@ justify-content: center; align-items: center; - padding: 15px 8px; - margin: 8px 0; + margin-top: 25px; + margin-bottom: 8px; text-align: center; @@ -29,6 +29,14 @@ .top { font-size: 1.5em; } + + &.pad { + padding: 15px 0; + } + + &:first-of-type { + margin-top: 8px; + } } .alert { @@ -42,3 +50,9 @@ } } } + +.button-top-right { + position: absolute; + top: 20px; + right: 20px; +} diff --git a/ui/src/components/Updater/Updater.tsx b/ui/src/components/Updater/UpdaterCard.tsx similarity index 90% rename from ui/src/components/Updater/Updater.tsx rename to ui/src/components/Updater/UpdaterCard.tsx index 772f837..6873897 100644 --- a/ui/src/components/Updater/Updater.tsx +++ b/ui/src/components/Updater/UpdaterCard.tsx @@ -5,10 +5,10 @@ import Dropzone from "../Dropzone/Dropzone" import ProgressCircle from "../ProgressCircle/ProgressCircle" import Icon from "../Icon/Icon" import "./Updater.scss" -import axios from "axios" import Alert from "./Alert" import Reboot from "./Reboot" -import {apiUrl, wsUrl} from "../../util/apiUrls" +import {sebraucApi, wsUrl} from "../../util/apiUrls" +import colors from "../../util/colors" class UploadStatus { uploading = false @@ -50,7 +50,7 @@ type State = { wsConnected: boolean } -export default class Updater extends Component { +export default class UpdaterCard extends Component { private dropzoneRef = createRef() private conn: WebSocket | undefined @@ -77,19 +77,13 @@ export default class Updater extends Component { 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(apiUrl + "/update", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, + sebraucApi + .startUpdate(newFile, { onUploadProgress: (progressEvent: {loaded: number; total: number}) => { this.setState({ uploadStatus: UploadStatus.fromProgressEvent(progressEvent), @@ -132,8 +126,6 @@ export default class Updater extends Component { JSON.parse(messages[i]) ), }) - - console.log(this.state.raucStatus) } } } else { @@ -163,9 +155,9 @@ export default class Updater extends Component { } private circleColor(): string { - if (this.state.raucStatus.installing) return "#FF0039" - if (this.state.uploadStatus.uploading) return "#148420" - return "#1f85de" + if (this.state.raucStatus.installing) return colors.RED + if (this.state.uploadStatus.uploading) return colors.GREEN + return colors.BLUE } private circlePercentage(): number { @@ -195,12 +187,13 @@ export default class Updater extends Component { topText = "Updating firmware" bottomText = this.state.raucStatus.message } else { - topText = "Upload firmware package" + topText = "Firmware update" + bottomText = "Upload *.raucb FW package" } return ( -
-
+
+

{topText}

diff --git a/ui/src/components/Updater/UpdaterView.tsx b/ui/src/components/Updater/UpdaterView.tsx new file mode 100644 index 0000000..faf96c4 --- /dev/null +++ b/ui/src/components/Updater/UpdaterView.tsx @@ -0,0 +1,48 @@ +import {mdiInformation, mdiUpload} from "@mdi/js" +import {Component} from "preact" +import Icon from "../Icon/Icon" +import SysinfoCard from "./SysinfoCard" +import UpdaterCard from "./UpdaterCard" +import "./Updater.scss" + +type Props = {} + +type State = { + flipped: boolean +} + +export default class UpdaterView extends Component { + constructor(props?: Props | undefined, context?: any) { + super(props, context) + + this.state = { + flipped: false, + } + } + + private flipCard = () => { + this.setState({flipped: !this.state.flipped}) + } + + render() { + return ( +
+ + +
+ {!this.state.flipped ? : } +
+
+ ) + } +} diff --git a/ui/src/components/app.tsx b/ui/src/components/app.tsx index 4d0f547..b068bc3 100644 --- a/ui/src/components/app.tsx +++ b/ui/src/components/app.tsx @@ -1,5 +1,5 @@ import {Component} from "preact" -import Updater from "./Updater/Updater" +import UpdaterView from "./Updater/UpdaterView" import logo from "../assets/logo.svg" import {version} from "../util/version" @@ -9,7 +9,7 @@ export default class App extends Component {
SEBRAUC {version} - +
) } diff --git a/ui/src/sebrauc-client/.openapi-generator-ignore b/ui/src/sebrauc-client/.openapi-generator-ignore new file mode 100644 index 0000000..7e15242 --- /dev/null +++ b/ui/src/sebrauc-client/.openapi-generator-ignore @@ -0,0 +1,27 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md + +/.gitignore +/.npmignore +/git_push.sh diff --git a/ui/src/sebrauc-client/.openapi-generator/FILES b/ui/src/sebrauc-client/.openapi-generator/FILES new file mode 100644 index 0000000..53250c0 --- /dev/null +++ b/ui/src/sebrauc-client/.openapi-generator/FILES @@ -0,0 +1,5 @@ +api.ts +base.ts +common.ts +configuration.ts +index.ts diff --git a/ui/src/sebrauc-client/.openapi-generator/VERSION b/ui/src/sebrauc-client/.openapi-generator/VERSION new file mode 100644 index 0000000..e230c83 --- /dev/null +++ b/ui/src/sebrauc-client/.openapi-generator/VERSION @@ -0,0 +1 @@ +5.3.0 \ No newline at end of file diff --git a/ui/src/sebrauc-client/api.ts b/ui/src/sebrauc-client/api.ts new file mode 100644 index 0000000..65eaf45 --- /dev/null +++ b/ui/src/sebrauc-client/api.ts @@ -0,0 +1,533 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import {Configuration} from "./configuration" +import globalAxios, {AxiosPromise, AxiosInstance, AxiosRequestConfig} from "axios" +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "./common" +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "./base" + +/** + * + * @export + * @interface RaucStatus + */ +export interface RaucStatus { + /** + * True if the installer is running + * @type {boolean} + * @memberof RaucStatus + */ + installing: boolean + /** + * Installation progress + * @type {number} + * @memberof RaucStatus + */ + percent: number + /** + * Current installation step + * @type {string} + * @memberof RaucStatus + */ + message: string + /** + * Installation error message + * @type {string} + * @memberof RaucStatus + */ + last_error: string + /** + * Full command line output of the current installation + * @type {string} + * @memberof RaucStatus + */ + log: string +} +/** + * + * @export + * @interface Rootfs + */ +export interface Rootfs { + /** + * Block device + * @type {string} + * @memberof Rootfs + */ + device?: string + /** + * Filesystem + * @type {string} + * @memberof Rootfs + */ + type: string + /** + * Mount path (null when not mounted) + * @type {string} + * @memberof Rootfs + */ + mountpoint: string | null + /** + * Is the filesystem bootable + * @type {boolean} + * @memberof Rootfs + */ + bootable: boolean + /** + * Is the filesystem booted + * @type {boolean} + * @memberof Rootfs + */ + booted: boolean + /** + * Is the filesystem the next boot target + * @type {boolean} + * @memberof Rootfs + */ + primary: boolean +} +/** + * + * @export + * @interface StatusMessage + */ +export interface StatusMessage { + /** + * Is operation successful + * @type {boolean} + * @memberof StatusMessage + */ + success: boolean + /** + * Success message + * @type {string} + * @memberof StatusMessage + */ + msg: string +} +/** + * + * @export + * @interface SystemInfo + */ +export interface SystemInfo { + /** + * Name of the os distribution + * @type {string} + * @memberof SystemInfo + */ + os_name: string + /** + * Operating system version + * @type {string} + * @memberof SystemInfo + */ + os_version: string + /** + * System uptime in seconds + * @type {number} + * @memberof SystemInfo + */ + uptime: number + /** + * Compatible firmware name + * @type {string} + * @memberof SystemInfo + */ + rauc_compatible: string + /** + * Compatible firmware variant + * @type {string} + * @memberof SystemInfo + */ + rauc_variant: string + /** + * List of RAUC root filesystems + * @type {{ [key: string]: Rootfs; }} + * @memberof SystemInfo + */ + rauc_rootfs: {[key: string]: Rootfs} +} + +/** + * DefaultApi - axios parameter creator + * @export + */ +export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getInfo: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/info` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "GET", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStatus: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/status` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "GET", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startReboot: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/reboot` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "POST", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + /** + * + * @param {any} [updateFile] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startUpdate: async ( + updateFile?: any, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/update` + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = {method: "POST", ...baseOptions, ...options} + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + const localVarFormParams = new ((configuration && + configuration.formDataCtor) || + FormData)() + + if (updateFile !== undefined) { + localVarFormParams.append("updateFile", updateFile as any) + } + + localVarHeaderParameter["Content-Type"] = "multipart/form-data" + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + localVarRequestOptions.data = localVarFormParams + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, + } +} + +/** + * DefaultApi - functional programming interface + * @export + */ +export const DefaultApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getInfo( + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getInfo(options) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getStatus( + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.getStatus(options) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async startReboot( + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.startReboot( + options + ) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + /** + * + * @param {any} [updateFile] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async startUpdate( + updateFile?: any, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.startUpdate( + updateFile, + options + ) + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ) + }, + } +} + +/** + * DefaultApi - factory interface + * @export + */ +export const DefaultApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = DefaultApiFp(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getInfo(options?: any): AxiosPromise { + return localVarFp + .getInfo(options) + .then((request) => request(axios, basePath)) + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStatus(options?: any): AxiosPromise { + return localVarFp + .getStatus(options) + .then((request) => request(axios, basePath)) + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startReboot(options?: any): AxiosPromise { + return localVarFp + .startReboot(options) + .then((request) => request(axios, basePath)) + }, + /** + * + * @param {any} [updateFile] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startUpdate(updateFile?: any, options?: any): AxiosPromise { + return localVarFp + .startUpdate(updateFile, options) + .then((request) => request(axios, basePath)) + }, + } +} + +/** + * DefaultApi - object-oriented interface + * @export + * @class DefaultApi + * @extends {BaseAPI} + */ +export class DefaultApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public getInfo(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .getInfo(options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public getStatus(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .getStatus(options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public startReboot(options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .startReboot(options) + .then((request) => request(this.axios, this.basePath)) + } + + /** + * + * @param {any} [updateFile] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public startUpdate(updateFile?: any, options?: AxiosRequestConfig) { + return DefaultApiFp(this.configuration) + .startUpdate(updateFile, options) + .then((request) => request(this.axios, this.basePath)) + } +} diff --git a/ui/src/sebrauc-client/base.ts b/ui/src/sebrauc-client/base.ts new file mode 100644 index 0000000..6248680 --- /dev/null +++ b/ui/src/sebrauc-client/base.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import {Configuration} from "./configuration" +// Some imports not used depending on template conditions +// @ts-ignore +import globalAxios, {AxiosPromise, AxiosInstance, AxiosRequestConfig} from "axios" + +export const BASE_PATH = "http://localhost:8080/api".replace(/\/+$/, "") + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +} + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string + options: AxiosRequestConfig +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined + + constructor( + configuration?: Configuration, + protected basePath: string = BASE_PATH, + protected axios: AxiosInstance = globalAxios + ) { + if (configuration) { + this.configuration = configuration + this.basePath = configuration.basePath || this.basePath + } + } +} + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + name: "RequiredError" = "RequiredError" + constructor(public field: string, msg?: string) { + super(msg) + } +} diff --git a/ui/src/sebrauc-client/common.ts b/ui/src/sebrauc-client/common.ts new file mode 100644 index 0000000..9942bb6 --- /dev/null +++ b/ui/src/sebrauc-client/common.ts @@ -0,0 +1,181 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import {Configuration} from "./configuration" +import {RequiredError, RequestArgs} from "./base" +import {AxiosInstance, AxiosResponse} from "axios" + +/** + * + * @export + */ +export const DUMMY_BASE_URL = "https://example.com" + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function ( + functionName: string, + paramName: string, + paramValue: unknown +) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError( + paramName, + `Required parameter ${paramName} was null or undefined when calling ${functionName}.` + ) + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function ( + object: any, + keyParamName: string, + configuration?: Configuration +) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = + typeof configuration.apiKey === "function" + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey + object[keyParamName] = localVarApiKeyValue + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function ( + object: any, + configuration?: Configuration +) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { + username: configuration.username, + password: configuration.password, + } + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function ( + object: any, + configuration?: Configuration +) { + if (configuration && configuration.accessToken) { + const accessToken = + typeof configuration.accessToken === "function" + ? await configuration.accessToken() + : await configuration.accessToken + object["Authorization"] = "Bearer " + accessToken + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function ( + object: any, + name: string, + scopes: string[], + configuration?: Configuration +) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = + typeof configuration.accessToken === "function" + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken + object["Authorization"] = "Bearer " + localVarAccessTokenValue + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search) + for (const object of objects) { + for (const key in object) { + if (Array.isArray(object[key])) { + searchParams.delete(key) + for (const item of object[key]) { + searchParams.append(key, item) + } + } else { + searchParams.set(key, object[key]) + } + } + } + url.search = searchParams.toString() +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function ( + value: any, + requestOptions: any, + configuration?: Configuration +) { + const nonString = typeof value !== "string" + const needsSerialization = + nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers["Content-Type"]) + : nonString + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : value || "" +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function ( + axiosArgs: RequestArgs, + globalAxios: AxiosInstance, + BASE_PATH: string, + configuration?: Configuration +) { + return >( + axios: AxiosInstance = globalAxios, + basePath: string = BASE_PATH + ) => { + const axiosRequestArgs = { + ...axiosArgs.options, + url: (configuration?.basePath || basePath) + axiosArgs.url, + } + return axios.request(axiosRequestArgs) + } +} diff --git a/ui/src/sebrauc-client/configuration.ts b/ui/src/sebrauc-client/configuration.ts new file mode 100644 index 0000000..357fd9b --- /dev/null +++ b/ui/src/sebrauc-client/configuration.ts @@ -0,0 +1,123 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +export interface ConfigurationParameters { + apiKey?: + | string + | Promise + | ((name: string) => string) + | ((name: string) => Promise) + username?: string + password?: string + accessToken?: + | string + | Promise + | ((name?: string, scopes?: string[]) => string) + | ((name?: string, scopes?: string[]) => Promise) + basePath?: string + baseOptions?: any + formDataCtor?: new () => any +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: + | string + | Promise + | ((name: string) => string) + | ((name: string) => Promise) + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: + | string + | Promise + | ((name?: string, scopes?: string[]) => string) + | ((name?: string, scopes?: string[]) => Promise) + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey + this.username = param.username + this.password = param.password + this.accessToken = param.accessToken + this.basePath = param.basePath + this.baseOptions = param.baseOptions + this.formDataCtor = param.formDataCtor + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp( + "^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$", + "i" + ) + return ( + mime !== null && + (jsonMime.test(mime) || + mime.toLowerCase() === "application/json-patch+json") + ) + } +} diff --git a/ui/src/sebrauc-client/index.ts b/ui/src/sebrauc-client/index.ts new file mode 100644 index 0000000..a0c08f6 --- /dev/null +++ b/ui/src/sebrauc-client/index.ts @@ -0,0 +1,16 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * SEBRAUC + * REST API for the SEBRAUC firmware updater + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +export * from "./api" +export * from "./configuration" diff --git a/ui/src/style/index.scss b/ui/src/style/index.scss index 5774a08..1f333d4 100644 --- a/ui/src/style/index.scss +++ b/ui/src/style/index.scss @@ -1,3 +1,5 @@ +@use "table"; + html, body { height: 100%; diff --git a/ui/src/style/table.scss b/ui/src/style/table.scss new file mode 100644 index 0000000..c522f5d --- /dev/null +++ b/ui/src/style/table.scss @@ -0,0 +1,47 @@ +.table-wrapper { + width: 100%; + overflow-x: auto; +} + +.table { + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; + border-collapse: collapse; + margin: 0; + padding: 0; + width: 100%; + + &.no-bottom-border { + &, + > tr:last-child, + :not(thead) tr:last-child { + border-bottom: none; + } + } +} + +.table caption { + font-size: 1.5em; + margin: 0.5em 0 0.75em; +} + +.table tr { + border-bottom: 1px solid #ddd; + padding: 0.35em; +} + +.table th, +.table td { + padding: 0.625em; + text-align: left; + + .icon { + color: #1f85de; + } +} + +.table th { + font-size: 0.85em; + letter-spacing: 0.085em; + text-transform: uppercase; +} diff --git a/ui/src/util/apiUrls.ts b/ui/src/util/apiUrls.ts index 4a9d51b..724d52b 100644 --- a/ui/src/util/apiUrls.ts +++ b/ui/src/util/apiUrls.ts @@ -1,3 +1,5 @@ +import {Configuration, DefaultApi} from "../sebrauc-client" + let apiHost = document.location.host const httpProto = document.location.protocol const wsProto = httpProto === "https:" ? "wss:" : "ws:" @@ -9,4 +11,10 @@ if (import.meta.env.VITE_API_HOST !== undefined) { const apiUrl = `${httpProto}//${apiHost}/api` const wsUrl = `${wsProto}//${apiHost}/api/ws` -export {apiUrl, wsUrl} +let apicfg = new Configuration({ + basePath: apiUrl, +}) + +const sebraucApi = new DefaultApi(apicfg) + +export {apiUrl, wsUrl, sebraucApi} diff --git a/ui/src/util/colors.ts b/ui/src/util/colors.ts new file mode 100644 index 0000000..3202366 --- /dev/null +++ b/ui/src/util/colors.ts @@ -0,0 +1,7 @@ +class colors { + static readonly RED = "#FF0039" + static readonly GREEN = "#148420" + static readonly BLUE = "#1f85de" +} + +export default colors diff --git a/ui/src/util/functions.ts b/ui/src/util/functions.ts new file mode 100644 index 0000000..18179e2 --- /dev/null +++ b/ui/src/util/functions.ts @@ -0,0 +1,18 @@ +function secondsToString(seconds: number): string { + const numyears = Math.floor(seconds / 31536000) + const numdays = Math.floor((seconds % 31536000) / 86400) + const numhours = Math.floor(((seconds % 31536000) % 86400) / 3600) + const numminutes = Math.floor((((seconds % 31536000) % 86400) % 3600) / 60) + const numseconds = (((seconds % 31536000) % 86400) % 3600) % 60 + + let res = [] + if (numyears > 0) res.push(numyears + "yr") + if (numdays > 0) res.push(numdays + "d") + if (numhours > 0) res.push(numhours + "h") + if (numminutes > 0) res.push(numminutes + "m") + if (seconds < 60) res.push(numseconds + "s") + + return res.join(" ") +} + +export {secondsToString} From 44001bb7e7708119d544c8f2ee094654ac23dcc2 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Mon, 6 Dec 2021 00:59:23 +0100 Subject: [PATCH 3/3] add hostname to sysinfo --- openapi.yml | 5 +++++ src/sysinfo/sysinfo.go | 17 +++++++++++++++-- ui/src/components/Updater/SysinfoCard.tsx | 2 +- ui/src/sebrauc-client/api.ts | 8 +++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/openapi.yml b/openapi.yml index d343569..fe288c0 100644 --- a/openapi.yml +++ b/openapi.yml @@ -118,6 +118,10 @@ components: SystemInfo: type: object properties: + hostname: + description: Hostname of the system + type: string + example: "raspberrypi3" os_name: description: Name of the os distribution type: string @@ -144,6 +148,7 @@ components: additionalProperties: $ref: "#/components/schemas/Rootfs" required: + - hostname - os_name - os_version - uptime diff --git a/src/sysinfo/sysinfo.go b/src/sysinfo/sysinfo.go index 57caafe..f00f389 100644 --- a/src/sysinfo/sysinfo.go +++ b/src/sysinfo/sysinfo.go @@ -5,11 +5,13 @@ import ( "os" "regexp" "strconv" + "strings" "code.thetadev.de/TSGRain/SEBRAUC/src/util" ) type SystemInfo struct { + Hostname string `json:"hostname"` OsName string `json:"os_name"` OsVersion string `json:"os_version"` Uptime int `json:"uptime"` @@ -118,10 +120,11 @@ func getFSNameFromBootname(rfslist map[string]Rootfs, bootname string) string { return "n/a" } -func mapSysinfo(rinf raucInfo, osr osRelease, uptime int) SystemInfo { +func mapSysinfo(rinf raucInfo, osr osRelease, uptime int, hostname string) SystemInfo { rfslist := mapRootfs(rinf) return SystemInfo{ + Hostname: hostname, OsName: osr.OsName, OsVersion: osr.OsVersion, Uptime: uptime, @@ -141,6 +144,14 @@ func getUptime() (int, error) { return strconv.Atoi(string(uptimeChars)) } +func getHostname() string { + hostname, err := os.ReadFile("/etc/hostname") + if err != nil { + return "" + } + return strings.TrimSpace(string(hostname)) +} + func GetSysinfo() (SystemInfo, error) { cmd := util.CommandFromString(util.RaucCmd + " status") rinfJson, err := cmd.Output() @@ -163,5 +174,7 @@ func GetSysinfo() (SystemInfo, error) { return SystemInfo{}, err } - return mapSysinfo(rinf, osinf, uptime), nil + hostname := getHostname() + + return mapSysinfo(rinf, osinf, uptime, hostname), nil } diff --git a/ui/src/components/Updater/SysinfoCard.tsx b/ui/src/components/Updater/SysinfoCard.tsx index 56813a3..04bd51c 100644 --- a/ui/src/components/Updater/SysinfoCard.tsx +++ b/ui/src/components/Updater/SysinfoCard.tsx @@ -56,7 +56,7 @@ export default class SysinfoCard extends Component { Hostname - TODO + {this.state.sysinfo.hostname} diff --git a/ui/src/sebrauc-client/api.ts b/ui/src/sebrauc-client/api.ts index 65eaf45..82abc3d 100644 --- a/ui/src/sebrauc-client/api.ts +++ b/ui/src/sebrauc-client/api.ts @@ -85,7 +85,7 @@ export interface Rootfs { * @type {string} * @memberof Rootfs */ - device?: string + device: string /** * Filesystem * @type {string} @@ -142,6 +142,12 @@ export interface StatusMessage { * @interface SystemInfo */ export interface SystemInfo { + /** + * Hostname of the system + * @type {string} + * @memberof SystemInfo + */ + hostname: string /** * Name of the os distribution * @type {string}