Glance: setting up a self-hosted home page for your browser
0 (0)

By | 03/13/2026
Click to rate this post!
[Total: 0 Average: 0]

There’s this cool thing called self-hosted home pages.

I saw them somewhere on Reddit a while back, saved them to bookmarks, and now that I have all this self-hosted stuff with NAS (see FreeBSD: Home NAS, part 1), Grafana, and other useful things for work and daily life – I thought it would be nice to set up such a dashboard for myself.

From the ones I have saved, and which seem to be the most popular – there’s gethomepage/homepage and glanceapp/glance.

But Homepage feels a bit heavier, with a bunch of components – frontend, backend, some renderers – while Glance is simpler, and in general has everything I wanted to see – although in some places it’s done through workarounds 🙂

So – setting up Glance.

For now I’ll do it locally on my laptop with Docker Compose; later I’ll move the config to FreeBSD/NAS or to a Raspberry PI with Debian.

Configuring Glance

See the Pages & Columns documentation.

The main components are Pages, Columns, and Widgets:

  • pages: essentially pages – you can have multiple tabs
  • columns: each page is split into several columns
  • widgets: and each column has a set of widgets

Widgets can also be grouped and tabbed – we’ll see an example with Reddit below.

To play around – it runs easily with Docker, see Installation.

There are no separate themes – everything is configured through styles, for example:

...
theme:
  background-color: 225 14 15
  primary-color: 157 47 65
  contrast-multiplier: 1.1
...

But there are ready-made styles – see Themes.

Besides the default widgets, there are community widgets, for example, I grabbed NextDNS Stats from there.

My Pages and Widgets

Briefly, an example of how all this can be configured.

Since I’m hosting all of this locally and won’t be storing the config anywhere like GitHub, I wrote various tokens directly into the config, but in general you can use environment variables for them – see Other ways of providing tokens/passwords/secrets.

The first page – Home with three columns:

Clock, Weather, Calendar

Time, weather and 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
...

In the clock widget I added two more zones – because we have some developers in the US, and in Chiang Mai there’s one developer I talk to frequently and always have to think about what time it is there.

The center column – with type full, and each page needs to have at least one column with full.

Search

Here the search widget:

...
      # ---------- 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 – the main ones for quick access, organized by category:

...
          - 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/
...

For choosing color you can use colorpicker.dev: the first number is the color itself, the second is saturation, the third is lightness.

Group for Reddit

Next is an example of grouping with Group – I made separate tabs for different subreddits, but in two separate groups – a rough “Reddit Ukraine” and “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 for news

The next widget – Split Column, with news and some interesting materials in a more compact layout:

...
          - 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
...

And the more interesting part – on the right, service status information.

GitHub Releases

The releases widget – latest GitHub releases:

...
      - size: small
        widgets:
          - type: releases
            token: github_pat_11A***1jF
            repositories:
              - VictoriaMetrics/VictoriaMetrics
              - pdf/zfs_exporter
              - tess1o/go-ecoflow-exporter
...

It makes sense for me to track zfs_exporter and go-ecoflow-exporter, since they’re deployed manually on my end – see FreeBSD: Home NAS, part 11 – extended monitoring with additional exporters.

Although, of course, nobody cancels the option of just subscribing to updates in GitHub itself 🙂

Custom API for NextDNS

An example of a custom custom-api widget – information about my 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 }}
...

And there will be another example with a custom mini-API service below.

Monitor – HTTP service statuses

The monitor widget – a cool thing for displaying service status, it makes a simple GET request to the specified URL, and (at least for now) only works with 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
...

The MikroTik RB4011 (see MikroTik: First Look and Getting Started), for example, has a web interface accessible from the local network, so its status can be checked through it.

To connect icons – look for the default ones at, for example, selfh.st or dashboardicons.com.

Or you can set custom icons – add files to the /assets directory and include it in the Glance config:

...
server:
  host: 0.0.0.0
  port: 8080
  assets-path: /app/assets
...

Server Stats – CPU, RAM, Disk

An interesting thing – server-stats, although it’s still in beta:

...
          - type: server-stats
            servers:
              - type: local
                name: setevoy-work
...

Requires an additional service – glanceapp/agent, for now I just added it to 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

And the last one here – running Docker containers, because I sometimes forget that something is running:

...
          - type: docker-containers
            hide-by-default: false
            running-only: true
...

(I’ll write about Uptime Kuma too)

The whole Home page looks like this so far:

Custom API for FreeBSD/NAS

And here’s an interesting solution: I want to display some useful information from FreeBSD.

Active SSH connections

First, an example of “active SSH users” – checking who is connected and from where, showing only unique addresses:

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 is me connected from my home laptop via VPN, and 192.168.0.3 is a laptop on the local network in the office.

Now let’s make a simple Python script that will serve as our API endpoint.

Write the file /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()

Run it:

root@setevoy-nas:~ # chmod +x /usr/local/bin/glance_api.py

root@setevoy-nas:~ # /usr/local/bin/glance_api.py

Test locally:

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"}]}

Now add a new widget with type custom-api to Glance:

...
      # ---------- 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 }}
...

And the result:

Uptime, CPU, ZFS pool status

You can also display additional information – uptime, CPU load, etc.

Let’s add another /status endpoint to the script, here’s the full script now:

#!/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()

And the rc.d script right away – /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"

Start it:

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...

Add another block to 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>
...

And now the NAS Page looks like this:

You could add POST action execution to glance_api.py as well – but I didn’t bother, and executing commands from a dashboard feels like a bit much anyway.

One final touch – the Chrome/Firefox extension Custom New Tab URL:

And once Glance moves to a separate host – just update the URL.

The only downside to Glance is that it doesn’t support auto-refresh 🙁 But that can also be done through browser extensions.

Loading