NIDAL SIDDIQUE ORITRO
Nidal Siddique Oritro

Software Engineer / Manager

Software Engineer turned into Engineering Manager, helping teams to build better software and solve complex problems. I am passionate about building high-performing teams and creating a culture of continuous improvement.

Who Am i?

I started writing code as a profession early 2013 and i never stopped. My 13+ years of industry experience is helping me bring structure and value to my team. My 2025 goal is to build digital automation that helps team work more effortlessly.

This is my personal blog, portfolio, whatever floats your boat. I write about software engineering, homelab, self hosting, my journey into becoming a manager, my experience in helping teams build a better software and my experience in building high-performing teams.

I am a novice 3d model desiger, love 3d printing, creating complex homelab server ( that i probably don't need), working with LLM and AI models.

Back to home

Custom Glance Dashboard Widgets for Sonarr, Radarr, Portainer, and OctoPrint

February 15, 2026
Oritro Ahmed

I’ve been using Glance as my homelab dashboard for a while now. One of the things that makes Glance great is its widget system — both first-party and community-built — which is essentially plain HTML with Go templating that you can customize to your heart’s content. With that flexibility in mind, I built a few custom widgets of my own for Portainer, OctoPrint, Sonarr, and Radarr.

Portainer Widget

Glance already has a community Portainer widget, but it was missing a few features I wanted for my setup. I tweaked the layout to show container health status at a glance — running, healthy, stopped, and unhealthy counts all visible in one row. I didn’t submit this to the community widget repo since the changes are fairly minor.

Portainer Widget

- type: custom-api
    title: Portainer
    cache: 1h
    options:
    base-url: ${PORTAINER_URL}
    api-key: ${PORTAINER_API_KEY}
    endpoint-id: "2"
    template: |
    {{ $baseURL := .Options.StringOr "base-url" "" }}
    {{ $apiKey := .Options.StringOr "api-key" "" }}
    {{ $endpointID := .Options.StringOr "endpoint-id" "1" }}
    {{ $requestURL := print $baseURL "/api/endpoints/" $endpointID }}

    {{ if or (eq $baseURL "") (eq $apiKey "") }}
        <p class="color-negative">Portainer URL or API Key not configured.</p>
    {{ else }}
        {{ $response := newRequest $requestURL
            | withHeader "X-API-Key" $apiKey
            | getResponse }}

        {{ if ne $response.Response.StatusCode 200 }}
        <p class="color-negative">Error: {{ $response.Response.Status }}</p>
        {{ else }}
        {{ $data := $response.JSON }}
        <div style="display: flex; align-items: center; gap: 20px;">
            <!-- Left column: Logo + Name + Summary -->
            <div style="display: flex; align-items: center; gap: 12px; flex-shrink: 0;">
            <div style="width: 40px; height: 40px; display: flex; justify-content: center; align-items: center;">
                <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer-dark.svg" width="28" height="28" style="object-fit: contain;">
            </div>
            <div>
                <div class="size-h4 color-highlight" style="display: flex; align-items: center; gap: 6px;">
                {{ $data.String "Name" }}
                <span style="width: 8px; height: 8px; border-radius: 50%; background-color: var(--color-positive);"></span>
                </div>
                <div class="size-h5 color-subdue" style="display: flex; gap: 10px;">
                <span data-popover-type="text" data-popover-text="Containers">{{ $data.Int "Snapshots.0.ContainerCount" }} containers</span>
                <span data-popover-type="text" data-popover-text="Volumes">{{ $data.Int "Snapshots.0.VolumeCount" }} volumes</span>
                </div>
            </div>
            </div>
            <!-- Right column: Stats in a row -->
            <div style="display: flex; gap: 20px; margin-left: auto;">
            <div style="text-align: center;">
                <div class="size-h3 color-positive">{{ $data.Int "Snapshots.0.RunningContainerCount" }}</div>
                <div class="size-h6 color-subdue">Running</div>
            </div>
            <div style="text-align: center;">
                <div class="size-h3 color-highlight">{{ $data.Int "Snapshots.0.HealthyContainerCount" }}</div>
                <div class="size-h6 color-subdue">Healthy</div>
            </div>
            <div style="text-align: center;">
                <div class="size-h3 color-highlight">{{ $data.Int "Snapshots.0.StoppedContainerCount" }}</div>
                <div class="size-h6 color-subdue">Stopped</div>
            </div>
            <div style="text-align: center;">
                <div class="size-h3 color-negative">{{ $data.Int "Snapshots.0.UnhealthyContainerCount" }}</div>
                <div class="size-h6 color-subdue">Unhealthy</div>
            </div>
            </div>
        </div>
        {{ end }}
    {{ end }}
# Set the following in glance .env 
${PORTAINER_URL}
${PORTAINER_API_KEY}

OctoPrint Widget

OctoPrint Widget

I couldn’t find an existing Glance widget for OctoPrint, so I built one from scratch. It displays the printer’s connection status, nozzle and bed temperatures, and a live progress bar with time remaining when a print is active.

- type: custom-api
    title: OctoPrint
    cache: 1m
    options:
    base-url: ${OCTOPRINT_URL}
    api-key: ${OCTOPRINT_API_KEY}
    template: |
    {{ $baseURL := .Options.StringOr "base-url" "" }}
    {{ $apiKey := .Options.StringOr "api-key" "" }}

    {{ if or (eq $baseURL "") (eq $apiKey "") }}
        <p class="color-negative">OctoPrint URL or API Key not configured.</p>
    {{ else }}
        {{ $printerResponse := newRequest (print $baseURL "/api/printer")
            | withHeader "X-Api-Key" $apiKey
            | getResponse }}
        {{ $jobResponse := newRequest (print $baseURL "/api/job")
            | withHeader "X-Api-Key" $apiKey
            | getResponse }}
        {{ $connectionResponse := newRequest (print $baseURL "/api/connection")
            | withHeader "X-Api-Key" $apiKey
            | getResponse }}

        {{ if ne $printerResponse.Response.StatusCode 200 }}
        <div style="display: flex; align-items: center; gap: 10px;">
            <span class="size-h4 color-highlight">Printer</span>
            <span style="width: 8px; height: 8px; border-radius: 50%; background-color: var(--color-negative);"></span>
            <span class="color-negative">Offline</span>
        </div>
        {{ else }}
        {{ $printer := $printerResponse.JSON }}
        {{ $job := $jobResponse.JSON }}
        {{ $connection := $connectionResponse.JSON }}

        {{ $state := $printer.String "state.text" }}
        {{ $isPrinting := $printer.Bool "state.flags.printing" }}
        {{ $isPaused := $printer.Bool "state.flags.paused" }}
        {{ $isError := $printer.Bool "state.flags.error" }}
        {{ $isOperational := $printer.Bool "state.flags.operational" }}

        {{ $nozzleActual := $printer.Float "temperature.tool0.actual" }}
        {{ $nozzleTarget := $printer.Float "temperature.tool0.target" }}
        {{ $bedActual := $printer.Float "temperature.bed.actual" }}
        {{ $bedTarget := $printer.Float "temperature.bed.target" }}

        {{ $printerName := $connection.String "options.printerProfiles.0.name" }}

        <!-- Header: Printer name + status -->
        <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
            <div style="width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 24px; height: 24px;">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0 .229 2.523a1.125 1.125 0 0 1-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0 0 21 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 0 0-1.913-.247M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 0 1 1.913-.247m10.5 0a48.536 48.536 0 0 0-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5Zm-3 0h.008v.008H15V10.5Z" />
            </svg>
            </div>
            <div style="flex-grow: 1;">
            <div class="size-h4 color-highlight" style="display: flex; align-items: center; gap: 6px;">
                {{ if ne $printerName "" }}{{ $printerName }}{{ else }}OctoPrint{{ end }}
                <span style="width: 8px; height: 8px; border-radius: 50%; background-color: var(--color-{{ if $isError }}negative{{ else if $isPrinting }}primary{{ else if $isPaused }}warning{{ else if $isOperational }}positive{{ else }}negative{{ end }});"></span>
            </div>
            <div class="size-h5 color-subdue">{{ $state }}</div>
            </div>
        </div>

        <!-- Temperatures -->
        <div style="display: flex; gap: 20px; margin-bottom: 10px;">
            <div style="flex: 1;">
            <div class="size-h6 color-subdue">Nozzle</div>
            <div class="size-h4 color-highlight">
                {{ printf "%.1f" $nozzleActual }}°
                {{ if gt $nozzleTarget 0.0 }}<span class="color-subdue size-h5">/ {{ printf "%.0f" $nozzleTarget }}°</span>{{ end }}
            </div>
            </div>
            <div style="flex: 1;">
            <div class="size-h6 color-subdue">Bed</div>
            <div class="size-h4 color-highlight">
                {{ printf "%.1f" $bedActual }}°
                {{ if gt $bedTarget 0.0 }}<span class="color-subdue size-h5">/ {{ printf "%.0f" $bedTarget }}°</span>{{ end }}
            </div>
            </div>
        </div>

        <!-- Print progress (only when printing) -->
        {{ if $isPrinting }}
            {{ $fileName := $job.String "job.file.name" }}
            {{ $completion := $job.Float "progress.completion" }}
            {{ $printTimeLeft := $job.Int "progress.printTimeLeft" }}

            <div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--color-text-subdue);">
            <div class="size-h5 color-subdue text-truncate" style="margin-bottom: 5px;">{{ $fileName }}</div>
            <div style="display: flex; align-items: center; gap: 10px;">
                <div class="progress-bar" style="flex-grow: 1;">
                <div class="progress-value" style="--percent: {{ printf "%.0f" $completion }}"></div>
                </div>
                <div class="size-h5 color-highlight">{{ printf "%.1f" $completion }}%</div>
            </div>
            {{ if gt $printTimeLeft 0 }}
                <div class="size-h6 color-subdue" style="margin-top: 5px;">
                {{ $hours := div $printTimeLeft 3600 }}
                {{ $minutes := div (mod $printTimeLeft 3600) 60 }}
                {{ if gt $hours 0 }}{{ $hours }}h {{ end }}{{ $minutes }}m remaining
                </div>
            {{ end }}
            </div>
        {{ end }}
        {{ end }}
    {{ end }}
# Set the following on glance .env
${OCTOPRINT_URL}
${OCTOPRINT_API_KEY}

Sonarr and Radarr Widgets

Sonarr Widget

There are a few community-made Glance widgets for Sonarr and Radarr, but they pull media thumbnails in a way that requires setting up a proxy — something I wanted to avoid. Instead, I built a simpler, text-based version. Upcoming episodes and movies are grouped by date in collapsible sections, and anything that has already been downloaded gets a checkmark.

Radarr Widget

Sonarr Widget Code

- type: custom-api
    title: Upcoming Shows
    title-url: ${SONARR_URL}
    cache: 30m
    options:
    interval: 15
    api-base-url: ${SONARR_API_URL}
    key: ${SONARR_KEY}
    url: ${SONARR_URL}
    template: |
    {{ $intervalH := .Options.IntOr "interval" 7 | mul 24 }}
    {{ $startOfDay := printf "%sT00:00:00" (now | formatTime "2006-01-02") }}
    {{ $posInterval := (offsetNow (printf "+%dh" $intervalH)) | formatTime "2006-01-02T15:04:05" }}
    {{ $apiBaseUrl := .Options.StringOr "api-base-url" "" }}
    {{ $key := .Options.StringOr "key" "" }}
    {{ $url := .Options.StringOr "url" $apiBaseUrl }}

    {{ if or (eq $apiBaseUrl "") (eq $key "") }}
        <p class="color-negative">API URL or Key not configured.</p>
    {{ else }}
        {{ $requestUrl := printf "%s/api/v3/calendar?includeSeries=true&start=%s&end=%s" $apiBaseUrl $startOfDay $posInterval }}
        {{ $data := newRequest $requestUrl
        | withHeader "Accept" "application/json"
        | withHeader "X-Api-Key" $key
        | getResponse }}

        {{ $episodes := $data.JSON.Array "" | sortByTime "airDateUtc" "rfc3339" "asc" }}
        {{ $currentDate := "" }}
        {{ $hasItems := false }}

        {{ if eq (len $episodes) 0 }}
        <p>No upcoming shows.</p>
        {{ else }}
        <div class="flex flex-column gap-15">
        {{ range $idx, $haserPet := $episodes }}
            {{ $hasItems = true }}
            {{ $airDate := .String "airDateUtc" | parseTime "RFC3339" }}
            {{ $airDate = $airDate.In now.Location }}
            {{ $dateStr := $airDate.Format "January 2, 2006" }}
            {{ $showName := .String "series.title" }}
            {{ $episodeTitle := .String "title" }}
            {{ $season := .Int "seasonNumber" }}
            {{ $episode := .Int "episodeNumber" }}
            {{ $seString := printf "S%02dE%02d" $season $episode }}

            {{ if ne $dateStr $currentDate }}
            {{ $currentDate = $dateStr }}
            </details>
            {{ if eq $idx 0 }}
                <details open>
            {{ else }}
                <details>
            {{ end }}
                <summary class="color-primary size-h4" style="cursor:pointer;">
                {{ $dateStr }}
                </summary>
                <div class="margin-top-3" style="padding-left: 10px; border-top: 1px solid var(--color-text-subdue)">
            {{ end }}

            <div class="margin-top-3 margin-bottom-3">
            <div class="color-highlight text-truncate">
                {{ if .Bool "hasFile" }}<span class="color-positive">&#10003;</span> {{ end }}{{ $showName }}
            </div>
            <div class="size-h5 color-subdue text-truncate">{{ $seString }} - {{ $episodeTitle }}</div>
            </div>
        {{ end }}
        </details>
        </div>
        {{ end }}
    {{ end }}
# Set the following in glance .env
${SONARR_URL}
${SONARR_API_URL}
${SONARR_KEY}

Radarr Widget Code

- type: custom-api
    title: Upcoming Movies
    title-url: ${RADARR_URL}
    cache: 30m
    options:
    interval: 30
    api-base-url: ${RADARR_API_URL}
    key: ${RADARR_KEY}
    url: ${RADARR_URL}
    template: |
    {{ $intervalH := .Options.IntOr "interval" 30 | mul 24 }}
    {{ $now := now | formatTime "2006-01-02T15:04:05" }}
    {{ $posInterval := (offsetNow (printf "+%dh" $intervalH)) | formatTime "2006-01-02T15:04:05" }}
    {{ $apiBaseUrl := .Options.StringOr "api-base-url" "" }}
    {{ $key := .Options.StringOr "key" "" }}
    {{ $url := .Options.StringOr "url" $apiBaseUrl }}

    {{ if or (eq $apiBaseUrl "") (eq $key "") }}
        <p class="color-negative">API URL or Key not configured</p>
    {{ else }}
        {{ $requestUrl := printf "%s/api/v3/calendar?start=%s&end=%s" $apiBaseUrl $now $posInterval }}
        {{ $data := newRequest $requestUrl
        | withHeader "Accept" "application/json"
        | withHeader "X-Api-Key" $key
        | getResponse }}

        {{ $movies := $data.JSON.Array "" | sortByTime "digitalRelease" "rfc3339" "asc" }}
        {{ $currentDate := "" }}
        {{ $hasItems := false }}

        {{ if eq (len $movies) 0 }}
        <p>No upcoming movies.</p>
        {{ else }}
        <div class="flex flex-column gap-15">
        {{ range $idx, $movie := $movies }}
            {{ $hasItems = true }}
            {{ $movieTitle := .String "title" }}
            {{ $status := .String "status" }}
            {{ $digitalRelease := .String "digitalRelease" }}
            {{ $physicalRelease := .String "physicalRelease" }}
            {{ $inCinemas := .String "inCinemas" }}

            {{ $releaseType := "Upcoming" }}
            {{ $releaseDate := "" }}

            {{ if ne $digitalRelease "" }}
            {{ $releaseDate = $digitalRelease }}
            {{ $releaseType = "Digital Release" }}
            {{ else if ne $physicalRelease "" }}
            {{ $releaseDate = $physicalRelease }}
            {{ $releaseType = "Physical Release" }}
            {{ else if ne $inCinemas "" }}
            {{ $releaseDate = $inCinemas }}
            {{ $releaseType = "In Cinemas" }}
            {{ end }}

            {{ if ne $releaseDate "" }}
            {{ $parsedDate := $releaseDate | parseTime "RFC3339" }}
            {{ $parsedDate = $parsedDate.In now.Location }}
            {{ $dateStr := $parsedDate.Format "January 2, 2006" }}

            {{ if ne $dateStr $currentDate }}
                {{ $currentDate = $dateStr }}
                </details>
                {{ if eq $idx 0 }}
                <details open>
                {{ else }}
                <details>
                {{ end }}
                <summary class="color-primary size-h4" style="cursor:pointer;">
                    {{ $dateStr }}
                </summary>
                <div class="margin-top-3" style="padding-left: 10px; border-top: 1px solid var(--color-text-subdue)">
            {{ end }}

            <div class="margin-top-3 margin-bottom-3">
                <div class="color-highlight text-truncate">
                {{ if .Bool "hasFile" }}<span class="color-positive">&#10003;</span> {{ end }}{{ $movieTitle }}
                </div>
                <div class="size-h5 color-subdue">{{ $releaseType }}</div>
            </div>
            {{ end }}
        {{ end }}
        </details>
        </div>
        {{ end }}
    {{ end }}
# Set the following in glance .env
${RADARR_URL}
${RADARR_API_URL}
${RADARR_KEY}

Wrapping Up

These widgets have been working well on my dashboard for a while now. Glance’s custom widget system makes it straightforward to pull data from any API and display it however you want — no extra dependencies or proxies needed. If you end up using or adapting any of these for your own setup, feel free to reach out. I’d love to see what you come up with.

Comments