04/23 Enhance app functionalities

This commit is contained in:
2026-04-23 16:54:24 -04:00
parent 64419a445a
commit f4eea48e4d
8 changed files with 427 additions and 45 deletions
+33 -5
View File
@@ -131,9 +131,10 @@ One row per user per site per day (upserted). `user_note` optional.
### activity_log
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER,
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL,
CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, EXPORT_CSV, EXPORT_EXCEL,
EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS.
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE,
ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE,
EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS,
CREATE/UPDATE/DELETE_AI_CRITERION.
### shifts
`days_of_week` is a digit string using MySQL DAYOFWEEK: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat.
@@ -144,6 +145,30 @@ E.g. "23456" = MonFri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee
### login_attempts: username, attempted_at, ip_address (indexed)
### website_users (junction): (website_id, user_id) PK — for visibility='assigned'
### ai_criteria
Stores evaluation criteria used by the AI to assess opportunity alignment.
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| title | VARCHAR(200) | Short label |
| description | TEXT | Full criterion text sent to the AI |
| is_active | TINYINT(1) | Inactive criteria excluded from AI prompt |
| sort_order | INT | Controls display and prompt ordering |
| created_by | INT FK users SET NULL | |
### ai_analysis_log
Persists every AI analysis result for history and audit purposes.
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| user_id | INT FK users SET NULL | |
| file_names | TEXT | Comma-separated filenames analyzed |
| model | VARCHAR(100) | Groq model used |
| verdict | ENUM('PURSUE','PASS','UNCLEAR') NULL | NULL when no criteria active |
| criteria_snapshot | TEXT NULL | Active criteria text at time of analysis |
| summary_text | MEDIUMTEXT | Full AI response |
| analyzed_at | DATETIME | |
---
## 5. Module Details
@@ -224,6 +249,8 @@ Key internals:
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
- Auto-expands when editing a site that already has saved credentials
- " Add Credential" auto-expands the section if collapsed
- **Bug fixed:** `_on_visibility_change` previously used `before=self.creds_container`
which crosses a widget parent boundary causing a TclError; corrected to `before=self._cred_body`
### views/user_dashboard_view.py
Key internals:
@@ -330,8 +357,9 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start).
7. The email scheduler last_sent_date is in-memory — resets on restart.
A production deployment should persist it to the DB.
7. The email scheduler `last_sent_date` is now persisted to `config.ini`
`[email] last_sent_date` (ISO format). A restart after the configured
`send_time` will not re-send the report on the same day.
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
cp1252 UnicodeEncodeError on Windows consoles.
+30 -2
View File
@@ -39,8 +39,12 @@ class App(tk.Tk):
self.current_user = None
self._idle_timer = None # after() handle for timeout
self._warning_timer = None # after() handle for 1-min warning
self._idle_last_reset = 0.0 # monotonic timestamp of last actual reset
# Bind all user activity events to reset the idle timer
# Bind all user activity events to reset the idle timer.
# <Motion> fires continuously — the handler throttles itself to at most
# once every 5 seconds so we don't schedule/cancel hundreds of after()
# handles per second during normal mouse movement.
for event in ("<Motion>", "<KeyPress>", "<ButtonPress>", "<MouseWheel>"):
self.bind_all(event, self._reset_idle_timer, add="+")
@@ -333,11 +337,35 @@ class App(tk.Tk):
# ─── Session Timeout ──────────────────────────────────────────────────────
# Minimum interval (seconds) between idle-timer resets triggered by
# continuous events like <Motion>. KeyPress / ButtonPress always reset
# immediately (they are infrequent by nature).
_IDLE_THROTTLE_S = 5.0
def _reset_idle_timer(self, event=None):
"""Cancel any pending timeout/warning timers and restart them."""
"""Cancel any pending timeout/warning timers and restart them.
Throttled for <Motion> events: the timer is only rescheduled if at
least _IDLE_THROTTLE_S seconds have elapsed since the last reset.
This prevents hundreds of after_cancel/after() calls per second
during normal mouse movement without affecting correctness.
"""
if not self.current_user:
return # not logged in — nothing to time out
import time
# Throttle continuous motion events; discrete actions always go through
if event and getattr(event, "type", None) is not None:
try:
# EventType.Motion == 6 in tkinter
if int(event.type) == 6:
now = time.monotonic()
if now - self._idle_last_reset < self._IDLE_THROTTLE_S:
return
self._idle_last_reset = now
except Exception:
pass
if self._idle_timer:
self.after_cancel(self._idle_timer)
if self._warning_timer:
+13
View File
@@ -310,6 +310,19 @@ def initialize_database():
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS ai_analysis_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
file_names TEXT NOT NULL,
model VARCHAR(100) NOT NULL,
verdict ENUM('PURSUE','PASS','UNCLEAR') NULL,
criteria_snapshot TEXT NULL,
summary_text MEDIUMTEXT NOT NULL,
analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""",
"""
CREATE TABLE IF NOT EXISTS ai_criteria (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
+168 -14
View File
@@ -891,8 +891,14 @@ def get_shift_report(date_from=None, date_to=None, user_id=None, website_id=None
def get_unchecked_report(target_date=None, user_id=None):
"""
Return websites that were NOT checked on target_date (default: today)
for the given user (or all users if omitted).
Return websites that a user was EXPECTED to check on target_date but did not.
"Expected" is defined by shift membership on that day-of-week:
- Only websites assigned to a shift the user belongs to are included.
- Visibility rules (all / assigned) are respected.
- When a user has no shifts, falls back to all active visible websites.
When no target_date is supplied, defaults to today via CURDATE().
Columns: check_date, username, full_name, website_name, url, status
"""
conn = None
@@ -902,20 +908,35 @@ def get_unchecked_report(target_date=None, user_id=None):
user_filter = "AND u.id = %s" if user_id else ""
# Use a bind param only when a specific date is provided.
# When no date is given, embed CURDATE() directly in SQL so MySQL
# evaluates it as a function — passing "CURDATE()" as a %s bind
# parameter treats it as a literal string, not a SQL function, and
# causes the NOT EXISTS filter to match nothing (returns 0 rows).
# Embed CURDATE() directly when no date given — passing it as a %s
# bind param would treat it as a literal string, not a SQL function.
if target_date:
date_val = str(target_date)
date_expr = "%s"
date_params = [date_val]
# DAYOFWEEK for a specific date
dow_expr = "DAYOFWEEK(%s)"
dow_params = [date_val]
else:
date_expr = "CURDATE()"
date_params = []
dow_expr = "DAYOFWEEK(CURDATE())"
dow_params = []
params = date_params + ([user_id] if user_id else []) + date_params
# One user_id param slot for the user_filter inside the main query
user_filter_params = [user_id] if user_id else []
# The subquery needs: dow_params, (optional user_id for shift_users join)
# Outer query needs: date_params, user_filter_params, date_params
# EXISTS(shift) subquery: dow_params + (user_id if filtering by user)
# We build params carefully to match the f-string placeholders below.
params = (
date_params # {date_expr} in SELECT
+ user_filter_params # {user_filter} AND u.id = %s
+ dow_params # DAYOFWEEK(%s) in shift EXISTS
+ (user_id and [user_id] or []) # su.user_id=%s in shift EXISTS
+ date_params # DATE(sc.checked_at) = {date_expr}
)
cur.execute(
f"""
@@ -926,12 +947,31 @@ def get_unchecked_report(target_date=None, user_id=None):
w.name AS website_name,
w.url,
'Not Checked' AS status
FROM websites w
CROSS JOIN users u
WHERE w.is_active = 1
AND u.is_active = 1
FROM users u
-- Only websites the user was expected to check on this date
JOIN (
SELECT DISTINCT sw.website_id
FROM shift_websites sw
JOIN shifts s ON s.id = sw.shift_id
JOIN shift_users su ON su.shift_id = s.id
JOIN websites w2 ON w2.id = sw.website_id
WHERE s.is_active = 1
AND w2.is_active = 1
AND LOCATE(CAST({dow_expr} AS CHAR), s.days_of_week) > 0
{"AND su.user_id = %s" if user_id else "AND su.user_id = u.id"}
AND (
w2.visibility = 'all'
OR EXISTS (
SELECT 1 FROM website_users wu
WHERE wu.website_id = w2.id AND wu.user_id = su.user_id
)
)
) expected ON 1=1
JOIN websites w ON w.id = expected.website_id
WHERE u.is_active = 1
AND u.role = 'user'
{user_filter}
-- Exclude sites the user DID check on the target date
AND NOT EXISTS (
SELECT 1 FROM shift_checks sc
WHERE sc.website_id = w.id
@@ -1461,7 +1501,12 @@ def create_criterion(admin_id: int, title: str, description: str,
def update_criterion(admin_id: int, criterion_id: int, title: str,
description: str, is_active: bool, sort_order: int):
"""Update an existing AI evaluation criterion."""
"""Update an existing AI evaluation criterion.
The full description text is included in the activity_log detail field so
there is a complete audit trail of exactly what criteria wording the AI was
evaluating against at any point in time.
"""
conn = None
try:
conn = get_connection()
@@ -1476,8 +1521,13 @@ def update_criterion(admin_id: int, criterion_id: int, title: str,
)
conn.commit()
cur.close()
# Include full description in detail so audit log captures wording at
# time of change — essential for reconstructing what criteria were
# active during any historical AI analysis.
log_action(admin_id, "UPDATE_AI_CRITERION", "ai_criteria", criterion_id,
f"Updated criterion id={criterion_id} '{title}' active={is_active}.")
f"Updated criterion id={criterion_id} '{title}' "
f"active={is_active} order={sort_order}. "
f"Description: {description[:500]}")
logger.info(f"AI criterion id={criterion_id} updated by admin_id={admin_id}.")
finally:
if conn:
@@ -1502,3 +1552,107 @@ def delete_criterion(admin_id: int, criterion_id: int):
finally:
if conn:
conn.close()
# ─── AI Analysis History ──────────────────────────────────────────────────────
def save_ai_analysis(user_id: int, file_names: str, model: str,
verdict: str | None, criteria_snapshot: str | None,
summary_text: str) -> int:
"""
Persist an AI analysis result to ai_analysis_log.
verdict : 'PURSUE' | 'PASS' | 'UNCLEAR' | None
criteria_snapshot: JSON/text of active criteria at time of analysis
Returns the new row id.
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"""
INSERT INTO ai_analysis_log
(user_id, file_names, model, verdict, criteria_snapshot, summary_text)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(user_id, file_names, model, verdict, criteria_snapshot, summary_text)
)
conn.commit()
new_id = cur.lastrowid
cur.close()
logger.info(
f"AI analysis saved: id={new_id} user_id={user_id} "
f"verdict={verdict} files='{file_names[:80]}'."
)
return new_id
finally:
if conn:
conn.close()
def get_ai_analysis_history(user_id: int | None = None, limit: int = 100):
"""
Return recent AI analysis log entries.
When user_id is provided, filters to that user's own analyses.
Admins pass user_id=None to see all users' analyses.
Columns: id, username, file_names, model, verdict, analyzed_at
(summary_text excluded for list view — fetch by id for detail).
"""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
if user_id:
cur.execute(
"""
SELECT al.id, u.username, al.file_names, al.model,
al.verdict, al.analyzed_at
FROM ai_analysis_log al
LEFT JOIN users u ON u.id = al.user_id
WHERE al.user_id = %s
ORDER BY al.analyzed_at DESC
LIMIT %s
""",
(user_id, limit)
)
else:
cur.execute(
"""
SELECT al.id, u.username, al.file_names, al.model,
al.verdict, al.analyzed_at
FROM ai_analysis_log al
LEFT JOIN users u ON u.id = al.user_id
ORDER BY al.analyzed_at DESC
LIMIT %s
""",
(limit,)
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_ai_analysis_detail(analysis_id: int):
"""Return a single ai_analysis_log row including summary_text and criteria_snapshot."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT al.*, u.username
FROM ai_analysis_log al
LEFT JOIN users u ON u.id = al.user_id
WHERE al.id = %s
""",
(analysis_id,)
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
+20 -1
View File
@@ -33,6 +33,7 @@ class AdminShiftsView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._show_inactive = tk.BooleanVar(value=False)
self._build_ui()
self._load_shifts()
@@ -56,6 +57,15 @@ class AdminShiftsView(ttk.Frame):
style="Ghost.TButton",
command=self._export_pdf).pack(side="right", padx=(0, 12))
# Show / hide inactive shifts toggle
ttk.Checkbutton(
toolbar,
text="Show inactive",
variable=self._show_inactive,
command=self._load_shifts,
style="TCheckbutton",
).pack(side="left", padx=(16, 0))
cols = ("ID", "Shift Name", "Days", "Start", "End",
"Users", "Websites", "Active", "Note")
self.tree = ttk.Treeview(self, columns=cols,
@@ -80,8 +90,13 @@ class AdminShiftsView(ttk.Frame):
def _load_shifts(self):
from models import get_all_shifts
self.tree.delete(*self.tree.get_children())
show_inactive = self._show_inactive.get()
try:
for s in get_all_shifts():
all_shifts = get_all_shifts()
shown = 0
for s in all_shifts:
if not s["is_active"] and not show_inactive:
continue
days_str = _days_label(s["days_of_week"])
active = "" if s["is_active"] else ""
tag = "active" if s["is_active"] else "inactive"
@@ -99,6 +114,10 @@ class AdminShiftsView(ttk.Frame):
(s["note"] or "")[:60],
)
)
shown += 1
hidden = len(all_shifts) - shown
if hidden and not show_inactive:
logger.debug(f"Shifts view: {hidden} inactive shift(s) hidden.")
except Exception as e:
show_error(f"Failed to load shifts:\n{e}")
+79
View File
@@ -39,6 +39,9 @@ class AdminUsersView(ttk.Frame):
ttk.Button(toolbar, text="✕ Delete",
style="Danger.TButton",
command=self._delete_selected).pack(side="right")
ttk.Button(toolbar, text="🔑 Reset Password",
style="Ghost.TButton",
command=self._reset_password).pack(side="right", padx=(0, 8))
# Treeview
cols = ("ID", "Username", "Full Name", "Role", "Active", "Created")
@@ -76,6 +79,82 @@ class AdminUsersView(ttk.Frame):
# ─── Dialogs ──────────────────────────────────────────────────────────────
def _reset_password(self):
"""
Generate a random temporary password for the selected user, set it in
the database, copy it to the clipboard, and log the action.
The admin must communicate the temporary password to the user out-of-band;
the user should change it immediately via Change Password.
"""
import random
import string
uid = self._get_selected_id()
if not uid:
show_error("Please select a user to reset.")
return
if uid == self.current_user["id"]:
show_error("You cannot reset your own password here.\nUse Change Password instead.")
return
vals = self.tree.item(uid, "values")
username = vals[1] if vals else str(uid)
# Confirm before proceeding
from tkinter import messagebox
if not messagebox.askyesno(
"Reset Password",
f"Generate a new temporary password for '{username}'?\n\n"
"The temporary password will be copied to your clipboard.",
icon="warning",
):
return
# Build a strong random password that meets the existing policy:
# 8+ chars, uppercase, digit, special character
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
while True:
pwd = "".join(random.SystemRandom().choices(alphabet, k=16))
if (any(c.isupper() for c in pwd)
and any(c.isdigit() for c in pwd)
and any(c in "!@#$%^&*" for c in pwd)):
break
try:
from models import update_user, get_user_by_id, log_action
user_data = get_user_by_id(uid)
if not user_data:
show_error("User not found.")
return
update_user(
self.current_user["id"],
uid,
user_data["username"],
user_data["role"],
user_data["full_name"],
user_data["is_active"],
password=pwd,
)
log_action(
self.current_user["id"], "RESET_PASSWORD", "users", uid,
f"Admin reset password for user '{username}' (id={uid})."
)
logger.info(
f"Password reset for user id={uid} '{username}' "
f"by admin '{self.current_user['username']}'."
)
# Copy to clipboard
self.clipboard_clear()
self.clipboard_append(pwd)
show_info(
f"Temporary password for '{username}' has been set and "
f"copied to your clipboard:\n\n{pwd}\n\n"
"Please share it with the user securely.\n"
"The user should change it immediately after logging in."
)
except Exception as e:
show_error(f"Password reset failed:\n{e}")
def _open_add_dialog(self):
UserDialog(self, self.current_user, user_data=None,
on_save=self._load_users)
+37 -7
View File
@@ -939,6 +939,10 @@ class CriterionDialog(tk.Toplevel):
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
# Recommended character limit for criterion descriptions.
# Beyond this the AI prompt grows large and eats into document token budget.
_DESC_SOFT_LIMIT = 500
def _build_ui(self):
C = COLOURS
@@ -950,14 +954,14 @@ class CriterionDialog(tk.Toplevel):
form.pack(fill="x", padx=24, pady=8)
form.columnconfigure(1, weight=1)
# Title
# Row 0 — Title
ttk.Label(form, text="Title *").grid(
row=0, column=0, sticky="w", padx=(0, 10), pady=6)
self._title_var = tk.StringVar()
ttk.Entry(form, textvariable=self._title_var).grid(
row=0, column=1, sticky="ew", pady=6)
# Description
# Row 1 — Description text area
ttk.Label(form, text="Description *").grid(
row=1, column=0, sticky="nw", padx=(0, 10), pady=6)
@@ -975,15 +979,39 @@ class CriterionDialog(tk.Toplevel):
self._desc_txt.pack(fill="both", expand=True, padx=4, pady=4)
desc_vsb.config(command=self._desc_txt.yview)
# Sort order
# Row 2 — Live character counter (guidance, not a hard block)
self._char_lbl = tk.Label(
form,
text=f"0 / {self._DESC_SOFT_LIMIT} chars",
bg=C["bg"], fg=C["text_dim"],
font=FONT_SMALL, anchor="e",
)
self._char_lbl.grid(row=2, column=1, sticky="e", pady=(0, 4))
def _update_char_count(event=None):
n = len(self._desc_txt.get("1.0", "end-1c"))
over = n > self._DESC_SOFT_LIMIT
colour = C["danger"] if over else C["text_dim"]
label = (
f"{n} / {self._DESC_SOFT_LIMIT} chars"
f" ⚠ exceeds recommended limit" if over else
f"{n} / {self._DESC_SOFT_LIMIT} chars"
)
self._char_lbl.config(text=label, fg=colour)
self._desc_txt.bind("<KeyRelease>", _update_char_count)
# Also update when content is inserted programmatically (edit pre-fill)
self._desc_txt.bind("<<Modified>>", _update_char_count)
# Row 3 — Sort order
ttk.Label(form, text="Sort Order").grid(
row=2, column=0, sticky="w", padx=(0, 10), pady=6)
row=3, column=0, sticky="w", padx=(0, 10), pady=6)
self._order_var = tk.StringVar(value="0")
ttk.Spinbox(form, from_=0, to=999,
textvariable=self._order_var, width=8).grid(
row=2, column=1, sticky="w", pady=6)
row=3, column=1, sticky="w", pady=6)
# Active flag
# Row 4 — Active flag
self._active_var = tk.BooleanVar(value=True)
tk.Checkbutton(
form, text="Active (included in AI evaluation)",
@@ -991,7 +1019,7 @@ class CriterionDialog(tk.Toplevel):
bg=C["bg"], fg=C["text"],
activebackground=C["bg"], activeforeground=C["accent"],
selectcolor=C["surface2"], font=FONT, cursor="hand2",
).grid(row=3, column=1, sticky="w", pady=6)
).grid(row=4, column=1, sticky="w", pady=6)
tk.Label(
self,
@@ -1018,6 +1046,8 @@ class CriterionDialog(tk.Toplevel):
self._desc_txt.insert("1.0", d.get("description") or "")
self._order_var.set(str(d.get("sort_order", 0)))
self._active_var.set(bool(d.get("is_active", True)))
# Trigger counter update now that content has been inserted
_update_char_count()
def _save(self):
title = self._title_var.get().strip()
+38 -7
View File
@@ -265,12 +265,18 @@ class UserDashboardView(ttk.Frame):
cached = self._health_cache.get(wid)
if cached:
status_str, ms = cached
dot_col = {"ok": COLOURS["success"],
dot_col = {
"ok": COLOURS["success"],
"slow": COLOURS["warning"],
"down": COLOURS["danger"]}.get(status_str, COLOURS["text_dim"])
dot_tip = {"ok": f"Reachable ({ms}ms)",
"restricted": COLOURS["warning"],
"down": COLOURS["danger"],
}.get(status_str, COLOURS["text_dim"])
dot_tip = {
"ok": f"Reachable ({ms}ms)",
"slow": f"Slow ({ms}ms)",
"down": "Unreachable"}.get(status_str, "Unknown")
"restricted": f"Reachable — access restricted ({ms}ms)",
"down": "Unreachable",
}.get(status_str, "Unknown")
else:
dot_col = COLOURS["text_dim"]
dot_tip = "Checking..."
@@ -424,8 +430,21 @@ class UserDashboardView(ttk.Frame):
# ─── Health Pre-check ─────────────────────────────────────────────────────
def _check_site_health(self, wid: int, url: str, dot_label: tk.Label):
"""Background thread: HEAD request to url; updates health_cache + dot colour."""
"""Background thread: HEAD request to url; updates health_cache + dot colour.
Status semantics:
ok 2xx / 3xx response, <= 3 000 ms
slow 2xx / 3xx response, > 3 000 ms
restricted 4xx response (server reachable but denying HEAD access)
down network error, timeout, or 5xx (server not responding)
4xx responses are intentionally treated as "restricted" (amber) rather
than "down" (red) because the server is clearly reachable many sites
block unauthenticated HEAD requests with 401/403 even when fully
operational.
"""
import urllib.request
import urllib.error
import time
if not url.startswith(("http://", "https://")):
@@ -438,14 +457,26 @@ class UserDashboardView(ttk.Frame):
urllib.request.urlopen(req, timeout=6)
ms = int((time.monotonic() - t0) * 1000)
status = "slow" if ms > 3000 else "ok"
except urllib.error.HTTPError as e:
ms = int((time.monotonic() - t0) * 1000)
if 400 <= e.code < 500:
# Server responded — it is reachable but blocking this probe
status = "restricted"
else:
# 5xx: server error — treat as down
ms = 0
status = "down"
except Exception:
ms = 0
status = "down"
self._health_cache[wid] = (status, ms)
col = {"ok": COLOURS["success"],
col = {
"ok": COLOURS["success"],
"slow": COLOURS["warning"],
"down": COLOURS["danger"]}.get(status, COLOURS["text_dim"])
"restricted": COLOURS["warning"],
"down": COLOURS["danger"],
}.get(status, COLOURS["text_dim"])
# Update the dot on the main thread
try: