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/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..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 @@ -138,21 +142,21 @@ 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/RaucFS" + $ref: "#/components/schemas/Rootfs" + required: + - hostname + - os_name + - os_version + - uptime + - rauc_compatible + - rauc_variant + - rauc_rootfs - RaucFS: + Rootfs: type: object properties: device: @@ -163,11 +167,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 +175,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 +200,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 4c9b884..58986ff 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":"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"}}]}` + +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..f00f389 --- /dev/null +++ b/src/sysinfo/sysinfo.go @@ -0,0 +1,180 @@ +package sysinfo + +import ( + "encoding/json" + "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"` + 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 + Mountpoint *string `json:"mountpoint"` + Bootable bool `json:"bootable"` + Booted bool `json:"booted"` + Primary bool `json:"primary"` +} + +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(rinf raucInfo) map[string]Rootfs { + res := make(map[string]Rootfs) + + 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, + Mountpoint: fs.Mountpoint, + Bootable: fs.BootStatus == "good", + Booted: fs.State == "booted", + Primary: rinf.BootPrimary == name, + } + } + } + } + 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, hostname string) SystemInfo { + rfslist := mapRootfs(rinf) + + return SystemInfo{ + Hostname: hostname, + OsName: osr.OsName, + OsVersion: osr.OsVersion, + Uptime: uptime, + RaucCompatible: rinf.Compatible, + RaucVariant: rinf.Variant, + 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 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() + 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 + } + + hostname := getHostname() + + return mapSysinfo(rinf, osinf, uptime, hostname), nil +} diff --git a/src/sysinfo/sysinfo_test.go b/src/sysinfo/sysinfo_test.go new file mode 100644 index 0000000..3628435 --- /dev/null +++ b/src/sysinfo/sysinfo_test.go @@ -0,0 +1,122 @@ +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 mountRoot = "/" + +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: nil, + BootStatus: "good", + }, + }, + { + "rootfs.0": { + Class: "rootfs", + Device: "/dev/mmcblk0p2", + Type: "ext4", + Bootname: "A", + State: "booted", + Mountpoint: &mountRoot, + BootStatus: "good", + }, + }, + }, +} + +var expectedRootfsList = map[string]Rootfs{ + "rootfs.0": { + Device: "/dev/mmcblk0p2", + Type: "ext4", + bootname: "A", + Mountpoint: &mountRoot, + Bootable: true, + Booted: true, + Primary: true, + }, + "rootfs.1": { + Device: "/dev/mmcblk0p3", + Type: "ext4", + bootname: "B", + Mountpoint: nil, + Bootable: true, + Booted: false, + Primary: false, + }, +} + +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) + + assert.Equal(t, expectedRootfsList, rootfsList) +} + +func TestGetFSNameFromBootname(t *testing.T) { + rootfsList := mapRootfs(expectedRaucInfo) + + 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, "dev", sysinfo.RaucVariant) + 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 ) 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..04bd51c --- /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 + {this.state.sysinfo.hostname}
+ 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..82abc3d --- /dev/null +++ b/ui/src/sebrauc-client/api.ts @@ -0,0 +1,539 @@ +/* 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 { + /** + * Hostname of the system + * @type {string} + * @memberof SystemInfo + */ + hostname: string + /** + * 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}