From f2e2f45a1991d09244a6bcb88d533ea824737fa5 Mon Sep 17 00:00:00 2001 From: Simon Vieille Date: Tue, 24 Feb 2026 09:21:41 +0100 Subject: [PATCH] init --- .gitignore | 3 + cmd/main.go | 48 +++++++++++ go.mod | 15 ++++ go.sum | 24 ++++++ pkg/borgmatic/archive.go | 15 ++++ pkg/borgmatic/archive_limits.go | 5 ++ pkg/borgmatic/archive_stats.go | 8 ++ pkg/borgmatic/cache.go | 6 ++ pkg/borgmatic/cache_stats.go | 10 +++ pkg/borgmatic/encryption.go | 5 ++ pkg/borgmatic/info.go | 8 ++ pkg/borgmatic/infos.go | 3 + pkg/borgmatic/repository.go | 8 ++ pkg/borgmatic/time.go | 24 ++++++ pkg/database/database.go | 68 ++++++++++++++++ pkg/database/host.go | 39 +++++++++ pkg/database/model/host.go | 50 ++++++++++++ pkg/database/model/info.go | 28 +++++++ pkg/web/host.go | 137 ++++++++++++++++++++++++++++++++ pkg/web/hosts.go | 86 ++++++++++++++++++++ pkg/web/page.go | 94 ++++++++++++++++++++++ 21 files changed, 684 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/main.go create mode 100644 go.sum create mode 100644 pkg/borgmatic/archive.go create mode 100644 pkg/borgmatic/archive_limits.go create mode 100644 pkg/borgmatic/archive_stats.go create mode 100644 pkg/borgmatic/cache.go create mode 100644 pkg/borgmatic/cache_stats.go create mode 100644 pkg/borgmatic/encryption.go create mode 100644 pkg/borgmatic/info.go create mode 100644 pkg/borgmatic/infos.go create mode 100644 pkg/borgmatic/repository.go create mode 100644 pkg/borgmatic/time.go create mode 100644 pkg/database/database.go create mode 100644 pkg/database/host.go create mode 100644 pkg/database/model/host.go create mode 100644 pkg/database/model/info.go create mode 100644 pkg/web/host.go create mode 100644 pkg/web/hosts.go create mode 100644 pkg/web/page.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3fe14d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/borgmatic-monitor +/env.sh +/example.json diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..b7c9774 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" + "gitnet.fr/deblan/borgmatic-monitor/pkg/database" + "gitnet.fr/deblan/borgmatic-monitor/pkg/database/model" + "gitnet.fr/deblan/borgmatic-monitor/pkg/web" +) + +func main() { + database.Init() + + db := database.GetDb() + + db.AutoMigrate(model.Host{}) + db.AutoMigrate(model.Info{}) + + e := echo.New() + e.Use(middleware.RequestLogger()) + + e.GET("/", web.Hosts) + e.GET("/host/:id", web.Host) + + if err := e.Start(":1323"); err != nil { + e.Logger.Error("failed to start server", "error", err) + } + + // var hosts []model.Host + // + // db.Model(model.Host{}).Find(&hosts) + // + // for _, host := range hosts { + // fmt.Printf("%+v\n", host) + // } + + // if content, err := ioutil.ReadFile("example.json"); err == nil { + // var res borgmatic.Infos + // + // err := json.Unmarshal(content, &res) + // + // if err != nil { + // fmt.Printf("%+v\n", err) + // } + // + // fmt.Printf("%+v\n", res) + // } +} diff --git a/go.mod b/go.mod index 1cc579a..2fa9238 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,18 @@ module gitnet.fr/deblan/borgmatic-monitor go 1.25.1 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/echo/v5 v5.0.4 // indirect + github.com/spf13/cast v1.10.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect + maragu.dev/gomponents v1.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3fd9d35 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/labstack/echo/v5 v5.0.4 h1:ll3I/O8BifjMztj9dD1vx/peZQv8cR2CTUdQK6QxGGc= +github.com/labstack/echo/v5 v5.0.4/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc= +maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= diff --git a/pkg/borgmatic/archive.go b/pkg/borgmatic/archive.go new file mode 100644 index 0000000..1843fe3 --- /dev/null +++ b/pkg/borgmatic/archive.go @@ -0,0 +1,15 @@ +package borgmatic + +type Archive struct { + CommandLine []string `json:"command_line"` + Comment string `json:"comment"` + Duration float64 `json:"duration"` + Start *Time `json:"start"` + End *Time `json:"end"` + Hostname string `json:"hostname"` + Id string `json:"id"` + Limits ArchiveLimits `json:"limits"` + Stats ArchiveStats `json:"stats"` + Name string `json:"name"` + Username string `json:"username"` +} diff --git a/pkg/borgmatic/archive_limits.go b/pkg/borgmatic/archive_limits.go new file mode 100644 index 0000000..d083ec3 --- /dev/null +++ b/pkg/borgmatic/archive_limits.go @@ -0,0 +1,5 @@ +package borgmatic + +type ArchiveLimits struct { + MaxArchiveSize float64 `json:"max_archive_size"` +} diff --git a/pkg/borgmatic/archive_stats.go b/pkg/borgmatic/archive_stats.go new file mode 100644 index 0000000..39fab04 --- /dev/null +++ b/pkg/borgmatic/archive_stats.go @@ -0,0 +1,8 @@ +package borgmatic + +type ArchiveStats struct { + CompressedSize int64 `json:"compressed_size"` + DeduplicatedSize int64 `json:"deduplicated_size"` + Nfiles int64 `json:"nfiles"` + OriginalSize int64 `json:"original_size"` +} diff --git a/pkg/borgmatic/cache.go b/pkg/borgmatic/cache.go new file mode 100644 index 0000000..a412943 --- /dev/null +++ b/pkg/borgmatic/cache.go @@ -0,0 +1,6 @@ +package borgmatic + +type Cache struct { + Path string `json:"path"` + Stats CacheStats `json:"stats"` +} diff --git a/pkg/borgmatic/cache_stats.go b/pkg/borgmatic/cache_stats.go new file mode 100644 index 0000000..1972655 --- /dev/null +++ b/pkg/borgmatic/cache_stats.go @@ -0,0 +1,10 @@ +package borgmatic + +type CacheStats struct { + TotalChunks float64 `json:"total_chunks"` + TotalCsize float64 `json:"total_csize"` + TotalSize float64 `json:"total_size"` + TotalUniqueChunks float64 `json:"total_unique_chunks"` + UniqueCsize float64 `json:"unique_csize"` + UniqueSize float64 `json:"unique_size"` +} diff --git a/pkg/borgmatic/encryption.go b/pkg/borgmatic/encryption.go new file mode 100644 index 0000000..8ae6985 --- /dev/null +++ b/pkg/borgmatic/encryption.go @@ -0,0 +1,5 @@ +package borgmatic + +type Encryption struct { + Mode string `json:"mode"` +} diff --git a/pkg/borgmatic/info.go b/pkg/borgmatic/info.go new file mode 100644 index 0000000..dcba3d8 --- /dev/null +++ b/pkg/borgmatic/info.go @@ -0,0 +1,8 @@ +package borgmatic + +type Info struct { + Archives []Archive `json:"archives"` + Cache Cache `json:"cache"` + Encryption Encryption `json:"encryption"` + Repository Repository `json:"repository"` +} diff --git a/pkg/borgmatic/infos.go b/pkg/borgmatic/infos.go new file mode 100644 index 0000000..4f44229 --- /dev/null +++ b/pkg/borgmatic/infos.go @@ -0,0 +1,3 @@ +package borgmatic + +type Infos []Info diff --git a/pkg/borgmatic/repository.go b/pkg/borgmatic/repository.go new file mode 100644 index 0000000..bcfd9d3 --- /dev/null +++ b/pkg/borgmatic/repository.go @@ -0,0 +1,8 @@ +package borgmatic + +type Repository struct { + Id string `json:"id"` + LastModified *Time `json:"last_modified"` + Location string `json:"location"` + Label string `json:"label"` +} diff --git a/pkg/borgmatic/time.go b/pkg/borgmatic/time.go new file mode 100644 index 0000000..f322255 --- /dev/null +++ b/pkg/borgmatic/time.go @@ -0,0 +1,24 @@ +package borgmatic + +import ( + "strings" + "time" +) + +type Time struct { + time.Time +} + +func (t *Time) UnmarshalJSON(b []byte) (err error) { + s := strings.Trim(string(b), "\"") + + if s == "null" { + t.Time = time.Time{} + + return + } + + t.Time, err = time.Parse("2006-01-02T15:04:05.000000", s) + + return +} diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 0000000..ffa299e --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,68 @@ +package database + +import ( + "fmt" + "os" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +var ( + db *gorm.DB + config mysql.Config +) + +func Init() { + config = mysql.Config{ + DSN: fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=True&loc=Local", + fallback(os.Getenv("MYSQL_USERNAME"), "root"), + fallback(os.Getenv("MYSQL_PASSWORD"), "root"), + fallback(os.Getenv("MYSQL_HOST"), "127.0.0.1"), + fallback(os.Getenv("MYSQL_PORT"), "3306"), + fallback(os.Getenv("MYSQL_DATABASE"), "app"), + ), + DefaultStringSize: 256, + DisableDatetimePrecision: true, + DontSupportRenameIndex: true, + DontSupportRenameColumn: true, + SkipInitializeWithVersion: false, + } +} + +func GetDb() *gorm.DB { + var err error + + if db == nil { + db, err = gorm.Open(mysql.New(config)) + + if err != nil { + panic(err) + } + } + + gdb, err := db.DB() + + if err != nil { + db = nil + + return GetDb() + } + + if err = gdb.Ping(); err != nil { + db = nil + + return GetDb() + } + + return db +} + +func fallback(value string, fb string) string { + if value == "" { + return fb + } + + return value +} diff --git a/pkg/database/host.go b/pkg/database/host.go new file mode 100644 index 0000000..4396d5d --- /dev/null +++ b/pkg/database/host.go @@ -0,0 +1,39 @@ +package database + +import ( + "gitnet.fr/deblan/borgmatic-monitor/pkg/database/model" + "gorm.io/gorm" +) + +func Hosts() []model.Host { + var hosts []model.Host + + GetDb(). + Model(model.Host{}). + Preload("Infos", func(db *gorm.DB) *gorm.DB { + return db.Order("infos.id DESC") + }). + Order("name ASC"). + Find(&hosts) + + return hosts +} + +func Host(id int) *model.Host { + var host model.Host + + GetDb(). + Model(model.Host{}). + Preload("Infos", func(db *gorm.DB) *gorm.DB { + return db.Order("infos.id DESC") + }). + Order("name ASC"). + Where("id = ?", id). + First(&host) + + if host.ID > 0 { + return &host + } + + return nil +} diff --git a/pkg/database/model/host.go b/pkg/database/model/host.go new file mode 100644 index 0000000..91a165d --- /dev/null +++ b/pkg/database/model/host.go @@ -0,0 +1,50 @@ +package model + +import ( + "time" + + "gitnet.fr/deblan/borgmatic-monitor/pkg/borgmatic" + "gorm.io/gorm" +) + +type Host struct { + gorm.Model + + Name string `gorm:"not null"` + Token string `gorm:"not null"` + Infos []Info `gorm:"one2many:info;not null"` +} + +func (h *Host) ArchivesCount() int { + c := 0 + + for _, item := range h.Infos { + if infos := *item.Infos(); infos != nil { + for _, info := range *item.Infos() { + c += len(info.Archives) + } + } + } + + return c + +} + +func (h *Host) LastArchive() *borgmatic.Archive { + var last *borgmatic.Archive + var lastDate *time.Time + + for _, item := range h.Infos { + if infos := *item.Infos(); infos != nil { + for _, info := range infos { + for _, a := range info.Archives { + if lastDate == nil || a.End.After(*lastDate) { + last = &a + } + } + } + } + } + + return last +} diff --git a/pkg/database/model/info.go b/pkg/database/model/info.go new file mode 100644 index 0000000..bfddba0 --- /dev/null +++ b/pkg/database/model/info.go @@ -0,0 +1,28 @@ +package model + +import ( + "encoding/json" + + "gitnet.fr/deblan/borgmatic-monitor/pkg/borgmatic" + "gorm.io/gorm" +) + +type Info struct { + gorm.Model + + HostID uint + Host Host `gorm:"many2one:info"` + Data string `gorm:"type:text;not null"` +} + +func (i *Info) Infos() *borgmatic.Infos { + var infos borgmatic.Infos + + err := json.Unmarshal([]byte(i.Data), &infos) + + if err != nil { + return nil + } + + return &infos +} diff --git a/pkg/web/host.go b/pkg/web/host.go new file mode 100644 index 0000000..ef661f6 --- /dev/null +++ b/pkg/web/host.go @@ -0,0 +1,137 @@ +package web + +import ( + "fmt" + "net/http" + + "github.com/dustin/go-humanize" + "github.com/labstack/echo/v5" + "github.com/spf13/cast" + "gitnet.fr/deblan/borgmatic-monitor/pkg/borgmatic" + "gitnet.fr/deblan/borgmatic-monitor/pkg/database" + + . "maragu.dev/gomponents" + + // . "maragu.dev/gomponents/components" + . "maragu.dev/gomponents/html" +) + +func Host(c *echo.Context) error { + id := cast.ToInt(c.Param("id")) + + if id == 0 { + return c.HTML(http.StatusNotFound, "Not found.") + } + + host := database.Host(id) + + if host == nil { + return c.HTML(http.StatusNotFound, "Not found.") + } + + var nodes []Node + + if len(host.Infos) == 0 { + nodes = append( + nodes, + Div( + Class("text-muted"), + Text("Nothing yet!"), + ), + ) + } else { + + for _, item := range host.Infos { + if infos := *item.Infos(); infos != nil { + for i := len(infos) - 1; i >= 0; i-- { + info := infos[i] + + card := Div( + Class("col-12"), + Div( + Class("card mb-3"), + Div( + Class("card-header"), + Text(info.Repository.LastModified.Format("2006-01-02 15:04:05")), + ), + Div( + Class("card-body"), + Div( + I(Class("ri-price-tag-3-line me-1")), + Text(info.Repository.Label), + ), + Div( + I(Class("ri-map-pin-line me-1")), + Text(info.Repository.Location), + ), + Div( + Class("mb-3"), + I(Class("ri-lock-line me-1")), + Text(info.Encryption.Mode), + ), + Div( + Map(info.Archives, func(archive borgmatic.Archive) Node { + return Div( + Class("row"), + Div( + Class("col-12 col-md-4"), + I(Class("ri-store-3-fill me-1")), + Text(archive.Name), + ), + Div( + Class("text-end col-12 col-md-4"), + Text(fmt.Sprintf("%2.fs", archive.Duration)), + I(Class("ri-time-fill mx-2")), + Text(fmt.Sprintf("%d", archive.Stats.Nfiles)), + I(Class("ri-file-fill ms-2")), + ), + Div( + Class("text-end col-12 col-md-4"), + Div( + Class("row"), + Div( + Class("col-4 text-secondary"), + Text(humanize.Bytes(uint64(archive.Stats.OriginalSize))), + ), + Div( + Class("col-4 text-primary"), + Text(humanize.Bytes(uint64(archive.Stats.DeduplicatedSize))), + ), + Div( + Class("col-4 text-success"), + Text(humanize.Bytes(uint64(archive.Stats.CompressedSize))), + I(Class("ri-file-zip-fill ms-2")), + ), + ), + ), + ) + }), + ), + ), + ), + ) + + nodes = append(nodes, card) + + // for _, archive := range info.Archives { + // + // } + } + } + } + } + + return c.HTML(http.StatusOK, page( + fmt.Sprintf("host_%d", host.ID), + host.Name, + Div( + Class("container-fluid"), + Div( + Class("row"), + Map(nodes, func(node Node) Node { + return node + }), + ), + ), + )) +} diff --git a/pkg/web/hosts.go b/pkg/web/hosts.go new file mode 100644 index 0000000..1adb785 --- /dev/null +++ b/pkg/web/hosts.go @@ -0,0 +1,86 @@ +package web + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v5" + "gitnet.fr/deblan/borgmatic-monitor/pkg/database" + . "maragu.dev/gomponents" + + // . "maragu.dev/gomponents/components" + . "maragu.dev/gomponents/html" +) + +func Hosts(c *echo.Context) error { + var nodes []Node + + for _, host := range database.Hosts() { + lastArchive := host.LastArchive() + + var body Node + + if lastArchive != nil { + body = Div( + Div( + Class("fw-bold"), + I(Class("ri-archive-stack-line me-1")), + Text(fmt.Sprintf( + "%d archive(s)", + host.ArchivesCount(), + )), + ), + Div( + I(Class("ri-calendar-line me-1")), + Text(fmt.Sprintf( + "Last update on %s", + lastArchive.End.Format("2006-01-02 15:04:05"), + )), + ), + Div( + Class("mt-3"), + A( + Class("btn btn-primary"), + Href(fmt.Sprintf("/host/%d", host.ID)), + Text("Show"), + ), + ), + ) + } else { + body = Div( + Class("text-danger"), + I(Class("ri-error-warning-line me-1")), + Text("Nothing yet!"), + ) + } + + card := Div( + Class("col-6"), + Div( + Class("card mb-3"), + Div( + Class("card-header"), + Text(host.Name), + ), + Div( + Class("card-body"), + body, + ), + ), + ) + + nodes = append(nodes, card) + } + + return c.HTML(http.StatusOK, page( + "home", + "Hosts", + Div( + Class("container-fluid"), + Div( + Class("row"), + Group(nodes), + ), + ), + )) +} diff --git a/pkg/web/page.go b/pkg/web/page.go new file mode 100644 index 0000000..ff39a6b --- /dev/null +++ b/pkg/web/page.go @@ -0,0 +1,94 @@ +package web + +import ( + "bytes" + "fmt" + + "gitnet.fr/deblan/borgmatic-monitor/pkg/database" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/components" + . "maragu.dev/gomponents/html" +) + +func page(currentNode string, title string, children ...Node) string { + page := HTML5(HTML5Props{ + Title: title, + Language: "en", + Head: []Node{ + Link( + Href("https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"), + Rel("stylesheet"), + Integrity("sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"), + CrossOrigin("anonymous"), + ), + Link( + Href("https://cdn.jsdelivr.net/npm/remixicon@4.9.0/fonts/remixicon.css"), + Rel("stylesheet"), + ), + }, + Body: []Node{ + Main( + Class("d-flex flex-nowrap"), + menu(currentNode), + Div( + Class("flex-fill p-3"), + Style("max-height: 100vh; overflow: auto"), + Group(children), + ), + ), + Script( + Src("https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"), + Integrity("sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"), + ), + }, + }) + + var buf bytes.Buffer + + page.Render(&buf) + + return buf.String() +} + +func menu(currentNode string) Node { + items := [][3]string{ + [3]string{"home", "/", "All"}, + } + + for _, host := range database.Hosts() { + items = append( + items, + [3]string{ + fmt.Sprintf("host_%d", host.ID), + fmt.Sprintf("/host/%d", host.ID), + host.Name, + }, + ) + } + + return Div( + Class("d-flex flex-column flex-shrink-0 p-3 text-bg-dark"), + Style("width: 280px; height: 100vh"), + Ul( + Class("nav nav-pills flex-column mb-auto"), + Map(items, func(item [3]string) Node { + class := "nav-link" + + if currentNode == item[0] { + class += " active" + } else { + class += " text-white" + } + + return Li( + Class("nav-item"), + A( + Class(class), + Href(item[1]), + Text(item[2]), + ), + ) + }), + ), + ) +}