feat: dynamic favicon reflects real-time system health status

Favicon changes to green/yellow/red based on device and alert status.
LiveView computes the status server-side and passes it to a minimal
JS hook that generates PNG favicons via canvas.
This commit is contained in:
Graham McIntire 2026-03-05 13:47:54 -06:00
parent ade7e2fe15
commit b56cdf4e9f
No known key found for this signature in database
4 changed files with 47 additions and 21 deletions

View file

@ -885,35 +885,50 @@ const NetworkMap = {
}
}
// Dynamic Favicon - alternates favicon between status indicator SVGs
// Dynamic Favicon - sets favicon color based on data-status from LiveView
const DynamicFavicon = {
interval: null as ReturnType<typeof setInterval> | null,
isGreen: true,
statusColors: { green: '#22c55e', yellow: '#eab308', red: '#ef4444' } as Record<string, string>,
cache: {} as Record<string, string>,
originalHref: null as string | null,
mounted(this: any) {
const greenSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#4ade80"/><stop offset="60%" stop-color="#22c55e"/><stop offset="100%" stop-color="#16a34a"/></radialGradient><filter id="glow"><feGaussianBlur stdDeviation="2" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><circle cx="16" cy="16" r="12" fill="url(#g)" filter="url(#glow)"/><circle cx="13" cy="13" r="3" fill="white" opacity="0.3"/></svg>`
const redSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="r" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#f87171"/><stop offset="60%" stop-color="#ef4444"/><stop offset="100%" stop-color="#dc2626"/></radialGradient><filter id="glow"><feGaussianBlur stdDeviation="2" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><circle cx="16" cy="16" r="12" fill="url(#r)" filter="url(#glow)"/><circle cx="13" cy="13" r="3" fill="white" opacity="0.3"/></svg>`
const greenDataUri = 'data:image/svg+xml,' + encodeURIComponent(greenSvg)
const redDataUri = 'data:image/svg+xml,' + encodeURIComponent(redSvg)
getFaviconPng(this: any, status: string): string {
if (this.cache[status]) return this.cache[status]
const color = this.statusColors[status] || this.statusColors.green
const canvas = document.createElement('canvas')
canvas.width = 64
canvas.height = 64
const ctx = canvas.getContext('2d')!
ctx.beginPath()
ctx.arc(32, 32, 32, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
this.cache[status] = canvas.toDataURL('image/png')
return this.cache[status]
},
applyStatus(this: any) {
const faviconEl = document.getElementById('favicon') as HTMLLinkElement
if (!faviconEl) return
const status = this.el.dataset.status || 'green'
faviconEl.type = 'image/png'
faviconEl.href = this.getFaviconPng(status)
},
this.isGreen = true
faviconEl.type = 'image/svg+xml'
faviconEl.href = greenDataUri
mounted(this: any) {
const faviconEl = document.getElementById('favicon') as HTMLLinkElement
if (faviconEl) this.originalHref = faviconEl.href
this.applyStatus()
},
this.interval = setInterval(() => {
this.isGreen = !this.isGreen
faviconEl.href = this.isGreen ? greenDataUri : redDataUri
}, 1000)
updated(this: any) {
this.applyStatus()
},
destroyed(this: any) {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
const faviconEl = document.getElementById('favicon') as HTMLLinkElement
if (faviconEl && this.originalHref) {
faviconEl.type = 'image/png'
faviconEl.href = this.originalHref
}
}
}

View file

@ -65,7 +65,12 @@
{t_auth("You are running the local mail adapter.")}
</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-200">
<%= t_auth("To see sent emails, visit %{link}.", link: raw(~s(<a href="/dev/mailbox" class="font-medium underline">#{t_auth("the mailbox page")}</a>))) %>
{t_auth("To see sent emails, visit %{link}.",
link:
raw(
~s(<a href="/dev/mailbox" class="font-medium underline">#{t_auth("the mailbox page")}</a>)
)
)}
</p>
</div>
</div>

View file

@ -156,6 +156,7 @@ defmodule ToweropsWeb.DashboardLive do
|> assign(:uptime_percentage, uptime_percentage)
|> assign(:recent_activity, recent_activity)
|> assign(:last_updated, DateTime.utc_now())
|> assign(:favicon_status, favicon_status(Map.get(status_counts, :down, 0), length(active_alerts)))
|> load_insights(organization_id)
|> load_recent_config_changes(organization_id)
end
@ -185,6 +186,10 @@ defmodule ToweropsWeb.DashboardLive do
assign(socket, :insights, insights)
end
defp favicon_status(devices_down, _alert_count) when devices_down > 0, do: "red"
defp favicon_status(_devices_down, alert_count) when alert_count > 0, do: "yellow"
defp favicon_status(_devices_down, _alert_count), do: "green"
defp health_score_color(score) when score > 80, do: "text-green-600 dark:text-green-400"
defp health_score_color(score) when score > 50, do: "text-yellow-600 dark:text-yellow-400"
defp health_score_color(_score), do: "text-red-600 dark:text-red-400"

View file

@ -3,7 +3,8 @@
current_scope={@current_scope}
active_page="dashboard"
>
<div id="dynamic-favicon" phx-hook="DynamicFavicon" class="hidden"></div>
<div id="dynamic-favicon" phx-hook="DynamicFavicon" data-status={@favicon_status} class="hidden">
</div>
<%!-- ═══════════════════════════════════════════════
NOC HEADER — dense, zero chrome
═══════════════════════════════════════════════ --%>