4 Commits

Author SHA1 Message Date
f4f0d86d1f svg-maker: rendered example in the README
All checks were successful
release-tools / build-release (push) Successful in 4m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 08:54:40 +02:00
4bae7bfb72 ci/tasks/readme: register fleet
All checks were successful
release-tools / build-release (push) Successful in 2m27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 01:02:53 +02:00
ea06442d44 Merge dev/fleet: fleet v1 2026-08-07 01:02:06 +02:00
184378ae57 fleet: one-shot homelab health snapshot (containers, disks, units)
status across configured hosts via read-only commands (claude-docker
wrapper on the Pi5, local docker/systemctl here). Stable table +
findings list, exit 0/2/1, CI job containers skipped, unreachable
hosts become findings. Parsers unit-tested; smoked against the real
fleet (19 genuine findings). Spec: doc/tool-parity.md 4.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 01:02:06 +02:00
12 changed files with 497 additions and 6 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- name: Detektera ändrade tools - name: Detektera ändrade tools
id: changed id: changed
run: | run: |
TOOLS="pixel-sprite-maker mesh-tool bitmap-font-maker sfx-maker hitbox-tool notifyr svg-maker waitfor cronr giteactl" TOOLS="pixel-sprite-maker mesh-tool bitmap-font-maker sfx-maker hitbox-tool notifyr svg-maker waitfor cronr giteactl fleet"
BEFORE="${{ github.event.before }}" BEFORE="${{ github.event.before }}"
CHANGED="" CHANGED=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ] \ if [ "${{ github.event_name }}" = "workflow_dispatch" ] \
@@ -64,6 +64,7 @@ jobs:
waitfor) BIN=waitfor ;; waitfor) BIN=waitfor ;;
cronr) BIN=cronr ;; cronr) BIN=cronr ;;
giteactl) BIN=giteactl ;; giteactl) BIN=giteactl ;;
fleet) BIN=fleet ;;
*) echo "okänt tool $t"; exit 1 ;; *) echo "okänt tool $t"; exit 1 ;;
esac esac
echo "=== $t ($BIN) ===" echo "=== $t ($BIN) ==="
@@ -98,6 +99,7 @@ jobs:
waitfor) BIN=waitfor ;; waitfor) BIN=waitfor ;;
cronr) BIN=cronr ;; cronr) BIN=cronr ;;
giteactl) BIN=giteactl ;; giteactl) BIN=giteactl ;;
fleet) BIN=fleet ;;
esac esac
TAG="$t-latest" TAG="$t-latest"
BODY="$t (binär: $BIN) - rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)." BODY="$t (binär: $BIN) - rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)."

27
.vscode/tasks.json vendored
View File

@@ -241,6 +241,30 @@
"$go" "$go"
] ]
}, },
{
"label": "build fleet",
"type": "shell",
"command": "go build -trimpath -ldflags '-s -w' -o build/fleet .",
"options": {
"cwd": "${workspaceFolder}/fleet"
},
"group": "build",
"problemMatcher": [
"$go"
]
},
{
"label": "test fleet",
"type": "shell",
"command": "go test ./...",
"options": {
"cwd": "${workspaceFolder}/fleet"
},
"group": "test",
"problemMatcher": [
"$go"
]
},
{ {
"label": "build", "label": "build",
"dependsOn": [ "dependsOn": [
@@ -253,7 +277,8 @@
"build svg-maker", "build svg-maker",
"build waitfor", "build waitfor",
"build cronr", "build cronr",
"build giteactl" "build giteactl",
"build fleet"
], ],
"dependsOrder": "parallel", "dependsOrder": "parallel",
"group": { "group": {

View File

@@ -18,6 +18,7 @@ agent understands the result without opening an image viewer.
| [`waitfor/`](waitfor/) | `waitfor` | Blocks until a shell condition holds (exit 0 + optional regex match) — replaces hand-rolled poll loops. `--then` hook composes with notifyr. | | [`waitfor/`](waitfor/) | `waitfor` | Blocks until a shell condition holds (exit 0 + optional regex match) — replaces hand-rolled poll loops. `--then` hook composes with notifyr. |
| [`cronr/`](cronr/) | `cronr` | Recurring/one-shot jobs as systemd user timers that survive session exit and reboot. add/list/run/logs/rm, schedules validated by systemd-analyze. | | [`cronr/`](cronr/) | `cronr` | Recurring/one-shot jobs as systemd user timers that survive session exit and reboot. add/list/run/logs/rm, schedules validated by systemd-analyze. |
| [`giteactl/`](giteactl/) | `giteactl` | Gitea Actions runs, job logs (API/web/ssh+zstd fallback), `wait` (block until green) and `wait-quiet` (the "serialize heavy builds" rule), release assets. | | [`giteactl/`](giteactl/) | `giteactl` | Gitea Actions runs, job logs (API/web/ssh+zstd fallback), `wait` (block until green) and `wait-quiet` (the "serialize heavy builds" rule), release assets. |
| [`fleet/`](fleet/) | `fleet` | One-shot homelab health snapshot: container states, disk fill and failed systemd units across hosts, via read-only commands. Exit 2 = findings. |
Each tool has its own folder, its own README with the full format/CLI Each tool has its own folder, its own README with the full format/CLI
reference, its own tests and its own dev branch (`dev/<tool>`). reference, its own tests and its own dev branch (`dev/<tool>`).
@@ -27,8 +28,8 @@ reference, its own tests and its own dev branch (`dev/<tool>`).
[`doc/tool-parity.md`](doc/tool-parity.md) compares the Google agent's [`doc/tool-parity.md`](doc/tool-parity.md) compares the Google agent's
built-in tools with Claude Code's, and specs the CLI tools that close built-in tools with Claude Code's, and specs the CLI tools that close
the gaps so any agent gets the same capabilities via its shell tool. the gaps so any agent gets the same capabilities via its shell tool.
Built so far: `notifyr`, `waitfor`, `cronr`, `giteactl`, `svgc` Built so far: `notifyr`, `waitfor`, `cronr`, `giteactl`, `svgc`,
(2026-08-07). Still on the list: `fleet`, `envaudit`, `reghelper`, `fleet` (2026-08-07). Still on the list: `envaudit`, `reghelper`,
`pagepub`, `nbcell`, `wtreectl`, `fanout`. Build order and rationale `pagepub`, `nbcell`, `wtreectl`, `fanout`. Build order and rationale
live there and in [`doc/plan.md`](doc/plan.md). Agy finds the tools live there and in [`doc/plan.md`](doc/plan.md). Agy finds the tools
through the `agent-tools` skill in `~/.gemini/config/skills/`. through the `agent-tools` skill in `~/.gemini/config/skills/`.

View File

@@ -31,7 +31,7 @@ master/main. Only tools whose folders changed get rebuilt.
send/read), `waitfor` (block on condition), `cronr` (systemd user send/read), `waitfor` (block on condition), `cronr` (systemd user
timers), `giteactl` (CI runs/logs/wait/wait-quiet/releases) and timers), `giteactl` (CI runs/logs/wait/wait-quiet/releases) and
`svgc` (`svg-maker/`: .svgd → SVG for agent-helm, Björns direkta `svgc` (`svg-maker/`: .svgd → SVG for agent-helm, Björns direkta
önskemål). Alla smoke-testade mot riktig infra; agy-integration önskemål) samt `fleet` (hälsosnapshot). Alla smoke-testade mot riktig infra; agy-integration
via `agent-tools`-skill + AGENTS.md i `~/.gemini/config/`. via `agent-tools`-skill + AGENTS.md i `~/.gemini/config/`.
## Roadmap / expansion ideas (not agreed yet) ## Roadmap / expansion ideas (not agreed yet)

View File

@@ -292,7 +292,7 @@ binaries), same philosophy — don't duplicate those here.
1.`notifyr` — smallest, everything else composes with it, ntfy already runs. *(built 2026-08-07)* 1.`notifyr` — smallest, everything else composes with it, ntfy already runs. *(built 2026-08-07)*
2.`giteactl` — removes the biggest daily friction (CI logs + build serialization). *(built 2026-08-07; private repos need an API token in the config)* 2.`giteactl` — removes the biggest daily friction (CI logs + build serialization). *(built 2026-08-07; private repos need an API token in the config)*
3.`waitfor` + `cronr` — turns both agents into unattended operators. *(built 2026-08-07)* 3.`waitfor` + `cronr` — turns both agents into unattended operators. *(built 2026-08-07)*
4. `fleet` — replaces the hand-rolled health loops in every runbook. 4. `fleet` — replaces the hand-rolled health loops in every runbook. *(built 2026-08-07)*
5. `envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl` — as needed. 5. `envaudit`, `reghelper`, `pagepub`, `nbcell`, `wtreectl` — as needed.
6. `fanout` — only if a concrete multi-agent need shows up. 6. `fanout` — only if a concrete multi-agent need shows up.

60
fleet/README.md Normal file
View File

@@ -0,0 +1,60 @@
# fleet — one-shot homelab health snapshot
Replaces the hand-rolled `ssh pi5-claude sudo claude-docker ps …`
loops from every runbook with a single command that checks **all
hosts**: container states, disk fill and failed systemd units.
Read-only by construction — it only runs `ps`/`df`/`systemctl` through
the read-only claude-docker wrapper, so it needs no new permissions.
From [`doc/tool-parity.md`](../doc/tool-parity.md) §4.2.
## Usage
```
fleet status [--host NAME] # all hosts, or one
```
```
== pi5 ==
containers: 54/58 healthy
disk / 81% used, 82G free
disk /srv/storage1 58% used, 1.2T free
failed units: 0 system, 0 user
...
19 finding(s):
pi5: container dozzle: exited (Exited (0) 5 months ago)
brasse-linux01: failed unit: cpupower-gui.service
```
| Exit | Meaning |
|------|---------|
| 0 | everything healthy |
| 2 | findings printed (unhealthy/exited containers, disk ≥ warn %, failed units) |
| 1 | error (unknown host, config problem) |
Output is a stable text table — an agent can diff it between runs.
Unreachable hosts become findings, not crashes. Gitea Actions'
transient job containers are skipped.
## Config
`~/.config/fleet/config.json`, created on first run (`FLEET_CONFIG`
overrides):
```json
{
"disk_warn_pct": 85,
"hosts": [
{"name": "pi5", "ssh": "pi5-claude", "docker_cmd": "sudo claude-docker", "df_targets": "/ /srv/storage1", "user_units": false},
{"name": "brasse-linux01", "ssh": "", "docker_cmd": "docker", "df_targets": "/ /home", "user_units": true}
]
}
```
`ssh: ""` means run locally. Add hosts by appending entries.
## Build & test
```bash
go test ./...
go build -o build/fleet .
```

118
fleet/check/check.go Normal file
View File

@@ -0,0 +1,118 @@
// Package check parses the health data fleet collects (docker ps,
// df, systemctl --failed) into stable rows and findings an agent can
// diff between runs.
package check
import (
"fmt"
"strconv"
"strings"
)
// Container is one row of docker ps output.
type Container struct {
Name string
State string // running, exited, restarting, ...
Status string // "Up 2 hours (healthy)", "Exited (1) 3 days ago"
Image string
}
// Healthy reports whether the container looks fine: running, and not
// flagged unhealthy.
func (c Container) Healthy() bool {
return c.State == "running" && !strings.Contains(c.Status, "unhealthy")
}
// ParseDockerPS parses `docker ps -a --format
// "{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}"`. CI job
// containers (Gitea Actions workers) are transient and skipped.
func ParseDockerPS(out string) []Container {
var rows []Container
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 4 {
continue
}
if strings.HasPrefix(parts[0], "GITEA-ACTIONS-TASK-") {
continue
}
rows = append(rows, Container{Name: parts[0], State: parts[1], Status: parts[2], Image: parts[3]})
}
return rows
}
// Mount is one row of df output.
type Mount struct {
Target string
UsedPct int
Avail string
}
// ParseDF parses `df -h --output=target,pcent,avail`.
func ParseDF(out string) []Mount {
var rows []Mount
for i, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if i == 0 || len(fields) < 3 { // header
continue
}
pct, err := strconv.Atoi(strings.TrimSuffix(fields[1], "%"))
if err != nil {
continue
}
rows = append(rows, Mount{Target: fields[0], UsedPct: pct, Avail: fields[2]})
}
return rows
}
// ParseFailedUnits parses `systemctl --failed --no-legend --plain`
// into unit names.
func ParseFailedUnits(out string) []string {
var units []string
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "● "))
if len(fields) == 0 {
continue
}
u := fields[0]
if strings.Contains(u, ".") {
units = append(units, u)
}
}
return units
}
// Finding is one thing worth attention.
type Finding struct {
Host string
Text string
}
func (f Finding) String() string { return f.Host + ": " + f.Text }
// Evaluate turns parsed data into findings. diskWarnPct is the fill
// grade that counts as a problem.
func Evaluate(host string, containers []Container, mounts []Mount, failedSys, failedUser []string, diskWarnPct int) []Finding {
var out []Finding
for _, c := range containers {
if !c.Healthy() {
out = append(out, Finding{host, fmt.Sprintf("container %s: %s (%s)", c.Name, c.State, c.Status)})
}
}
for _, m := range mounts {
if m.UsedPct >= diskWarnPct {
out = append(out, Finding{host, fmt.Sprintf("disk %s at %d%% (%s left)", m.Target, m.UsedPct, m.Avail)})
}
}
for _, u := range failedSys {
out = append(out, Finding{host, "failed unit: " + u})
}
for _, u := range failedUser {
out = append(out, Finding{host, "failed user unit: " + u})
}
return out
}

78
fleet/check/check_test.go Normal file
View File

@@ -0,0 +1,78 @@
package check
import (
"strings"
"testing"
)
const psOut = `GITEA-ACTIONS-TASK-120-WORKFLOW-x running Up 2 minutes catthehacker/ubuntu:act-latest
helm-hub running Up 58 minutes localhost:5000/helmd:latest
gitea-d running Up 27 hours (healthy) gitea/gitea:1.24
deluge exited Exited (1) 3 days ago linuxserver/deluge
kuma running Up 4 days (unhealthy) louislam/uptime-kuma:1`
func TestParseDockerPS(t *testing.T) {
rows := ParseDockerPS(psOut)
if len(rows) != 4 {
t.Fatalf("got %d rows, want 4 (CI worker skipped)", len(rows))
}
if rows[0].Name != "helm-hub" || !rows[0].Healthy() {
t.Errorf("helm-hub should be healthy: %+v", rows[0])
}
for _, r := range rows {
switch r.Name {
case "deluge":
if r.Healthy() {
t.Error("exited container marked healthy")
}
case "kuma":
if r.Healthy() {
t.Error("unhealthy container marked healthy")
}
}
}
}
const dfOut = `Mounted on Use% Avail
/ 81% 82G
/srv/storage1 58% 1.2T`
func TestParseDF(t *testing.T) {
rows := ParseDF(dfOut)
if len(rows) != 2 || rows[0].Target != "/" || rows[0].UsedPct != 81 || rows[1].Avail != "1.2T" {
t.Errorf("df parse wrong: %+v", rows)
}
}
func TestParseFailedUnits(t *testing.T) {
units := ParseFailedUnits("● backup.service loaded failed failed Nightly backup\nfoo.timer loaded failed failed X\n\n")
if len(units) != 2 || units[0] != "backup.service" || units[1] != "foo.timer" {
t.Errorf("failed units: %v", units)
}
if len(ParseFailedUnits("")) != 0 {
t.Error("empty output should give no units")
}
}
func TestEvaluate(t *testing.T) {
findings := Evaluate("pi5",
ParseDockerPS(psOut),
ParseDF(dfOut),
[]string{"backup.service"}, nil, 80)
var text []string
for _, f := range findings {
text = append(text, f.String())
}
joined := strings.Join(text, "\n")
for _, want := range []string{"deluge", "kuma", "disk / at 81%", "backup.service"} {
if !strings.Contains(joined, want) {
t.Errorf("findings missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "storage1") {
t.Error("58% disk should not be a finding at threshold 80")
}
if len(findings) != 4 {
t.Errorf("got %d findings, want 4", len(findings))
}
}

3
fleet/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-tools/fleet
go 1.24

191
fleet/main.go Normal file
View File

@@ -0,0 +1,191 @@
// fleet takes a one-shot health snapshot of the homelab: container
// states, disk fill and failed systemd units per host — the loop every
// runbook used to hand-roll, done once and properly. Read-only by
// construction. See doc/tool-parity.md §4.2.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.brasse-pc.eu/brasse/agent-tools/fleet/check"
)
var version = "dev"
const usage = `fleet - one-shot homelab health snapshot
Usage:
fleet status [--host NAME] [--all] containers, disks, failed units
fleet version
Exit codes: 0 = everything healthy, 2 = findings, 1 = error.
Hosts come from ~/.config/fleet/config.json (created on first run;
FLEET_CONFIG overrides). Default: the Pi5 through the read-only
claude-docker wrapper, and this workstation locally. All commands are
read-only (ps/df/systemctl status) — mutations stay in the supervised
tmux flow.
`
type host struct {
Name string `json:"name"`
SSH string `json:"ssh"` // ssh host alias, "" = run locally
DockerCmd string `json:"docker_cmd"` // e.g. "sudo claude-docker" or "docker"
DFTargets string `json:"df_targets"` // space-separated mount points
UserUnits bool `json:"user_units"` // also check systemctl --user --failed
}
type config struct {
DiskWarnPct int `json:"disk_warn_pct"`
Hosts []host `json:"hosts"`
}
func defaultConfig() *config {
return &config{
DiskWarnPct: 85,
Hosts: []host{
{Name: "pi5", SSH: "pi5-claude", DockerCmd: "sudo claude-docker", DFTargets: "/ /srv/storage1", UserUnits: false},
{Name: "brasse-linux01", SSH: "", DockerCmd: "docker", DFTargets: "/ /home", UserUnits: true},
},
}
}
func configPath() string {
if p := os.Getenv("FLEET_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
return filepath.Join(dir, "fleet", "config.json")
}
func loadConfig() *config {
cfg := defaultConfig()
path := configPath()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err == nil {
out, _ := json.MarshalIndent(cfg, "", " ")
os.WriteFile(path, append(out, '\n'), 0o600)
fmt.Fprintf(os.Stderr, "fleet: created %s\n", path)
}
return cfg
}
if err == nil {
json.Unmarshal(data, cfg)
}
return cfg
}
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "status":
cmdStatus(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("fleet", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'fleet help'", os.Args[1])
}
}
// run executes a read-only command locally or over ssh, with a
// timeout so one dead host cannot hang the snapshot.
func run(h host, cmdline string) (string, error) {
var c *exec.Cmd
if h.SSH != "" {
c = exec.Command("ssh", "-o", "ConnectTimeout=10", h.SSH, cmdline)
} else {
c = exec.Command("sh", "-c", cmdline)
}
done := make(chan struct{})
var out []byte
var err error
go func() { out, err = c.CombinedOutput(); close(done) }()
select {
case <-done:
case <-time.After(45 * time.Second):
c.Process.Kill()
return "", fmt.Errorf("timeout")
}
return string(out), err
}
func cmdStatus(args []string) {
fs := flag.NewFlagSet("status", flag.ExitOnError)
hostFilter := fs.String("host", "", "only this host (default: all)")
fs.Parse(args)
cfg := loadConfig()
var allFindings []check.Finding
checked := 0
for _, h := range cfg.Hosts {
if *hostFilter != "" && h.Name != *hostFilter {
continue
}
checked++
fmt.Printf("== %s ==\n", h.Name)
psOut, err := run(h, h.DockerCmd+` ps -a --format '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Image}}'`)
if err != nil {
fmt.Printf(" docker: UNREACHABLE (%v)\n", err)
allFindings = append(allFindings, check.Finding{Host: h.Name, Text: "docker unreachable: " + strings.TrimSpace(psOut)})
}
containers := check.ParseDockerPS(psOut)
up := 0
for _, c := range containers {
if c.Healthy() {
up++
}
}
fmt.Printf(" containers: %d/%d healthy\n", up, len(containers))
dfOut, _ := run(h, "df -h --output=target,pcent,avail "+h.DFTargets)
mounts := check.ParseDF(dfOut)
for _, m := range mounts {
fmt.Printf(" disk %-16s %3d%% used, %s free\n", m.Target, m.UsedPct, m.Avail)
}
sysOut, _ := run(h, "systemctl --failed --no-legend --plain")
failedSys := check.ParseFailedUnits(sysOut)
var failedUser []string
if h.UserUnits {
userOut, _ := run(h, "systemctl --user --failed --no-legend --plain")
failedUser = check.ParseFailedUnits(userOut)
}
fmt.Printf(" failed units: %d system, %d user\n", len(failedSys), len(failedUser))
allFindings = append(allFindings, check.Evaluate(h.Name, containers, mounts, failedSys, failedUser, cfg.DiskWarnPct)...)
}
if checked == 0 {
die("no host named %q in the config", *hostFilter)
}
if len(allFindings) == 0 {
fmt.Println("\nall healthy")
return
}
fmt.Printf("\n%d finding(s):\n", len(allFindings))
for _, f := range allFindings {
fmt.Println(" " + f.String())
}
os.Exit(2)
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "fleet: "+format+"\n", args...)
os.Exit(1)
}

View File

@@ -30,6 +30,11 @@ canvas** — so mistakes surface as text even without the preview.
Errors carry line numbers (`card.svgd: line 7: "four" is not a Errors carry line numbers (`card.svgd: line 7: "four" is not a
number — rect needs: rect <x> <y> <w> <h>`). number — rect needs: rect <x> <y> <w> <h>`).
The example above, rendered (this exact file is
[`examples/gauge.svgd`](examples/gauge.svgd) → [`examples/gauge.svg`](examples/gauge.svg)):
![gauge example](examples/gauge.svg)
## The .svgd format ## The .svgd format
One element per line, `#` comments, `key=value` attributes last: One element per line, `#` comments, `key=value` attributes last:

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 120" width="240" height="120">
<rect width="100%" height="100%" fill="#12161f"/>
<rect x="8" y="8" width="224" height="104" fill="none" rx="10" stroke="#262d3a" stroke-width="2"/>
<circle cx="40" cy="60" r="22" fill="none" stroke="#3fca7c" stroke-width="6"/>
<path d="M30,60 L38,68 L52,50" fill="none" stroke="#3fca7c" stroke-linecap="round" stroke-width="5"/>
<text x="76" y="54" fill="#8a93a5" font-size="13">backups</text>
<text x="76" y="76" fill="white" font-size="17" font-weight="bold">all green</text>
</svg>

After

Width:  |  Height:  |  Size: 586 B