Fix unreachable mobile Settings link and raw error tracebacks
All checks were successful
Deploy / deploy (push) Successful in 2m6s

Settings lived inside .header-right, which is hidden below 600px,
leaving no way to reach it on mobile. It's now a standalone 44x44px
button next to the title.

Dashboard error cards showed raw Python/urllib3 exception strings.
Added a friendly_error() filter that classifies the failure (timeout,
connection refused, DNS, auth, 4xx/5xx, TLS) into plain-English copy,
with the raw string still available behind a details disclosure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Barely Removable 2026-08-25 23:59:07 -07:00
parent 9804283396
commit 2e1f8efa33
2 changed files with 133 additions and 16 deletions

35
app.py
View file

@ -32,6 +32,41 @@ def save_settings(data):
app = Flask(__name__) app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
def friendly_error(raw, service=""):
"""Turn a raw exception string into a short, human-readable cause.
The service dicts carry str(e) from requests/urllib3, which is useful for
debugging but unreadable on the dashboard. Templates render this instead
and keep the raw string behind a details disclosure.
"""
s = str(raw or "")
low = s.lower()
name = service or "The service"
if low.strip() == "timeout":
return f"{name} took too long to answer and was dropped at the 5s page deadline."
if "connecttimeout" in low or "timed out" in low or "read timed out" in low:
return f"{name} didn't respond — the connection timed out."
if "connection refused" in low or "newconnectionerror" in low:
return f"{name} refused the connection — check that it's running and the port is right."
if "name or service not known" in low or "nameresolution" in low or "failed to resolve" in low:
return f"{name}'s hostname couldn't be resolved — check the host setting."
if "no route to host" in low or "network is unreachable" in low:
return f"{name} is unreachable from this container — check the network."
if "401" in s or "unauthorized" in low or "403" in s or "forbidden" in low:
return f"{name} rejected the API key."
if "404" in s or "not found" in low:
return f"{name} returned 404 — check the URL or base path."
if any(code in s for code in ("500", "502", "503", "504")):
return f"{name} returned a server error."
if "ssl" in low or "certificate" in low:
return f"{name}'s TLS handshake failed — check the certificate settings."
return f"{name} couldn't be reached."
app.jinja_env.filters["friendly_error"] = friendly_error
app.config["SESSION_COOKIE_PATH"] = "/" app.config["SESSION_COOKIE_PATH"] = "/"
app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SAMESITE"] = "Lax"

View file

@ -163,11 +163,40 @@
font-style: italic; font-style: italic;
} }
.header-right { .header-right {
display: flex;
align-items: center;
gap: 0.7rem;
font-family: var(--brand-font); font-family: var(--brand-font);
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted); color: var(--muted);
text-align: right; text-align: right;
} }
.header-meta { text-align: right; }
.header-clock {
font-size: 1.1rem;
font-family: var(--brand-font);
color: var(--gold);
letter-spacing: 1px;
}
.header-room { font-size: 0.7rem; color: var(--muted); margin-top: 0.1rem; }
.settings-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-width: 44px;
min-height: 44px;
padding: 0 0.7rem;
background: var(--navy);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.78rem;
white-space: nowrap;
transition: color 0.2s, border-color 0.2s;
}
.settings-link:hover { color: var(--gold); border-color: var(--gold); }
/* ── Main layout ── */ /* ── Main layout ── */
main { padding: 1.5rem 2rem; max-width: 1280px; margin: 0 auto; } main { padding: 1.5rem 2rem; max-width: 1280px; margin: 0 auto; }
@ -790,6 +819,34 @@
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.error-detail { margin-top: 0.5rem; }
.error-detail summary {
cursor: pointer;
font-size: 0.75rem;
color: var(--muted);
list-style: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0;
}
.error-detail summary::-webkit-details-marker { display: none; }
.error-detail summary::before { content: "▸"; font-size: 0.7rem; }
.error-detail[open] summary::before { content: "▾"; }
.error-detail summary:hover { color: var(--text); }
.error-detail pre {
margin: 0.4rem 0 0;
padding: 0.6rem 0.7rem;
background: rgba(0,0,0,0.25);
border-radius: 6px;
font-size: 0.7rem;
line-height: 1.45;
color: var(--muted);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rt-error { font-size: 0.8rem; color: #ff8080; padding: 0.4rem 0; } .rt-error { font-size: 0.8rem; color: #ff8080; padding: 0.4rem 0; }
/* ── Footer quote ── */ /* ── Footer quote ── */
@ -820,7 +877,12 @@
} }
header h1 { font-size: 1.05rem; } header h1 { font-size: 1.05rem; }
.header-sub { display: none; } .header-sub { display: none; }
/* Title and Settings share the first row; the pill strip wraps below */
.header-left { order: 1; min-width: 0; flex: 1 1 auto; gap: 0.6rem; }
.header-left h1 { overflow-wrap: anywhere; }
.header-right { order: 2; margin-left: auto; flex: 0 0 auto; }
.header-services { .header-services {
order: 3;
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -901,12 +963,28 @@
.rt-title { font-size: 0.8rem; } .rt-title { font-size: 0.8rem; }
.rt-meta { font-size: 0.68rem; } .rt-meta { font-size: 0.68rem; }
.svc-pill-stat { display: none; } .svc-pill-stat { display: none; }
.header-right { display: none; } /* Keep the Settings link reachable on mobile — only the clock/room drop out */
.header-meta { display: none; }
.settings-label { display: none; }
.settings-link { padding: 0; }
.stat .value { font-size: 1.4rem; } .stat .value { font-size: 1.4rem; }
.search-bar input { font-size: 16px; } /* prevent iOS zoom */ .search-bar input { font-size: 16px; } /* prevent iOS zoom */
} }
</style> </style>
</head> </head>
{#- Renders a degraded service as human copy, with the raw exception tucked
behind a disclosure for debugging. -#}
{% macro service_error(name, raw) %}
<div class="error" role="alert">
<div>{{ raw | friendly_error(name) }}</div>
{% if raw %}
<details class="error-detail">
<summary>Technical details</summary>
<pre>{{ raw }}</pre>
</details>
{% endif %}
</div>
{% endmacro %}
<body> <body>
<header> <header>
@ -1064,16 +1142,20 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<div id="header-clock" style="font-size:1.1rem; font-family:var(--brand-font); color:var(--gold); letter-spacing:1px;"></div> <div class="header-meta">
<div style="font-size:0.7rem; color:var(--muted); margin-top:0.1rem;"> <div id="header-clock" class="header-clock"></div>
<div class="header-room">
{% if theme == 'office' %}Dunder Mifflin HQ {% if theme == 'office' %}Dunder Mifflin HQ
{% elif theme == 'parks' %}Pawnee City Hall {% elif theme == 'parks' %}Pawnee City Hall
{% elif theme == 'potter' %}Hogwarts Great Hall {% elif theme == 'potter' %}Hogwarts Great Hall
{% elif theme == 'starwars' %}Death Star Control Room {% elif theme == 'starwars' %}Death Star Control Room
{% else %}Study Room F{% endif %} {% else %}Study Room F{% endif %}
&nbsp;·&nbsp; <a href="/settings" style="color:var(--muted); text-decoration:none;">⚙ Settings</a>
</div> </div>
</div> </div>
<a href="{{ url_for('settings_page') }}" class="settings-link" aria-label="Settings">
<span aria-hidden="true"></span><span class="settings-label">Settings</span>
</a>
</div>
</header> </header>
<main> <main>
@ -1138,7 +1220,7 @@
</div> </div>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="error">Could not load system stats: {{ unraid.error }}</div> {{ service_error("Unraid", unraid.error) }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -1174,7 +1256,7 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="error">Could not reach Sonarr: {{ sonarr.error }}</div> {{ service_error("Sonarr", sonarr.error) }}
{% endif %} {% endif %}
<div class="section-title">🍅 Popular on RT</div> <div class="section-title">🍅 Popular on RT</div>
@ -1242,7 +1324,7 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="error">Could not reach Radarr: {{ radarr.error }}</div> {{ service_error("Radarr", radarr.error) }}
{% endif %} {% endif %}
<div class="section-title">🍅 Top Box Office on RT</div> <div class="section-title">🍅 Top Box Office on RT</div>
@ -1301,7 +1383,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="error">Could not reach Prowlarr: {{ prowlarr.error }}</div> {{ service_error("Prowlarr", prowlarr.error) }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -1351,7 +1433,7 @@
⏱ ETA: <span id="sab-timeleft"></span> ⏱ ETA: <span id="sab-timeleft"></span>
</div> </div>
{% else %} {% else %}
<div class="error">Could not reach SABnzbd: {{ sabnzbd.error }}</div> {{ service_error("SABnzbd", sabnzbd.error) }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -1387,7 +1469,7 @@
<div style="color:var(--muted); font-size:0.85rem;">No recent requests.</div> <div style="color:var(--muted); font-size:0.85rem;">No recent requests.</div>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="error">Could not reach Ombi: {{ ombi.error }}</div> {{ service_error("Ombi", ombi.error) }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -1509,7 +1591,7 @@
</div> </div>
{% else %} {% else %}
<div class="error">Could not reach Tautulli: {{ tautulli.error }}</div> {{ service_error("Tautulli", tautulli.error) }}
{% endif %} {% endif %}
</div> </div>
</div> </div>