diff --git a/_frontend/src/components/Widget.jsx b/_frontend/src/components/Widget.jsx index 663692c..1df9f8f 100644 --- a/_frontend/src/components/Widget.jsx +++ b/_frontend/src/components/Widget.jsx @@ -1,9 +1,13 @@ -import { MessageWidget } from './MessageWidget.jsx' -import { PieChartWidget } from './PieChartWidget.jsx' +import { DiskUsage } from './widgets/DiskUsage.jsx' +import { MessageWidget } from './widgets/MessageWidget.jsx' +import { PieChartWidget } from './widgets/PieChartWidget.jsx' +import { ScriptStatus } from './widgets/ScriptStatus.jsx' const WidgetTypes = { pie: PieChartWidget, message: MessageWidget, + 'script-status': ScriptStatus, + 'disk-usage': DiskUsage, } export const Widget = ({ type, value }) => { diff --git a/_frontend/src/components/PieChartWidget.jsx b/_frontend/src/components/charts/PieChart.jsx similarity index 82% rename from _frontend/src/components/PieChartWidget.jsx rename to _frontend/src/components/charts/PieChart.jsx index a7a0fb2..5b2d4d9 100644 --- a/_frontend/src/components/PieChartWidget.jsx +++ b/_frontend/src/components/charts/PieChart.jsx @@ -1,13 +1,9 @@ import { useEffect, useRef } from 'preact/hooks' -import { hashCode } from '../util.jsx' - -const CANVAS_SIZE = 350 - -export const PieChartWidget = ({ title, parts, labels, total }) => { - title = title || 'Grafico a torta' +import { hashCode } from '../../util.jsx' +export const PieChart = ({ parts, labels, total }) => { parts = parts || [1] - labels = labels || [] + labels = labels || parts const canvasRef = useRef() @@ -33,7 +29,7 @@ export const PieChartWidget = ({ title, parts, labels, total }) => { g.scale(2, 2) g.translate(width / 2, height / 2) - g.font = `18px 'Open Sans'` + g.font = `16px 'Open Sans'` g.textAlign = 'center' g.textBaseline = 'middle' @@ -62,13 +58,14 @@ export const PieChartWidget = ({ title, parts, labels, total }) => { g.beginPath() g.moveTo(0, 0) g.arc(0, 0, width * 0.5 * 0.8, acc - 0.5 * Math.PI, acc + angle - 0.5 * Math.PI) + g.lineTo(0, 0) g.stroke() g.fillStyle = '#333' g.fillText( label, - Math.cos(acc + angle / 2 - 0.5 * Math.PI) * width * 0.5 * 0.9, - Math.sin(acc + angle / 2 - 0.5 * Math.PI) * width * 0.5 * 0.9 + Math.cos(acc + angle / 2 - 0.5 * Math.PI) * width * 0.5 * 0.9125, + Math.sin(acc + angle / 2 - 0.5 * Math.PI) * width * 0.5 * 0.9125 ) acc += angle @@ -76,12 +73,5 @@ export const PieChartWidget = ({ title, parts, labels, total }) => { } }, [canvasRef, parts]) - return ( - <> -
{title}
-
- -
- - ) + return } diff --git a/_frontend/src/components/widgets/DiskUsage.jsx b/_frontend/src/components/widgets/DiskUsage.jsx new file mode 100644 index 0000000..1d3573f --- /dev/null +++ b/_frontend/src/components/widgets/DiskUsage.jsx @@ -0,0 +1,52 @@ +import { byteSizeToString, useRemoteState } from '../../util.jsx' +import { PieChart } from '../charts/PieChart.jsx' +import { Icon } from '../Icon.jsx' +import { ToastMessage, useToasts } from '../Toasts.jsx' + +export const DiskUsage = ({ disk }) => { + const [showToast] = useToasts() + const [output, err, refreshOutput] = useRemoteState( + `/api/monitor/status?script=${encodeURIComponent(`disk-usage ${disk}`)}`, + '100\n0' + ) + + const [total, used] = output.split('\n').map(s => parseInt(s)) + + const title = ( + <> + Spazio Utilizzato • {disk} + + ) + + return ( + <> +
+
+
+

{title}

+
+
+ +
+
+
+
+ {err ? ( +

Errore "{err}"

+ ) : ( + + )} +
+ + ) +} diff --git a/_frontend/src/components/MessageWidget.jsx b/_frontend/src/components/widgets/MessageWidget.jsx similarity index 100% rename from _frontend/src/components/MessageWidget.jsx rename to _frontend/src/components/widgets/MessageWidget.jsx diff --git a/_frontend/src/components/widgets/PieChartWidget.jsx b/_frontend/src/components/widgets/PieChartWidget.jsx new file mode 100644 index 0000000..f4b9916 --- /dev/null +++ b/_frontend/src/components/widgets/PieChartWidget.jsx @@ -0,0 +1,14 @@ +import { PieChart } from '../charts/PieChart.jsx' + +export const PieChartWidget = ({ title, ...chart }) => { + title = title || 'Grafico a torta' + + return ( + <> +
{title}
+
+ +
+ + ) +} diff --git a/_frontend/src/components/widgets/ScriptStatus.jsx b/_frontend/src/components/widgets/ScriptStatus.jsx new file mode 100644 index 0000000..6bd00c5 --- /dev/null +++ b/_frontend/src/components/widgets/ScriptStatus.jsx @@ -0,0 +1,43 @@ +import { useRemoteState } from '../../util.jsx' +import { Icon } from '../Icon.jsx' +import { ToastMessage, useToasts } from '../Toasts.jsx' + +export const ScriptStatus = ({ title, script }) => { + const [showToast] = useToasts() + const [output, err, refreshOutput] = useRemoteState( + `/api/monitor/status?script=${encodeURIComponent(script)}`, + '' + ) + + return ( + <> +
+
+
{title}
+
+ +
+
+
+
+ {err ? ( +

Errore "{err}"

+ ) : ( +
+                        {output}
+                    
+ )} +
+ + ) +} diff --git a/_frontend/src/util.jsx b/_frontend/src/util.jsx index 07f2034..99aa67d 100644 --- a/_frontend/src/util.jsx +++ b/_frontend/src/util.jsx @@ -137,3 +137,24 @@ const toCaseMap = { export function changeCase(from, to, s) { return toCaseMap[to](fromCaseMap[from](s)) } + +// +// Disk space conversions +// + +const byteUnits = { + K: 1024, + M: 1024 ** 2, + G: 1024 ** 3, + T: 1024 ** 4, +} + +export function byteSizeToString(size) { + let i = 0 + while (size >= 450) { + size /= 1024 + i++ + } + + return `${size.toFixed(2)}${Object.keys(byteUnits)[i]}B` +} diff --git a/_frontend/styles/main.scss b/_frontend/styles/main.scss index 4c7afef..4043935 100644 --- a/_frontend/styles/main.scss +++ b/_frontend/styles/main.scss @@ -528,6 +528,10 @@ p { } } +pre { + margin: 0; +} + code { font-size: 95%; } @@ -587,7 +591,8 @@ $heading-scale: 1.33; min-height: 10rem; - &.pie { + &.pie, + &.disk-usage { grid-row: span 2; & > .content { diff --git a/jobs/jobs.go b/jobs/jobs.go deleted file mode 100644 index 976b05a..0000000 --- a/jobs/jobs.go +++ /dev/null @@ -1,39 +0,0 @@ -package jobs - -import ( - "os" - "path" -) - -type Config struct { - ScriptsDir string `json:"scriptsDir"` -} - -type Service struct { - Config Config - - scriptPaths []string -} - -func New(config Config) *Service { - return &Service{ - Config: config, - } -} - -func (s *Service) LoadScripts() error { - entries, err := os.ReadDir(s.Config.ScriptsDir) - if err != nil { - return err - } - - s.scriptPaths = []string{} - - for _, entry := range entries { - s.scriptPaths = append(s.scriptPaths, - path.Join(s.Config.ScriptsDir, entry.Name()), - ) - } - - return nil -} diff --git a/main.go b/main.go index cfc2b31..c92703a 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "git.phc.dm.unipi.it/phc/storage/config" "git.phc.dm.unipi.it/phc/storage/database" + "git.phc.dm.unipi.it/phc/storage/monitor" "git.phc.dm.unipi.it/phc/storage/routes" "github.com/gofiber/fiber/v2" @@ -16,18 +17,46 @@ import ( ) func main() { + // Setup services + + db := database.NewJSON("database.local.json") + + monitorService := monitor.NewService(&monitor.Config{ + ScriptsDir: "./scripts", + }) + + if err := monitorService.LoadScripts(); err != nil { + panic(err) + } + + // The router wraps all application dependencies and provides "routing" methods router := &routes.Router{ - Database: database.NewJSON("database.local.json"), + Database: db, + Monitor: monitorService, } + // We use go-fiber as the HTTP framework app := fiber.New() - // Main middlewares + // Middlewares app.Use(logger.New()) app.Use(recover.New()) // Static files - app.Static("/", "/_frontend/dist") + app.Static("/", "./_frontend/dist") + + // Setting up dynamic "single page application" routes (all routes these to the same html file) + for _, route := range []string{ + "/", + "/api-keys", + "/admin", + "/buckets", + "/buckets/:bucket", + } { + app.Get(route, func(c *fiber.Ctx) error { + return c.SendFile("./_frontend/dist/index.html") + }) + } app.Route("/api", router.Api) diff --git a/monitor/service.go b/monitor/service.go new file mode 100644 index 0000000..100f187 --- /dev/null +++ b/monitor/service.go @@ -0,0 +1,58 @@ +package monitor + +import ( + "bytes" + "os" + "os/exec" + "path" + "strings" +) + +type Config struct { + ScriptsDir string `json:"scriptsDir"` +} + +type Service struct { + Config *Config + + scriptPaths map[string]string +} + +func NewService(config *Config) *Service { + return &Service{ + Config: config, + } +} + +func (s *Service) LoadScripts() error { + entries, err := os.ReadDir(s.Config.ScriptsDir) + if err != nil { + return err + } + + s.scriptPaths = map[string]string{} + + for _, entry := range entries { + newScriptPath := path.Join(s.Config.ScriptsDir, entry.Name()) + s.scriptPaths[entry.Name()] = newScriptPath + } + + return nil +} + +// TODO: Add caching +func (s *Service) GetLastOutput(command string) (string, error) { + args := strings.Fields(command) + + scriptPath := s.scriptPaths[args[0]] + + cmd := exec.Command(scriptPath, args[1:]...) + var b bytes.Buffer + cmd.Stdout = &b + + if err := cmd.Run(); err != nil { + return "", err + } + + return b.String(), nil +} diff --git a/routes/api.go b/routes/api.go index f8ec9e6..3e791c9 100644 --- a/routes/api.go +++ b/routes/api.go @@ -37,6 +37,13 @@ func (r *Router) Api(api fiber.Router) { return c.Next() } + // + // Setup "/api/monitor" routes + // + monitorRoute := api.Group("/monitor") + monitorRoute.Use(isAdminMiddleware) + r.ApiMonitor(monitorRoute) + api.Post("/login", func(c *fiber.Ctx) error { var form struct { Password string `form:"password"` @@ -67,22 +74,25 @@ func (r *Router) Api(api fiber.Router) { return c.JSON("ok") }) - api.Get("/current-user", func(c *fiber.Ctx) error { - if _, found := adminSessions[c.Cookies("sid")]; !found { - return c.JSON("anonymous") - } + api.Get("/current-user", + func(c *fiber.Ctx) error { + if _, found := adminSessions[c.Cookies("sid")]; !found { + return c.JSON("anonymous") + } - return c.JSON("admin") - }) + return c.JSON("admin") + }) - api.Get("/dashboard-state", func(c *fiber.Ctx) error { - state, err := r.Database.GetDashboardState() - if err != nil { - return err - } + api.Get("/dashboard-state", + isAdminMiddleware, + func(c *fiber.Ctx) error { + state, err := r.Database.GetDashboardState() + if err != nil { + return err + } - return c.JSON(state) - }) + return c.JSON(state) + }) api.Post("/dashboard-state", isAdminMiddleware, diff --git a/routes/monitor.go b/routes/monitor.go new file mode 100644 index 0000000..a34ea81 --- /dev/null +++ b/routes/monitor.go @@ -0,0 +1,27 @@ +package routes + +import ( + "fmt" + "log" + + "github.com/gofiber/fiber/v2" +) + +func (r *Router) ApiMonitor(api fiber.Router) { + // Respond to requests like + // - "/api/monitor/status?script=SCRIPT_NAME" where SCRIPT_NAME is the name of a file inside "./scripts" + api.Get("/status", func(c *fiber.Ctx) error { + if qScript := c.Query("script"); qScript != "" { + log.Printf("Script %q", qScript) + + output, err := r.Monitor.GetLastOutput(qScript) + if err != nil { + return err + } + + return c.JSON(output) + } + + return fmt.Errorf("no script, device or entity provided") + }) +} diff --git a/routes/router.go b/routes/router.go index 1fc5443..898aec3 100644 --- a/routes/router.go +++ b/routes/router.go @@ -1,7 +1,11 @@ package routes -import "git.phc.dm.unipi.it/phc/storage/database" +import ( + "git.phc.dm.unipi.it/phc/storage/database" + "git.phc.dm.unipi.it/phc/storage/monitor" +) type Router struct { Database database.Database + Monitor *monitor.Service } diff --git a/scripts/disk-usage b/scripts/disk-usage new file mode 100755 index 0000000..9cfd46b --- /dev/null +++ b/scripts/disk-usage @@ -0,0 +1,5 @@ +#!/bin/bash + +DEVICE="$1" + +df | grep "$DEVICE" | tr -s ' ' | cut -d' ' -f 2-3 | tr ' ' '\n' diff --git a/scripts/example-raid-status b/scripts/example-raid-status new file mode 100755 index 0000000..146c68c --- /dev/null +++ b/scripts/example-raid-status @@ -0,0 +1,7 @@ +#!/bin/bash + +echo 'active' +echo 'active' +echo 'active' +echo 'failing' +echo 'missing' \ No newline at end of file