Building a Self-Hosted Personal Dashboard with Glance

Contents

I used to start the day by going through the same handful of browser tabs: weather, calendar, news, markets, YouTube. I wanted all of it on one screen instead.

Glance is an open source, self-hosted dashboard. It ships as a single binary with no database, and the whole page (widgets, data sources, refresh intervals, layout) is declared in one YAML file, glance.yml.

My config has grown to about 730 lines, plus roughly 2,000 lines of PHP in the proxy layer that feeds the parts Glance cannot fetch on its own. This post covers what is on the screen, where the data comes from, and why the PHP layer exists at all.

What the screen shows#

Three columns, one page, no scrolling on a desktop monitor.

Column Width What it holds
Left Narrow Local conditions and status
Middle Wide Reading material
Right Narrow Personal

The left column is local conditions and status: current temperature, sky description and any weather front warning; UV index and rainfall for the last 24 hours and last 7 days; an hourly temperature strip for the next 12 hours; a three-day forecast; air quality with the composite index and the individual pollutants; a few market tickers; uptime checks on the sites I run; quick-launch buttons; and a button that restarts Glance when a source has gone stale.

The middle column is reading material. News and car sites in two tabbed groups, top posts from fourteen subreddits, and the Hacker News front page.

The right column is personal: two Google Calendar feeds showing the next eight events each, and recent videos from a list of YouTube channels.

How it fits together#

Glance runs on a NAS on the LAN at port 8080. A small Apache and PHP instance on localhost:8081 serves the proxy scripts that Glance calls through its custom-api widget type.

Glance and the PHP proxy layerGlance calls a small PHP layer on the same box, which talks to the outside world.

The proxy layer exists because the sources fall into three awkward categories:

  • No API at all, so the data has to be scraped out of HTML.
  • A token or a private URL that has no business sitting in a config file I might paste into a blog post.
  • A shape Glance’s template engine cannot use directly, so something has to flatten it first.
Proxy Source Why it exists
idokep.php Időkép weather No API, scraped
aqicn.php AQICN air quality City page scraped
icsproxy.php Google Calendar Private URL, and ICS needs real parsing
youtube.php YouTube channel feeds Fetches every channel feed in parallel
topnews.php News and car sites One scraper per site
reddit.php Reddit The JSON endpoint respects the time period
launch.php Local machine Opens a browser window on the dashboard host

Anything Glance can do natively, it does natively. There is no proxy for market data or uptime checks, because the built-in widgets already handle them.

The YAML#

The file opens with the bind address and a small theme block, which is all the colour customisation Glance offers out of the box. Everything else visual is done later with injected CSS.

glance.yml
server:
  host: 0.0.0.0      # bind on the LAN; use 127.0.0.1 to keep it local-only
  port: 8080

theme:
  background-color: 240 8 9        # HSL
  primary-color: 43 50 70
  contrast-multiplier: 1.1

Anchors keep it from doubling in size#

Glance parses standard YAML, which means anchors and aliases work. Proxy URLs and reusable widget templates get declared once under vars: and referenced with *anchor_name. Without this, eight nearly identical news widgets would each carry their own copy of a forty-line template, and a change to the markup would mean eight edits and at least one mistake.

glance.yml: shared URLs
vars:
  ICAL_PROXY:    &ical_proxy    "http://localhost:8081/glance/icsproxy.php"
  TOPNEWS_BASE:  &topnews_base  "http://localhost:8081/glance/topnews.php"
  AQICN_PROXY:   &aqicn_proxy   "http://localhost:8081/glance/aqicn.php"
  IDOKEP_PROXY:  &idokep_proxy  "http://localhost:8081/glance/idokep.php"
  REDDIT_PROXY:  &reddit_proxy  "http://localhost:8081/glance/reddit.php"

A custom-api widget renders the proxy’s JSON with Go templates. The calendar template handles the error case, the empty case and the data case, then iterates the events:

glance.yml: calendar template
  CAL_TPL: &cal_tpl |
    {{ if .JSON.Exists "error" }}
      <p class="color-negative">ICS proxy error: {{ .JSON.String "error" }}</p>
    {{ else if eq (.JSON.Int "count") 0 }}
      <p class="color-paragraph">No upcoming events.</p>
    {{ else }}
      <ul class="list list-gap-4 collapsible-container cal-list" data-collapse-after="5">
      {{ range .JSON.Array "events" }}
        <li class="cal-entry" style="padding: 2px 4px 4px;">
          <a class="size-h4 color-primary-if-not-visited block text-truncate"
             title="{{ .String "summary" }}">{{ .String "summary" }}</a>
          <ul class="list-horizontal-text text-compact">
            <li class="color-highlight">{{ .String "when" }}</li>
            {{ if ne (.String "location") "" }}<li class="text-truncate">{{ .String "location" }}</li>{{ end }}
          </ul>
        </li>
      {{ end }}
      </ul>
    {{ end }}

Handling the error branch inside the template is worth the three extra lines. A proxy that fails silently leaves an empty box on the screen and you keep trusting stale data for a week without noticing.

With the templates factored out, adding a news source is four lines:

glance.yml: one news widget
- type: custom-api
  title: "Telex >"
  cache: 30m
  url: *topnews_base
  parameters: { site: telex, limit: 5 }
  template: *news_tpl

Layout#

glance.yml: layout
pages:
  - name: Home
    columns:
      - size: small    # weather, AQI, markets, uptime
      - size: full     # news groups, Reddit, HN
      - size: small    # calendars, YouTube

The data sources#

Weather from Időkép#

Időkép is the most widely used weather service in Hungary. International weather APIs interpolate from grid models, which is fine for a continent and mediocre for a valley. Időkép runs a dense network of local stations and its own model tuned for the Carpathian Basin, and it publishes things the generic APIs skip: precipitation radar with real resolution, UV index, and weather front warnings, which matter more than you would think if anyone in the house gets headaches when the pressure drops.

There is no public API, so idokep.php scrapes the city page and returns a JSON object with the current conditions, UV, rainfall totals, an hourly strip and a three-day forecast.

YAML
- type: custom-api
  title: Időkép, Szeged
  cache: 15m
  url: *idokep_proxy
  parameters:
    city: Szeged
    lat: 46.253
    lon: 20.149

The template pulls fields out with .JSON.Int and .JSON.String, then colours the temperature when it goes above 30 or below 5 degrees:

Weather template (excerpt)
{{ $temp := .JSON.Int "current.temp" }}
{{ $sky  := .JSON.String "current.sky" }}
{{ $tclr := "" }}
{{ if gt $temp 30 }}{{ $tclr = "color:#c0392b; font-weight:800;" }}
{{ else if lt $temp 5 }}{{ $tclr = "color:#2471a3; font-weight:800;" }}{{ end }}
<span style="font-size:2.4rem; font-weight:800; {{ $tclr }}">{{ $temp }}°</span>
<div class="size-h4">{{ $sky }}</div>

{{ range .JSON.Array "hours" }}
  <div style="text-align:center; min-width:28px;">
    <div class="size-h6 color-paragraph">{{ .String "hour" }}</div>
    <img src="{{ .String "icon_url" }}" width="24" height="24" alt="" loading="lazy">
    <div class="size-h5">{{ .Int "temp" }}°</div>
  </div>
{{ end }}

Air quality from AQICN#

AQICN aggregates readings from government monitoring stations in more than a hundred countries. It reports the US EPA index, a 0 to 500 scale where higher is worse, along with the individual pollutants: PM2.5 (the fine particulates that matter most for health), PM10, ozone, nitrogen dioxide, sulphur dioxide and carbon monoxide.

aqicn.php reads the city page and returns the composite value, its category, and the per-pollutant numbers.

YAML
- type: custom-api
  title: Air quality
  cache: 30m
  url: *aqicn_proxy
  parameters:
    url: https://aqicn.org/city/hungary/szeged/szeged2/

In winter this widget earns its place. When wood smoke settles over the city the PM2.5 number climbs well before anything looks wrong outside.

Calendars#

icsproxy.php fetches a Google Calendar private ICS URL, parses the events, filters to the next N days, converts timestamps to the configured timezone and returns a flat JSON array. Two widgets share one template and differ only in the URL:

YAML
- type: custom-api
  title: Calendar, personal
  cache: 15m
  url: *ical_proxy
  parameters:
    url: https://calendar.google.com/calendar/ical/<CALENDAR_ID>/private-<SECRET>/basic.ics
    tz: Europe/Budapest
    days: 60
    limit: 8
  template: *cal_tpl

The parsing itself turned out to be the hardest part of the whole project, for reasons covered in the e-ink dashboard post, which uses the same proxy: recurring events with single-instance edits do not behave the way a naive parser expects.

YouTube#

Every YouTube channel publishes an Atom feed at https://www.youtube.com/feeds/videos.xml?channel_id=UC… with no authentication at all. youtube.php reads a CSV of channel IDs, fetches all of them in parallel with curl_multi, merges the entries, sorts by publish date and returns the newest.

youtube.php (excerpt)
$multi = curl_multi_init();
foreach (array_keys($channels) as $cid) {
    $h = curl_init('https://www.youtube.com/feeds/videos.xml?channel_id=' . $cid);
    curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8,
                           CURLOPT_ENCODING => 'gzip']);
    curl_multi_add_handle($multi, $h);
}
do { curl_multi_exec($multi, $running); curl_multi_select($multi, 0.1); } while ($running > 0);

// then, per feed, take the two newest entries:
preg_match_all('#<entry>(.*?)</entry>#s', $body, $entries);
foreach ($entries[1] as $entry) {
    preg_match('#<yt:videoId>([^<]+)</yt:videoId>#', $entry, $mv);
    preg_match('#<title>([^<]+)</title>#',           $entry, $mt);
    $videos[] = [
        'video_id'  => $mv[1],
        'url'       => 'https://www.youtube.com/watch?v=' . $mv[1],
        'title'     => html_entity_decode($mt[1] ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8'),
        'thumbnail' => sprintf('https://i.ytimg.com/vi/%s/mqdefault.jpg', $mv[1]),
    ];
}
usort($videos, fn($a, $b) => strcmp($b['published'], $a['published']));

Doing this sequentially took about eight seconds for thirty channels. In parallel it is under one. Regex parsing of XML is a sin in general, but for a feed format this rigid it has survived years without a single failure.

News#

topnews.php takes a site parameter and runs a per-site scraper. The sites fall into two tabbed groups: news and cars. Clicking a tab switches source, and so do the left and right arrow keys.

Reddit#

Fourteen subreddits covering technology, science, AI, hardware, cars and local topics. This one has a story.

The obvious approach is Reddit’s RSS feed, and that is where I started. The problem is that the RSS endpoint ignores the time period parameter, so ?t=day gets you the same list as ?t=all and the widget shows the same handful of famous posts forever. The JSON endpoint on old.reddit.com respects the period, so reddit.php uses that instead, with browser cookies pasted into the script rather than an OAuth application:

reddit.php: input checks
$sub    = preg_replace('/[^A-Za-z0-9_]/', '', $_GET['sub'] ?? '');
$period = in_array($_GET['period'] ?? '', ['hour','day','week','month','year','all'], true)
        ? $_GET['period'] : 'day';
$limit  = min(max((int)($_GET['limit'] ?? 5), 1), 25);

This is the least durable thing in the whole setup and I know it. The cookies expire, and then the widget shows an error until I refresh them. Registering a proper API client would fix that. I have not, because the fix takes two minutes about twice a year.

The built-in widgets#

Markets, uptime monitoring and RSS need no proxy at all:

glance.yml: built-in widgets
- type: markets
  title: Markets
  cache: 30m
  markets:
    - { symbol: "^GSPC",   name: S&P 500 }
    - { symbol: EURHUF=X,  name: EUR / HUF }
    - { symbol: USDHUF=X,  name: USD / HUF }

- type: monitor
  title: Uptime
  cache: 5m
  sites:
    - { title: site-one.example,   url: https://site-one.example,   check-url: https://site-one.example }
    - { title: site-two.example,   url: https://site-two.example,   check-url: https://site-two.example }

The uptime widget has already paid for the whole project once, by showing a red square on a Sunday morning about twenty minutes after a certificate renewal quietly failed.

Restyling a dashboard with no theming hooks#

Glance exposes a few colour variables and nothing else. But a type: html widget renders raw markup, so one of those placed at the top of the left column can load a web font and restyle the entire page with global CSS:

glance.yml: style injection
- type: html
  source: |
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
    <style>
      *, *::before, *::after { font-family: 'Inter', system-ui, sans-serif !important; }
      /* hide headers on standalone widgets, keep them on groups */
      .widget:not(.widget-type-group) > .widget-header { display: none !important; }
      /* move the page nav bar to the bottom of the viewport */
      .header-container { position: fixed !important; bottom: 0 !important; top: auto !important;
                          left: 0; right: 0; z-index: 200; }
      .page-wrapper { padding-top: 0 !important; padding-bottom: 56px !important; }
    </style>

The same widget injects JavaScript that adds a direct link to each news tab, puts arrow buttons into the group headers, and binds the keyboard arrows so every tabbed group steps at once:

HTML
<script>
(function () {
  document.addEventListener('keydown', function (e) {
    if (e.target.tagName === 'INPUT' || e.target.isContentEditable) return;
    if (e.key === 'ArrowLeft')  navAllGroups(-1);
    else if (e.key === 'ArrowRight') navAllGroups(1);
  });
})();
</script>

Launch buttons, carefully#

A second HTML widget holds quick-launch buttons. Each one calls launch.php, which opens a Chromium window on the machine running the web server:

Launch button
<button type="button" onclick="
  fetch('http://localhost/glance/launch.php?app=chromium&url='
        + encodeURIComponent('https://calendar.google.com/'), {cache:'no-store'})
    .then(r => r.json())
    .then(j => this.textContent = j.ok ? '✓ launched' : '✕ ' + (j.error || 'failed'));
">▶ Calendar</button>

Spawning a process from an HTTP request is exactly the kind of thing that turns a home dashboard into somebody else’s shell, so launch.php refuses anything that is not from loopback and only opens hosts on an explicit allowlist:

launch.php: guards
$remote = (string)($_SERVER['REMOTE_ADDR'] ?? '');
if (!in_array($remote, ['127.0.0.1', '::1'], true)) {
    http_response_code(403);
    echo json_encode(['ok' => false, 'error' => 'forbidden']);
    exit;
}

$ALLOWED_HOSTS = ['calendar.google.com', 'www.youtube.com', 'youtube.com'];
$parsed = parse_url((string)($_GET['url'] ?? ''));
$host   = strtolower((string)($parsed['host'] ?? ''));
if (($parsed['scheme'] ?? '') !== 'https' || !in_array($host, $ALLOWED_HOSTS, true)) {
    http_response_code(400);
    echo json_encode(['ok' => false, 'error' => 'url not allowed']);
    exit;
}

An allowlist rather than a blocklist, and a scheme check, because parse_url on something like javascript: or a URL with an embedded userinfo section will happily hand you a surprise.

A second button restarts the Glance service to clear every widget cache at once, which is useful when a source has returned garbage and cached it for half an hour:

Restart handler (excerpt)
@exec('sudo /bin/systemctl restart glance 2>/dev/null');
usleep(2500000);
// then probe http://127.0.0.1:8080/ and report whether it came back up

What I would tell someone starting this#

  1. Set the cache times deliberately. It is tempting to refresh everything every minute, and then you are hitting a dozen sites constantly for data that changes twice a day. Nothing needs to be faster than this:

    Widget Cache
    Uptime 5 minutes
    Weather, calendars 15 minutes
    Air quality, news, markets 30 minutes
  2. Write the proxies to fail loudly. Every one of mine returns {"error": "..."} with a message and a zero count, and every template checks for it. A dashboard that shows wrong data confidently is worse than one that shows a red line.

  3. Keep the secrets out of the YAML from day one. It is far easier than trying to scrub a config file later, and the whole point of a file this readable is that you can share it.

Everything here runs on the LAN. There is no account, no vendor, no analytics, and the only external service in the whole thing is a calendar URL that I could revoke in a minute. The config is one text file in git. When the NAS gets replaced, the dashboard moves with a copy and a systemctl enable.