Є така прикольна штука, як self-hosted home pages.
Колись побачив їх десь на Reddit, зберіг в закладки, і ось тепер, як в мене є всяка self-hosted тема з NAS (див. FreeBSD: Home NAS, part 1), Grafana і іншими корисними в роботі і побуті речами – то подумав, що було б непогано зробити і собі таку дашборду.
З тих, що в мене збережені, і вони, здається, найбільш популярні – це gethomepage/homepage та glanceapp/glance.
Але Homepage якась більш важка, з купою компонентів – фронтенд, бекенд, якісь рендери, а Glance – простіша, і при цьому, в принципі, має все, що я хотів побачити – хоча місцями це робиться через костилі 🙂
Власне – налаштування Glance.
Робити поки буду локально на ноутбуці з Docker Compose, пізніше перенесу конфіг або на FreeBSD/NAS чи на Raspberry PI з Debian.
Зміст
Налаштування Glance
Див. документацію Pages & Columns.
Основні компоненти – це Pages, Columns та Widgets:
- pages: власне, сторінки – можна мати кілька вкладок
- columns: кожна page розбивається на кілька колонок
- widgets: і в кожній колонці є набір віджетів
Віджети теж можна групувати і робити вкладки – далі побачимо на прикладі Reddit.
Для погратись – легко запускається з Docker, див. Installation.
Окремих тем нема – все налаштовується через стилі, наприклад:
... theme: background-color: 225 14 15 primary-color: 157 47 65 contrast-multiplier: 1.1 ...
Але є готові стилі – див. Themes.
Окрім дефолтних віджетів є community widgets, наприклад я там собі взяв NextDNS Stats.
Мої Pages та Widgets
Коротко – приклад того, як це все можна налаштувати.
Так як в мене це все хоститься локально і кудись в GitHub я конфіг зберігати не буду – то всякі токени записав прямо в конфіг, але взагалі для них можна використати змінні оточення – див. Other ways of providing tokens/passwords/secrets.
Перша сторінка – Home з трьома колонками:
Clock, Weather, Calendar
Час, погода і календар:
...
pages:
- name: Home
columns:
# ---------- LEFT ----------
- size: small
widgets:
- type: clock
hour-format: 24h
timezones:
- timezone: Asia/Bangkok
label: Chiang Mai
- timezone: America/New_York
label: New York
- type: weather
location: Kyiv
units: metric
- type: calendar
...
В clock віджеті додав ще дві зони – бо в США у нас частина розробників, а в Chiang Mai – розробник один, але часто з ним спілкуюсь і постійно згадую яка в нього зараз година.
Центрально колонка – з типом full, і для кожної page треба мати як мінімум одну колонку з full.
Search
Тут віджет search:
...
# ---------- CENTER ----------
- size: full
widgets:
- type: search
search-engine: duckduckgo
new-tab: true
autofocus: true
bangs:
- title: YouTube
shortcut: "!yt"
url: https://www.youtube.com/results?search_query={QUERY}
...
Bookmarks
Bookmarks – основні для швидкого доступу, розбиті по категоріям:
...
- type: bookmarks
groups:
- title: Local
color: 120 70 50
target: _self
links:
- title: MikroTik Gateway
url: http://192.168.0.1/webfig/#IP:DHCP_Server.Leases
...
- title: AI
color: 260 50 70
target: _self
links:
- title: ChatGPT
url: https://chat.openai.com/chat
...
- title: RTFM
color: 200 50 50
target: _self
links:
- title: RTFM Main
url: https://rtfm.co.ua/
...
Для вибору color можна скористатись colorpicker.dev: перша цифра – сам колір, друга – saturation (насиченість), третя – lightness (яскравість).
Group для Reddit
Далі приклад групування з Group – зробив собі окремі вкладки для різних сабредітів, але двома окремими групами – умовний “Reddit Ukraine” і “Reddit IT”:
...
- type: group
widgets:
- type: reddit
subreddit: finance_ukr
show-thumbnails: true
- type: reddit
subreddit: durka_ukr
show-thumbnails: true
- type: group
widgets:
- type: reddit
subreddit: aws
show-thumbnails: false
- type: reddit
subreddit: archlinux
show-thumbnails: false
...
Split Column для новин
Наступний віджет – Split Column, де більше стисло новини і якісь цікаві матеріали:
...
- type: split-column
max-columns: 2
widgets:
- type: lobsters
sort-by: hot
limit: 15
collapse-after: 5
- type: rss
title: News
style: vertical-list
feeds:
- url: https://aws.amazon.com/blogs/aws/feed/
title: AWS Blogs
- url: https://skeletor.org.ua/?feed=rss2
title: Skeletor
...
Ну і більш цікава частина – справа, інформація по статусу сервісів.
GitHub Releases
Віджет releases – останні релізи в GitHub:
...
- size: small
widgets:
- type: releases
token: github_pat_11A***1jF
repositories:
- VictoriaMetrics/VictoriaMetrics
- pdf/zfs_exporter
- tess1o/go-ecoflow-exporter
...
Мені за zfs_exporter і go-ecoflow-exporter є сенс слідкувати, бо вони в мене деплояться вручну, див. FreeBSD: Home NAS, part 11 – extended моніторинг з додатковими експортерами.
Хоча, звісно, ніхто не відміняє можливість просто підписатись на апдейти в самому GitHub 🙂
Custom API для NextDNS
Приклад кастомного віджета custom-api – інформація по моєму NextDNS:
...
- type: custom-api
title: NextDNS Analytics
title-url: https://api.nextdns.io/profiles/***/analytics/status
cache: 1h
url: https://api.nextdns.io/profiles/***/analytics/status
headers:
X-Api-Key: 3f8***457
template: |
{{ if eq .Response.StatusCode 200 }}
<div style="display: flex; justify-content: space-between;">
{{ $total := 0.0 }}
{{ $blocked := 0.0 }}
{{ range .JSON.Array "data" }}
{{ $total = add $total (.Int "queries" | toFloat) }}
{{ if eq (.String "status") "blocked" }}
{{ $blocked = add $blocked (.Int "queries" | toFloat) }}
...
<div style="text-align: center; color: var(--color-negative);">
Error: {{ .Response.StatusCode }} - {{ .Response.Status }}
</div>
{{ end }}
...
І далі ще буде приклад з власним міні-API сервісом.
Monitor – статуси HTTP-сервісів
Віджет monitor – прикольна штука для відображення статусу сервісів, робить простий GET-запит на вказаний URL, ну і працює (принаймні поки що) тільки з HTTP/S:
...
- type: monitor
cache: 1m
title: Services
sites:
- title: RTFM
url: https://rtfm.co.ua
icon: /assets/rtfm-icon-2.png
- title: MikroTik RB4011
url: http://192.168.0.1
icon: sh:mikrotik-light
- title: Grafana
url: http://nas.setevoy:3000
icon: sh:grafana
- title: Jellyfin
url: http://nas.setevoy:8096
icon: sh:jellyfin
...
На MikroTik RB4011 (див. MikroTik: перше знайомство та Getting Started), наприклад, з локальної мережі доступний web-інтерфейс, тому через нього можна перевіряти статус.
Аби підключити іконки – шукаємо дефолтні на, наприклад, selfh.st або dashboardicons.com.
Або можна задати кастомні іконки – додаємо файли в каталог /assets і включаємо його в конфігу Glance:
... server: host: 0.0.0.0 port: 8080 assets-path: /app/assets ...
Server Stats – CPU, RAM, Disk
Цікава штука server-stats, хоча вона ще в beta:
...
- type: server-stats
servers:
- type: local
name: setevoy-work
...
Потребує додаткового сервісу – glanceapp/agent, я його поки що просто додав до docker-compose.yaml:
services:
glance:
container_name: glance
image: glanceapp/glance
restart: unless-stopped
volumes:
- ./config:/app/config
- ./assets:/app/assets
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- 8080:8080
env_file: .env
glance-agent:
container_name: glance-agent
image: glanceapp/agent
ports:
- 27973:27973
Docker containers
І останній тут – запущені Docker-контейнери, бо іноді забуваю, що щось запущено:
...
- type: docker-containers
hide-by-default: false
running-only: true
...
(про Uptime Kuma теж напишу)
Вся сторінка Home вийшла поки що такою:
Custom API для FreeBSD/NAS
Ну і з цікавих рішень: хочеться з FreeBSD виводити якусь цікаву інформацію.
Active SSH connections
Спершу приклад “активні SSH-юзери” – перевіряємо хто підключений і звідки, виводимо тільки унікальні адреси:
root@setevoy-nas:~ # who | awk '$6 ~ /^\(/ {print $1, $6}' | sort -u
setevoy (10.100.0.3)
setevoy (192.168.0.3)
10.100.0.3 – це я підключений з домашнього ноута через VPN, а 192.168.0.3 – ноутбук в локальній мережі в офісі.
Тепер робимо простий python-скрипт, який буде нашим API-ендпоінтом.
Пишемо файл /usr/local/bin/glance_api.py:
#!/usr/bin/env python3.11
# minimal API server for Glance NAS page
# exposes active SSH sessions as JSON
from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess
import json
HOST = "192.168.0.2"
PORT = 9001
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/ssh":
# run who and extract unique SSH sessions
output = subprocess.check_output(
"who | awk '$6 ~ /^\\(/ {print $1, $6}' | sort -u",
shell=True
).decode().strip().splitlines()
sessions = []
for line in output:
if line:
parts = line.split()
sessions.append({
"user": parts[0],
"ip": parts[1].strip("()")
})
response = {
"count": len(sessions),
"sessions": sessions
}
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(response).encode())
else:
self.send_response(404)
self.end_headers()
if __name__ == "__main__":
server = HTTPServer((HOST, PORT), Handler)
server.serve_forever()
Запускаємо його:
root@setevoy-nas:~ # chmod +x /usr/local/bin/glance_api.py root@setevoy-nas:~ # /usr/local/bin/glance_api.py
Перевіряємо локально:
root@setevoy-nas:~ # curl 192.168.0.2:9001/ssh
{"count": 2, "sessions": [{"user": "setevoy", "ip": "10.100.0.3"}, {"user": "setevoy", "ip": "192.168.0.3"}]}
Тепер додаємо в Glance новий віджет з типом custom-api:
...
# ---------- RIGHT ----------
- size: small
widgets:
- type: custom-api
title: Active SSH
url: http://nas.setevoy:9001/ssh
template: |
{{ $count := .JSON.Int "count" }}
{{ if eq $count 0 }}
No active SSH sessions
{{ else }}
<ul class="list list-gap-10">
{{ range .JSON.Array "sessions" }}
<li>
<span class="color-highlight">{{ .String "user" }}</span>
<span class="color-muted">({{ .String "ip" }})</span>
</li>
{{ end }}
</ul>
{{ end }}
...
І результат:
Uptime, CPU, ZFS pool status
Додатково можна вивести ще інформацію – uptime, CPU load, etc.
Додаємо в скрипт ще один ендпоінт /status, тепер весь скрипт такий:
#!/usr/bin/env python3.11
# minimal API server for Glance NAS page
# exposes active SSH sessions as JSON
from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess
import json
HOST = "192.168.0.2"
PORT = 9001
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/ssh":
# run who and extract unique SSH sessions
output = subprocess.check_output(
"who | awk '$6 ~ /^\\(/ {print $1, $6}' | sort -u",
shell=True
).decode().strip().splitlines()
sessions = []
for line in output:
if line:
parts = line.split()
sessions.append({
"user": parts[0],
"ip": parts[1].strip("()")
})
response = {
"count": len(sessions),
"sessions": sessions
}
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(response).encode())
elif self.path == "/status":
# get uptime and load averages
uptime_raw = subprocess.check_output(
"uptime",
shell=True
).decode().strip()
# extract load averages
load_part = uptime_raw.split("load averages:")[1].strip()
load_values = [x.strip() for x in load_part.split(",")]
# extract uptime text
uptime_text = uptime_raw.split(" up ", 1)[1].split(", load averages:", 1)[0].rsplit(",", 1)[0].strip()
# get zpool info
zpool_raw = subprocess.check_output(
"zpool list -H -o name,health,size,alloc,free,capacity",
shell=True
).decode().strip().split()
zpool = {
"name": zpool_raw[0],
"health": zpool_raw[1],
"size": zpool_raw[2],
"alloc": zpool_raw[3],
"free": zpool_raw[4],
"capacity": zpool_raw[5],
}
# get child datasets only
datasets_raw = subprocess.check_output(
"zfs list -H -o name,used,avail -r nas | tail -n +2",
shell=True
).decode().strip().splitlines()
datasets = []
for line in datasets_raw:
parts = line.split()
datasets.append({
"name": parts[0],
"used": parts[1],
"avail": parts[2],
})
response = {
"load": {
"1m": load_values[0],
"5m": load_values[1],
"15m": load_values[2],
},
"uptime": uptime_text,
"zpool": zpool,
"datasets": datasets
}
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(response).encode())
else:
self.send_response(404)
self.end_headers()
if __name__ == "__main__":
server = HTTPServer((HOST, PORT), Handler)
server.serve_forever()
Відразу скрипт для rc.d – /usr/local/etc/rc.d/glance_api:
#!/bin/sh
# PROVIDE: glance_api
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="glance_api"
rcvar="glance_api_enable"
command="/usr/local/bin/python3.11"
command_args="/usr/local/bin/glance_api.py"
pidfile="/var/run/${name}.pid"
start_cmd="${name}_start"
stop_cmd="${name}_stop"
glance_api_start()
{
echo "Starting glance_api..."
daemon -p ${pidfile} ${command} ${command_args}
}
glance_api_stop()
{
echo "Stopping glance_api..."
if [ -f ${pidfile} ]; then
kill `cat ${pidfile}`
rm -f ${pidfile}
fi
}
load_rc_config $name
: ${glance_api_enable:=no}
run_rc_command "$1"
Запускаємо:
root@setevoy-nas:~ # chmod +x /usr/local/etc/rc.d/glance_api root@setevoy-nas:~ # sysrc glance_api_enable=YES glance_api_enable: -> YES root@setevoy-nas:~ # service glance_api start Starting glance_api...
В Glance додаємо ще один блок:
...
- type: custom-api
title: NAS Status
url: http://nas.setevoy:9001/status
template: |
<div>
<div><b>Uptime:</b> {{ .JSON.String "uptime" }}</div>
<div style="margin-top:10px;">
<b>Load:</b>
{{ .JSON.String "load.1m" }} /
{{ .JSON.String "load.5m" }} /
{{ .JSON.String "load.15m" }}
</div>
<div style="margin-top:10px;">
<b>ZFS:</b>
<span class="color-positive">
{{ .JSON.String "zpool.health" }}
</span>
({{ .JSON.String "zpool.capacity" }} used)
</div>
<div style="margin-top:10px;">
<b>Datasets:</b>
<ul class="list list-gap-5">
{{ range .JSON.Array "datasets" }}
<li>
{{ .String "name" }} —
{{ .String "used" }} used
</li>
{{ end }}
</ul>
</div>
</div>
...
І тепер NAS Page виглядає так:
Можна було б в glance_api.py додати і виконання дій через POST – але я не став заморачуватись, та і виконувати команди з дашборди – це вже трохи занадто.
Останній штрих – Chrome/Firefox extension Custom New Tab URL:
Ну а потім, яе Glance переїде на окремий хост – то замінити URL.
Єдиний мінус в Glance – що він не вміє в auto refresh 🙁 Але можна зробити теж через екстешени браузеру.
![]()














