Glance: налаштування self-hosted home page для браузера
0 (0)

Автор |  11/02/2026
Click to rate this post!
[Total: 0 Average: 0]

Є така прикольна штука, як 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 🙁 Але можна зробити теж через екстешени браузеру.

Loading