@yuuki824/kanshi 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -1,8 +1,8 @@
1
1
  # Copy to .env to override. Every value here is the built-in default.
2
2
 
3
- # Bind address. The Tailscale IP of this host keeps the dashboard off the LAN
4
- # and off the public interface; 0.0.0.0 exposes it everywhere.
5
- KANSHI_HOST=100.73.243.14
3
+ # Bind address. Localhost keeps the dashboard private. Use this host's
4
+ # Tailscale IP to share it on a tailnet; 0.0.0.0 exposes it everywhere.
5
+ KANSHI_HOST=127.0.0.1
6
6
  KANSHI_PORT=8100
7
7
 
8
8
  # Live metrics
@@ -11,7 +11,7 @@ KANSHI_DOCKER_CONCURRENCY=8
11
11
  KANSHI_IDLE_TIMEOUT=30
12
12
 
13
13
  # Storage treemap
14
- KANSHI_STORAGE_ROOTS=/mnt/data=/mnt/data,/=/hostfs
14
+ KANSHI_STORAGE_ROOTS=/=/hostfs,/mnt/data=/mnt/data
15
15
  KANSHI_STORAGE_INTERVAL=1800
16
16
  # Paths to skip entirely, comma separated (container-side paths).
17
17
  # The biggest win is /hostfs/var/lib/docker — ~100k overlay2 files.
package/Dockerfile CHANGED
@@ -1,27 +1,32 @@
1
- FROM python:3.12-slim
1
+ # Build stage. The module has no third-party dependencies, so there is nothing
2
+ # to download and nothing to vendor — go.mod is copied on its own only so the
3
+ # layer cache survives edits to the source.
4
+ FROM golang:1.25-alpine AS build
2
5
 
3
- ENV PYTHONUNBUFFERED=1 \
4
- PYTHONDONTWRITEBYTECODE=1 \
5
- PIP_NO_CACHE_DIR=1 \
6
- PIP_DISABLE_PIP_VERSION_CHECK=1
6
+ WORKDIR /src
7
+ COPY go.mod ./
8
+ COPY main.go ./
9
+ COPY internal ./internal
10
+ COPY web ./web
7
11
 
8
- WORKDIR /opt/kanshi
12
+ # CGO off because nothing here needs libc: /proc is read as plain files and the
13
+ # Docker socket is a plain unix socket. That is what makes a scratch image
14
+ # possible at all.
15
+ ENV CGO_ENABLED=0
16
+ RUN go vet ./... && \
17
+ go build -trimpath -ldflags="-s -w" -o /kanshi .
9
18
 
10
- COPY requirements.txt ./
11
- RUN pip install --no-cache-dir -r requirements.txt
19
+ # Runtime stage. The web assets are embedded in the binary, so the image is one
20
+ # static file and nothing else — no shell, no package manager, no CVE surface.
21
+ FROM scratch
12
22
 
13
- COPY app ./app
14
- COPY web ./web
23
+ COPY --from=build /kanshi /kanshi
15
24
 
16
25
  EXPOSE 8100
17
26
 
18
- # Probe the address uvicorn actually bound to with network_mode: host that
19
- # may be the Tailscale IP rather than loopback.
20
- HEALTHCHECK --interval=60s --timeout=5s --start-period=15s --retries=3 \
21
- CMD python -c "import os,urllib.request,sys; \
22
- h=os.environ.get('KANSHI_HOST','127.0.0.1'); h='127.0.0.1' if h=='0.0.0.0' else h; \
23
- sys.exit(0 if urllib.request.urlopen('http://%s:%s/healthz' % (h, os.environ.get('KANSHI_PORT','8100')), timeout=4).status==200 else 1)"
27
+ # The probe is the binary itself: a scratch image has no curl to call, and this
28
+ # way it reads KANSHI_HOST/KANSHI_PORT from the same code that binds them.
29
+ HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=3 \
30
+ CMD ["/kanshi", "-healthcheck"]
24
31
 
25
- CMD ["sh", "-c", "exec python -m uvicorn app.main:app \
26
- --host ${KANSHI_HOST:-0.0.0.0} --port ${KANSHI_PORT:-8100} \
27
- --no-access-log --timeout-keep-alive 65"]
32
+ ENTRYPOINT ["/kanshi"]
package/README.md CHANGED
@@ -1,12 +1,65 @@
1
+ <div align="center">
2
+
1
3
  # kanshi
2
4
 
3
- A one-page, mobile-first glance at this homeserver: live CPU and RAM, a
4
- Filelight-style storage treemap, and `docker stats` for every container — no
5
- historical storage, no alerting, no external services.
5
+ **A one-page, mobile-first glance at this homeserver.**
6
+
7
+ Live CPU and RAM, a Filelight-style storage treemap, and `docker stats` for
8
+ every container — no historical storage, no alerting, no external services.
9
+
10
+ [![Go](https://img.shields.io/badge/Go-1.25-00add8?logo=go&logoColor=white)](https://go.dev/)
11
+ [![Image](https://img.shields.io/badge/image-9.6MB%20scratch-0b7285)](https://hub.docker.com/_/scratch)
12
+ [![Docker](https://img.shields.io/badge/Docker-Compose-2496ed?logo=docker&logoColor=white)](https://docs.docker.com/compose/)
13
+ [![npm](https://img.shields.io/badge/npx-%40yuuki824%2Fkanshi-cb3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/@yuuki824/kanshi)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## Screenshots
20
+
21
+ <table>
22
+ <tr>
23
+ <td width="60%">
24
+
25
+ **Vitals & storage** — live CPU/RAM meters and the storage treemap
26
+ <img src="docs/screenshots/dashboard-vitals.png" alt="Processor, memory and storage map cards" />
27
+
28
+ </td>
29
+ <td width="40%">
30
+
31
+ **Mobile** — the same cards, stacked for a phone screen
32
+ <img src="docs/screenshots/dashboard-mobile.png" alt="Dashboard on a mobile viewport" />
33
+
34
+ </td>
35
+ </tr>
36
+ </table>
37
+
38
+ By default it is reachable only from the local machine:
6
39
 
7
- Reachable from any device on the tailnet:
40
+ http://localhost:8100
8
41
 
9
- http://homeserver.tail7ec1d9.ts.net:8100
42
+ ## Features
43
+
44
+ - 📊 **Processor** — hero utilisation %, per-core bars, load average, temperature, host net/disk throughput
45
+ - 🧠 **Memory & volumes** — RAM, swap, and one meter per storage root
46
+ - 🗺 **Storage map** — squarified treemap you can tap to drill into, with a table twin below; depth-bounded so it stays fast on large filesystems
47
+ - 🐳 **Containers** — per-container CPU%, memory, network rate and health straight from the Docker Engine API
48
+ - ⚡ **One SSE connection** — the server pushes every tick over `/api/stream`; nothing polls, nothing needs a manual reload
49
+ - 😴 **Idles to near-zero** — the poller and the Docker socket both go quiet after `KANSHI_IDLE_TIMEOUT` with nobody watching
50
+ - 🪶 **9.6 MB image, ~8 MB resident** — one static Go binary on `scratch`: no interpreter, no shell, no package manager
51
+ - 🔒 **Tailscale-friendly** — binds to `127.0.0.1` by default; point it at a Tailscale IP to share it on a tailnet instead of the open LAN
52
+
53
+ ## Tech Stack
54
+
55
+ | Layer | Choice |
56
+ |---|---|
57
+ | Backend | Go 1.25, standard library only — zero third-party dependencies |
58
+ | Host metrics | `/proc` and `/sys` parsed directly; `statfs(2)` for volumes |
59
+ | Live updates | Server-Sent Events (`/api/stream`) |
60
+ | Frontend | Vanilla JS, hand-rolled SVG treemap — no build step, embedded in the binary |
61
+ | Container metrics | Docker Engine API (one-shot stats, not the streaming daemon default) |
62
+ | Packaging | `scratch` image via multi-stage build, Docker Compose, `npx` launcher |
10
63
 
11
64
  ## Install with npx
12
65
 
@@ -14,29 +67,70 @@ Reachable from any device on the tailnet:
14
67
  repository includes a small npm launcher which starts the included Docker
15
68
  Compose app, so Docker (with the Compose v2 plugin) is still required.
16
69
 
17
- From a directory containing your `.env` file:
70
+ From any directory, run:
18
71
 
19
72
  ```sh
20
73
  npx @yuuki824/kanshi
21
74
  ```
22
75
 
23
- The command is equivalent to `docker compose up -d --build`. It reads a `.env`
24
- file from the directory where you run it, so first copy the template and set a
25
- safe `KANSHI_HOST` for the host being monitored:
76
+ The first run downloads the package, builds the Docker image, and starts a
77
+ container named `kanshi`. Open <http://localhost:8100> once it says the
78
+ container started. It may take a minute for the initial health check to pass.
79
+
80
+ The command is equivalent to `docker compose up -d --build`. To run without
81
+ the confirmation prompt:
26
82
 
27
83
  ```sh
28
- cp .env.example .env
29
- # edit .env, then:
30
- npx @yuuki824/kanshi
84
+ npx --yes @yuuki824/kanshi
85
+ ```
86
+
87
+ ### Manage the container
88
+
89
+ ```sh
90
+ # Stop it without removing it.
91
+ docker stop kanshi
92
+
93
+ # Start the stopped container again.
94
+ docker start kanshi
95
+
96
+ # Follow application logs.
97
+ docker logs -f kanshi
98
+
99
+ # Remove the current container, for example before installing an update.
100
+ docker rm -f kanshi
101
+
102
+ # Download the newest package version and start it again.
103
+ npx @yuuki824/kanshi@latest
31
104
  ```
32
105
 
33
- Pass Docker Compose commands after the package name, for example:
106
+ You can also pass Docker Compose commands after the package name:
34
107
 
35
108
  ```sh
36
109
  npx @yuuki824/kanshi logs -f
37
110
  npx @yuuki824/kanshi down
38
111
  ```
39
112
 
113
+ ### Network access
114
+
115
+ Kanshi listens on `127.0.0.1:8100` by default, so it is private to the host.
116
+ To share it over Tailscale, use the machine's Tailscale IP:
117
+
118
+ ```sh
119
+ KANSHI_HOST="$(tailscale ip -4)" npx @yuuki824/kanshi
120
+ ```
121
+
122
+ Do not use `KANSHI_HOST=0.0.0.0` unless the machine is protected by an
123
+ authenticated reverse proxy: the dashboard can read Docker information.
124
+
125
+ ### Troubleshooting
126
+
127
+ Check whether the container is running and inspect a restart or startup error:
128
+
129
+ ```sh
130
+ docker ps --filter name=kanshi
131
+ docker logs --tail=100 kanshi
132
+ ```
133
+
40
134
  To publish the launcher, use:
41
135
 
42
136
  ```sh
@@ -58,13 +152,13 @@ docker compose up -d --build
58
152
  Copy `.env.example` to `.env` to override anything. Every setting has a
59
153
  conservative default; the file documents each one.
60
154
 
61
- ## What it shows
155
+ ## API
62
156
 
63
157
  | Card | Source | Refresh |
64
158
  |---|---|---|
65
- | Processor — hero %, per-core bars, load, temp, host net/disk throughput | `psutil` over `/proc` | every `KANSHI_POLL_INTERVAL` (5s) |
66
- | Memory & volumes — RAM, swap, one meter per storage root | `psutil` + `statvfs` | same tick |
67
- | Storage map — squarified treemap, tap to drill, table twin below | cached `os.scandir` walk | every `KANSHI_STORAGE_INTERVAL` (30m), or the Rescan button |
159
+ | Processor — hero %, per-core bars, load, temp, host net/disk throughput | `/proc/stat`, `/proc/net/dev`, `/proc/diskstats`, `/sys` hwmon | every `KANSHI_POLL_INTERVAL` (5s) |
160
+ | Memory & volumes — RAM, swap, one meter per storage root | `/proc/meminfo` + `statfs(2)` | same tick |
161
+ | Storage map — squarified treemap, tap to drill, table twin below | cached `getdents`+`lstat` walk | every `KANSHI_STORAGE_INTERVAL` (30m), or the Rescan button |
68
162
  | Containers — CPU%, memory, network rates, health | Docker Engine API | same tick |
69
163
 
70
164
  The browser holds **one SSE connection** (`/api/stream`) and the server pushes
@@ -91,24 +185,16 @@ fully traversed and counted; their bytes roll up into the nearest kept ancestor.
91
185
  Sizes come from `st_blocks` (so they match `du`, not apparent size) and
92
186
  hardlinked files are counted once.
93
187
 
94
- **The walk runs at `nice 10`** in a worker thread. A full pass over `/` takes
95
- ~47s, and the live cards keep their exact 5s cadence throughout.
188
+ **The walk runs at `nice 10`** on a locked, dedicated OS thread Linux applies
189
+ `setpriority(PRIO_PROCESS)` per thread, and the runtime retires that thread when
190
+ the walk ends rather than handing a niced thread back to the poller. A full pass
191
+ over `/` takes ~76s on a cold cache, and the live cards keep their exact 5s
192
+ cadence throughout.
96
193
 
97
194
  **Unreadable directories are reported, not hidden.** If the walk cannot enter a
98
195
  directory it is counted and the storage card says so — a silently truncated tree
99
196
  that under-reports by 300 GB is worse than an obviously incomplete one.
100
197
 
101
- ## Access
102
-
103
- The port is bound to the **Tailscale interface only** (`KANSHI_HOST`), so the
104
- dashboard is not exposed on the LAN or the public interface and needs no auth
105
- layer of its own. Set `KANSHI_HOST=0.0.0.0` to expose it on the LAN — but note
106
- that anything that can reach this app can reach the Docker socket through it, so
107
- add an auth proxy first if you do.
108
-
109
- If Tailscale is down when Docker starts, the bind fails and `restart:
110
- unless-stopped` retries until `tailscale0` is back.
111
-
112
198
  ## Permissions
113
199
 
114
200
  Runs as root with `cap_drop: ALL` plus **`DAC_READ_SEARCH`** — read and traverse
@@ -118,12 +204,29 @@ filesystem by ~330 GB. The rootfs is read-only and every host mount is `:ro`.
118
204
 
119
205
  ## About kanshi's own memory number
120
206
 
121
- The dashboard will show kanshi using far more memory than you'd expect for a
122
- 10 MB Python process. That figure is mostly **reclaimable kernel dentry cache**
123
- charged to its cgroup — an unavoidable side effect of `stat`-ing ~150k files
124
- during a walk. Actual anonymous memory is ~27 MB; `mem_limit` acts as a ceiling
125
- on the cache and the kernel reclaims it under pressure. The number matches what
126
- `docker stats` reports for any container, which is the point.
207
+ The dashboard will show kanshi using far more memory than the process actually
208
+ has. That figure is mostly **reclaimable kernel dentry cache** charged to its
209
+ cgroup — an unavoidable side effect of `lstat`-ing ~150k files during a walk.
210
+ Measured anonymous memory is **~8 MB at rest and ~20 MB at the peak of a full
211
+ walk**; `mem_limit` acts as a ceiling on the cache and the kernel reclaims it
212
+ under pressure. `GOMEMLIMIT` sits below `mem_limit` so an unusually large
213
+ filesystem makes the collector work harder instead of getting the container
214
+ OOM-killed. The number matches what `docker stats` reports for any container,
215
+ which is the point.
216
+
217
+ ## Building
218
+
219
+ The only build dependency is Docker; the image compiles the binary itself.
220
+
221
+ ```sh
222
+ docker compose up -d --build
223
+ ```
224
+
225
+ With a local Go toolchain (1.22+), `go build .` and `go vet ./...` work from the
226
+ repository root with no module downloads — there are no third-party imports. The
227
+ frontend is embedded with `//go:embed`, so a rebuild is needed after editing
228
+ anything under `web/`; set `KANSHI_WEB_DIR=./web` to serve it from disk instead
229
+ while iterating.
127
230
 
128
231
  ## Tuning
129
232
 
@@ -10,10 +10,9 @@ services:
10
10
  network_mode: host
11
11
 
12
12
  environment:
13
- # Bind to the Tailscale IP only not the LAN, not the public interface.
14
- # Reachable at http://homeserver.tail7ec1d9.ts.net:8100 from any device on
15
- # the tailnet. Set to 0.0.0.0 to also expose it on the LAN.
16
- KANSHI_HOST: "${KANSHI_HOST:-100.73.243.14}"
13
+ # Localhost is safe for a package install. Set this to a Tailscale IP to
14
+ # share it on a tailnet, or 0.0.0.0 only behind an authenticated proxy.
15
+ KANSHI_HOST: "${KANSHI_HOST:-127.0.0.1}"
17
16
  KANSHI_PORT: "${KANSHI_PORT:-8100}"
18
17
 
19
18
  # Live poll cadence. Each tick is one /containers/json plus one /stats per
@@ -24,13 +23,20 @@ services:
24
23
  KANSHI_IDLE_TIMEOUT: "${KANSHI_IDLE_TIMEOUT:-30}"
25
24
 
26
25
  # Storage walk: label=path pairs. Cached, never recomputed per refresh.
27
- KANSHI_STORAGE_ROOTS: "${KANSHI_STORAGE_ROOTS:-/mnt/data=/mnt/data,/=/hostfs}"
26
+ KANSHI_STORAGE_ROOTS: "${KANSHI_STORAGE_ROOTS:-/=/hostfs,/mnt/data=/mnt/data}"
28
27
  KANSHI_STORAGE_INTERVAL: "${KANSHI_STORAGE_INTERVAL:-1800}"
29
28
  # Container-side paths to skip. /hostfs/var/lib/docker is ~48 GB across
30
29
  # ~100k overlay2 files and dominates the walk time — uncomment to trim it.
31
30
  KANSHI_STORAGE_EXCLUDE: "${KANSHI_STORAGE_EXCLUDE:-}"
32
31
  KANSHI_TREE_DEPTH: "${KANSHI_TREE_DEPTH:-4}"
33
32
 
33
+ # Give the GC a budget below mem_limit so a walk on an unusually large
34
+ # filesystem degrades into more collection rather than an OOM kill.
35
+ GOMEMLIMIT: "80MiB"
36
+ # Two Ps is plenty for a 5s poll loop and one walk, and it keeps the Go
37
+ # scheduler from spreading across all four cores of a busy box.
38
+ GOMAXPROCS: "2"
39
+
34
40
  volumes:
35
41
  # Read-only on the socket is a mount flag, not an API restriction — it is
36
42
  # the Tailscale-only bind above that keeps this out of reach.
@@ -50,9 +56,12 @@ services:
50
56
  - no-new-privileges:true
51
57
 
52
58
  # Stays out of the way of the other ~28 containers on this 4-core box.
53
- # Steady state is ~1% of one core; the ceiling is headroom for the walk.
59
+ # Steady state is ~0.03% of one core; the ceiling is headroom for the walk.
54
60
  cpus: "1.0"
55
- mem_limit: 256m
61
+ # Measured peak during a full walk of a 2 TB host is ~20 MiB of heap, and
62
+ # ~8 MiB at rest. The rest of this is reclaimable page cache the walk pulls
63
+ # in, which the kernel drops again the moment anything else wants it.
64
+ mem_limit: 96m
56
65
 
57
66
  logging:
58
67
  driver: json-file
package/go.mod ADDED
@@ -0,0 +1,3 @@
1
+ module github.com/yuuki824/kanshi
2
+
3
+ go 1.22
@@ -0,0 +1,140 @@
1
+ // Package config holds runtime configuration, all via environment variables.
2
+ //
3
+ // Defaults are deliberately conservative: this box has 4 cores and ~28 other
4
+ // containers, so Kanshi should be invisible in `docker stats`.
5
+ package config
6
+
7
+ import (
8
+ "os"
9
+ "path"
10
+ "strconv"
11
+ "strings"
12
+ "time"
13
+ )
14
+
15
+ // Config is read once at startup and never mutated, so it needs no locking.
16
+ type Config struct {
17
+ // How often the live poller samples vitals + container stats.
18
+ PollInterval time.Duration
19
+ // Stop polling entirely once no browser has been connected for this long.
20
+ // Nobody is looking, so there is no reason to keep waking the Docker daemon.
21
+ IdleTimeout time.Duration
22
+
23
+ // Max concurrent /stats requests against the Docker socket per tick.
24
+ DockerConcurrency int
25
+ DockerSocket string
26
+
27
+ // Storage walk. Roots are "label=path" or just "path".
28
+ StorageRoots []string
29
+ StorageInterval time.Duration
30
+ // Absolute container-side paths to skip entirely. Their bytes vanish from
31
+ // the totals, so only exclude things you truly don't want counted.
32
+ StorageExclude []string
33
+ StorageMinRescan time.Duration
34
+
35
+ // Tree pruning, to keep the JSON the phone downloads small.
36
+ TreeDepth int
37
+ // Children smaller than this fraction of their parent are folded into an
38
+ // aggregate node rather than shipped individually.
39
+ TreeMinFraction float64
40
+ TreeMaxChildren int
41
+
42
+ Host string
43
+ Port int
44
+
45
+ // Serve web assets from this directory instead of the ones baked into the
46
+ // binary. Only useful when iterating on the frontend.
47
+ WebDir string
48
+ }
49
+
50
+ // Root is one labelled entry from StorageRoots.
51
+ type Root struct {
52
+ Label string
53
+ Path string
54
+ }
55
+
56
+ // Roots expands the "label=path" entries. A bare path is labelled with its
57
+ // own basename.
58
+ func (c Config) Roots() []Root {
59
+ out := make([]Root, 0, len(c.StorageRoots))
60
+ for _, entry := range c.StorageRoots {
61
+ label, p, found := strings.Cut(entry, "=")
62
+ if !found || p == "" {
63
+ p = label
64
+ if base := path.Base(strings.TrimRight(label, "/")); base != "" && base != "." {
65
+ label = base
66
+ }
67
+ }
68
+ out = append(out, Root{Label: label, Path: p})
69
+ }
70
+ return out
71
+ }
72
+
73
+ // Load reads the environment. Unparseable values fall back to the default
74
+ // rather than refusing to start — a typo in one knob should not take the
75
+ // dashboard down.
76
+ func Load() Config {
77
+ return Config{
78
+ PollInterval: envSeconds("KANSHI_POLL_INTERVAL", 5*time.Second),
79
+ IdleTimeout: envSeconds("KANSHI_IDLE_TIMEOUT", 30*time.Second),
80
+ DockerConcurrency: envInt("KANSHI_DOCKER_CONCURRENCY", 8),
81
+ DockerSocket: envString("KANSHI_DOCKER_SOCKET", "/var/run/docker.sock"),
82
+ StorageRoots: envList("KANSHI_STORAGE_ROOTS", "/=/hostfs,/mnt/data=/mnt/data"),
83
+ StorageInterval: envSeconds("KANSHI_STORAGE_INTERVAL", 1800*time.Second),
84
+ StorageExclude: envList("KANSHI_STORAGE_EXCLUDE", ""),
85
+ StorageMinRescan: envSeconds("KANSHI_STORAGE_MIN_RESCAN", 30*time.Second),
86
+ TreeDepth: envInt("KANSHI_TREE_DEPTH", 4),
87
+ TreeMinFraction: envFloat("KANSHI_TREE_MIN_FRACTION", 0.005),
88
+ TreeMaxChildren: envInt("KANSHI_TREE_MAX_CHILDREN", 24),
89
+ Host: envString("KANSHI_HOST", "0.0.0.0"),
90
+ Port: envInt("KANSHI_PORT", 8100),
91
+ WebDir: envString("KANSHI_WEB_DIR", ""),
92
+ }
93
+ }
94
+
95
+ func envString(name, def string) string {
96
+ if v := os.Getenv(name); v != "" {
97
+ return v
98
+ }
99
+ return def
100
+ }
101
+
102
+ func envInt(name string, def int) int {
103
+ v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
104
+ if err != nil {
105
+ return def
106
+ }
107
+ return v
108
+ }
109
+
110
+ func envFloat(name string, def float64) float64 {
111
+ v, err := strconv.ParseFloat(strings.TrimSpace(os.Getenv(name)), 64)
112
+ if err != nil {
113
+ return def
114
+ }
115
+ return v
116
+ }
117
+
118
+ // envSeconds accepts a bare number of seconds, matching the Compose file's
119
+ // plain integers.
120
+ func envSeconds(name string, def time.Duration) time.Duration {
121
+ v, err := strconv.ParseFloat(strings.TrimSpace(os.Getenv(name)), 64)
122
+ if err != nil || v <= 0 {
123
+ return def
124
+ }
125
+ return time.Duration(v * float64(time.Second))
126
+ }
127
+
128
+ func envList(name, def string) []string {
129
+ raw := os.Getenv(name)
130
+ if raw == "" {
131
+ raw = def
132
+ }
133
+ var out []string
134
+ for _, part := range strings.Split(raw, ",") {
135
+ if p := strings.TrimSpace(part); p != "" {
136
+ out = append(out, p)
137
+ }
138
+ }
139
+ return out
140
+ }