diff --git a/CLAUDE.md b/CLAUDE.md index 667a97b..24567b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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" = Mon–Fri. 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. diff --git a/app.py b/app.py index b3a0094..19c3773 100644 --- a/app.py +++ b/app.py @@ -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. + # 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 ("", "", "", ""): 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 . 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 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: diff --git a/config.py b/config.py index b260213..f32ed3f 100644 --- a/config.py +++ b/config.py @@ -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, diff --git a/models.py b/models.py index dae3c2f..427ea0d 100644 --- a/models.py +++ b/models.py @@ -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,36 +908,70 @@ 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""" SELECT - {date_expr} AS check_date, + {date_expr} AS check_date, u.username, - COALESCE(u.full_name, u.username) AS full_name, - w.name AS website_name, + COALESCE(u.full_name, u.username) AS full_name, + 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 - AND u.role = 'user' + 'Not Checked' AS status + 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() diff --git a/views/admin_shifts_view.py b/views/admin_shifts_view.py index 02d2300..61662b8 100644 --- a/views/admin_shifts_view.py +++ b/views/admin_shifts_view.py @@ -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}") diff --git a/views/admin_users_view.py b/views/admin_users_view.py index dc073ff..3887f7a 100644 --- a/views/admin_users_view.py +++ b/views/admin_users_view.py @@ -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) diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index 40f1bd5..a99a084 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -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("", _update_char_count) + # Also update when content is inserted programmatically (edit pre-fill) + self._desc_txt.bind("<>", _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() diff --git a/views/user_dashboard_view.py b/views/user_dashboard_view.py index 35dda01..aebe60c 100644 --- a/views/user_dashboard_view.py +++ b/views/user_dashboard_view.py @@ -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"], - "slow": 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") + dot_col = { + "ok": COLOURS["success"], + "slow": COLOURS["warning"], + "restricted": COLOURS["warning"], + "down": COLOURS["danger"], + }.get(status_str, COLOURS["text_dim"]) + dot_tip = { + "ok": f"Reachable ({ms}ms)", + "slow": f"Slow ({ms}ms)", + "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://")): @@ -436,16 +455,28 @@ class UserDashboardView(ttk.Frame): req.add_header("User-Agent", "WebsiteChecker/1.0 HealthProbe") t0 = time.monotonic() urllib.request.urlopen(req, timeout=6) - ms = int((time.monotonic() - t0) * 1000) + 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"], - "slow": COLOURS["warning"], - "down": COLOURS["danger"]}.get(status, COLOURS["text_dim"]) + col = { + "ok": COLOURS["success"], + "slow": COLOURS["warning"], + "restricted": COLOURS["warning"], + "down": COLOURS["danger"], + }.get(status, COLOURS["text_dim"]) # Update the dot on the main thread try: