This commit is contained in:
Simon Vieille 2026-02-24 09:21:41 +01:00
commit f2e2f45a19
Signed by: deblan
GPG key ID: 579388D585F70417
21 changed files with 684 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/borgmatic-monitor
/env.sh
/example.json

48
cmd/main.go Normal file
View file

@ -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)
// }
}

15
go.mod
View file

@ -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
)

24
go.sum Normal file
View file

@ -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=

15
pkg/borgmatic/archive.go Normal file
View file

@ -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"`
}

View file

@ -0,0 +1,5 @@
package borgmatic
type ArchiveLimits struct {
MaxArchiveSize float64 `json:"max_archive_size"`
}

View file

@ -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"`
}

6
pkg/borgmatic/cache.go Normal file
View file

@ -0,0 +1,6 @@
package borgmatic
type Cache struct {
Path string `json:"path"`
Stats CacheStats `json:"stats"`
}

View file

@ -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"`
}

View file

@ -0,0 +1,5 @@
package borgmatic
type Encryption struct {
Mode string `json:"mode"`
}

8
pkg/borgmatic/info.go Normal file
View file

@ -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"`
}

3
pkg/borgmatic/infos.go Normal file
View file

@ -0,0 +1,3 @@
package borgmatic
type Infos []Info

View file

@ -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"`
}

24
pkg/borgmatic/time.go Normal file
View file

@ -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
}

68
pkg/database/database.go Normal file
View file

@ -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
}

39
pkg/database/host.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

137
pkg/web/host.go Normal file
View file

@ -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
}),
),
),
))
}

86
pkg/web/hosts.go Normal file
View file

@ -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),
),
),
))
}

94
pkg/web/page.go Normal file
View file

@ -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]),
),
)
}),
),
)
}