webstat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ """Notification dispatcher for Omarchy and standard desktop environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from typing import Any
11
+
12
+ from webstat.core.models import CheckResult, MonitoredSite
13
+
14
+
15
+ def _clean_notification_text(text: str) -> str:
16
+ """Sanitize notification text by stripping control characters and carriage returns/newlines."""
17
+ cleaned = text.replace("\r", " ").replace("\n", " ")
18
+ return re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned).strip()
19
+
20
+
21
+ class BaseNotifier(abc.ABC):
22
+ """Abstract base class for notification providers."""
23
+
24
+ @abc.abstractmethod
25
+ def notify_up(self, site: MonitoredSite, result: CheckResult) -> bool:
26
+ """Trigger notification that a site is back up."""
27
+ pass
28
+
29
+
30
+ class OmarchyNotifier(BaseNotifier):
31
+ """Native Omarchy desktop notification using `omarchy notification send`."""
32
+
33
+ def __init__(self):
34
+ self.omarchy_bin = shutil.which("omarchy")
35
+
36
+ def is_available(self) -> bool:
37
+ return self.omarchy_bin is not None
38
+
39
+ def notify_up(self, site: MonitoredSite, result: CheckResult) -> bool:
40
+ if not self.is_available():
41
+ return False
42
+
43
+ headline = _clean_notification_text(f"Site is UP: {site.display_name}")
44
+ latency_str = f" in {result.latency_ms}ms" if result.latency_ms else ""
45
+ code_str = f"{result.status_code} " if result.status_code else ""
46
+ desc = _clean_notification_text(f"{site.url} is back online ({code_str}OK{latency_str}). Click to open.")
47
+
48
+ cmd = [
49
+ self.omarchy_bin,
50
+ "notification",
51
+ "send",
52
+ "--app-name",
53
+ "webstat",
54
+ "-g",
55
+ "globe",
56
+ "-u",
57
+ "normal",
58
+ headline,
59
+ desc,
60
+ "--exec",
61
+ "xdg-open",
62
+ site.url,
63
+ ]
64
+
65
+ try:
66
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
67
+ return True
68
+ except Exception:
69
+ return False
70
+
71
+
72
+ class FreeDesktopNotifier(BaseNotifier):
73
+ """Standard Linux desktop notification via `notify-send`."""
74
+
75
+ def __init__(self):
76
+ self.notify_bin = shutil.which("notify-send")
77
+
78
+ def is_available(self) -> bool:
79
+ return self.notify_bin is not None
80
+
81
+ def notify_up(self, site: MonitoredSite, result: CheckResult) -> bool:
82
+ if not self.is_available():
83
+ return False
84
+
85
+ headline = _clean_notification_text(f"Site is UP: {site.display_name}")
86
+ desc = _clean_notification_text(f"{site.url} is back online!")
87
+ cmd = [self.notify_bin, "-a", "webstat", "-i", "network-idle", headline, desc]
88
+
89
+ try:
90
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
91
+ return True
92
+ except Exception:
93
+ return False
94
+
95
+
96
+ class BrowserLauncher(BaseNotifier):
97
+ """Directly opens the default browser using xdg-open."""
98
+
99
+ def notify_up(self, site: MonitoredSite, result: CheckResult) -> bool:
100
+ if not site.auto_open:
101
+ return False
102
+ xdg_bin = shutil.which("xdg-open")
103
+ if not xdg_bin:
104
+ return False
105
+ try:
106
+ subprocess.Popen(
107
+ [xdg_bin, site.url],
108
+ stdout=subprocess.DEVNULL,
109
+ stderr=subprocess.DEVNULL,
110
+ start_new_session=True,
111
+ )
112
+ return True
113
+ except Exception:
114
+ return False
115
+
116
+
117
+ class TerminalBellNotifier(BaseNotifier):
118
+ """Audible terminal bell trigger."""
119
+
120
+ def notify_up(self, site: MonitoredSite, result: CheckResult) -> bool:
121
+ try:
122
+ sys.stdout.write("\a")
123
+ sys.stdout.flush()
124
+ return True
125
+ except Exception:
126
+ return False
127
+
128
+
129
+ class NotificationDispatcher:
130
+ """Dispatches notifications across all applicable channels."""
131
+
132
+ def __init__(self, with_bell: bool = True):
133
+ self.omarchy = OmarchyNotifier()
134
+ self.freedesktop = FreeDesktopNotifier()
135
+ self.browser = BrowserLauncher()
136
+ self.bell = TerminalBellNotifier()
137
+ self.with_bell = with_bell
138
+
139
+ def dispatch_site_up(self, site: MonitoredSite, result: CheckResult) -> dict[str, bool]:
140
+ results: dict[str, bool] = {}
141
+
142
+ # 1. Desktop Notification (Omarchy first, fallback to notify-send)
143
+ if site.notify_on_up:
144
+ if self.omarchy.is_available():
145
+ results["omarchy"] = self.omarchy.notify_up(site, result)
146
+ elif self.freedesktop.is_available():
147
+ results["desktop"] = self.freedesktop.notify_up(site, result)
148
+
149
+ # 2. Browser Auto-Open
150
+ if site.auto_open:
151
+ results["browser_open"] = self.browser.notify_up(site, result)
152
+
153
+ # 3. Terminal Bell
154
+ if self.with_bell:
155
+ results["bell"] = self.bell.notify_up(site, result)
156
+
157
+ return results
@@ -0,0 +1,110 @@
1
+ """Monitoring loop and scheduler for watching website status and triggering reminders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Callable
8
+
9
+ from webstat.core.checker import HttpChecker
10
+ from webstat.core.models import CheckResult, MonitoredSite, SiteStatus
11
+ from webstat.core.notifier import NotificationDispatcher
12
+ from webstat.core.store import SiteStore
13
+
14
+
15
+ class WatchScheduler:
16
+ """Orchestrates polling sites, detecting recovery, and dispatching notifications."""
17
+
18
+ def __init__(
19
+ self,
20
+ store: SiteStore | None = None,
21
+ dispatcher: NotificationDispatcher | None = None,
22
+ checker: HttpChecker | None = None,
23
+ poll_interval: float = 5.0,
24
+ max_interval: float = 60.0,
25
+ adaptive_backoff: bool = True,
26
+ notification_cooldown: float = 60.0,
27
+ ):
28
+ self.store = store or SiteStore()
29
+ self.dispatcher = dispatcher or NotificationDispatcher()
30
+ self.checker = checker or HttpChecker()
31
+ self.poll_interval = poll_interval
32
+ self.max_interval = max_interval
33
+ self.adaptive_backoff = adaptive_backoff
34
+ self.notification_cooldown = notification_cooldown
35
+ self._running = False
36
+
37
+ def compute_delay(self, retries: int) -> float:
38
+ """Compute poll delay with optional adaptive backoff."""
39
+ if not self.adaptive_backoff:
40
+ return self.poll_interval
41
+ factor = min(retries // 3, 4)
42
+ return min(self.poll_interval * (1.5**factor), self.max_interval)
43
+
44
+ async def check_and_update(self, site: MonitoredSite) -> tuple[CheckResult, bool]:
45
+ """Check a single site, update store, and dispatch notification if recovered.
46
+
47
+ Returns (CheckResult, just_recovered: bool).
48
+ """
49
+ was_down = not site.is_up and site.status != SiteStatus.UNKNOWN
50
+ result = await self.checker.check(site.url)
51
+
52
+ site.status = result.status
53
+ site.status_code = result.status_code
54
+ site.latency_ms = result.latency_ms
55
+ site.last_checked = result.timestamp
56
+ site.last_error = result.error_message
57
+
58
+ just_recovered = False
59
+ now = time.time()
60
+ if result.is_up:
61
+ if was_down or site.retries > 0:
62
+ # Enforce cooldown window to debounce flapping endpoints and prevent browser/notification floods
63
+ if site.last_notified_at is None or (now - site.last_notified_at) >= self.notification_cooldown:
64
+ just_recovered = True
65
+ site.last_notified_at = now
66
+ self.dispatcher.dispatch_site_up(site, result)
67
+ site.retries = 0
68
+ else:
69
+ site.retries += 1
70
+
71
+ self.store.update_site(site)
72
+ return result, just_recovered
73
+
74
+ async def watch_sites(
75
+ self,
76
+ sites: list[MonitoredSite],
77
+ on_tick: Callable[[MonitoredSite, CheckResult, bool], None] | None = None,
78
+ ) -> None:
79
+ """Watch a list of sites until all are online (unless keep_watching is True)."""
80
+ self._running = True
81
+ active_sites = list(sites)
82
+
83
+ try:
84
+ while self._running and active_sites:
85
+ remaining: list[MonitoredSite] = []
86
+
87
+ for site in active_sites:
88
+ result, just_recovered = await self.check_and_update(site)
89
+ if on_tick:
90
+ on_tick(site, result, just_recovered)
91
+
92
+ if result.is_up and not site.keep_watching:
93
+ # Site is up and completed reminder
94
+ continue
95
+ remaining.append(site)
96
+
97
+ active_sites = remaining
98
+ if not active_sites:
99
+ break
100
+
101
+ # Determine sleep delay from min retry count
102
+ min_retries = min(s.retries for s in active_sites)
103
+ delay = self.compute_delay(min_retries)
104
+ await asyncio.sleep(delay)
105
+
106
+ finally:
107
+ self._running = False
108
+
109
+ def stop(self) -> None:
110
+ self._running = False
webstat/core/store.py ADDED
@@ -0,0 +1,210 @@
1
+ """Atomic XDG state storage for webstat monitored sites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ import fcntl
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import tempfile
12
+ from typing import Any
13
+
14
+ from webstat.core.models import MonitoredSite
15
+ from webstat.utils.url import get_display_name, normalize_url
16
+
17
+
18
+ def get_default_state_path() -> Path:
19
+ """Return the state file path respecting XDG_STATE_HOME with 0700 permissions."""
20
+ state_dir = os.environ.get("XDG_STATE_HOME")
21
+ if state_dir:
22
+ base = Path(state_dir)
23
+ else:
24
+ base = Path.home() / ".local" / "state"
25
+ target_dir = base / "webstat"
26
+ target_dir.mkdir(parents=True, exist_ok=True)
27
+ try:
28
+ os.chmod(target_dir, 0o700)
29
+ except OSError:
30
+ pass
31
+ return target_dir / "sites.json"
32
+
33
+
34
+ def generate_site_id(url: str) -> str:
35
+ """Generate a stable, clean identifier for a URL."""
36
+ normalized = normalize_url(url)
37
+ h = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:10]
38
+ host = get_display_name(normalized).replace(".", "-").replace(":", "-")
39
+ return f"{host}-{h}"
40
+
41
+
42
+ class SiteStore:
43
+ """Thread-safe and process-safe state store using atomic JSON file replacement and POSIX file locking."""
44
+
45
+ def __init__(self, file_path: Path | None = None):
46
+ self.file_path = file_path or get_default_state_path()
47
+ self._lock_depth = 0
48
+ self._lock_fd: int | None = None
49
+
50
+ @contextmanager
51
+ def _file_lock(self, shared: bool = False):
52
+ """Re-entrant file lock coordinator for multi-process safety (daemon, CLI, QML)."""
53
+ if self._lock_depth > 0:
54
+ self._lock_depth += 1
55
+ try:
56
+ yield
57
+ finally:
58
+ self._lock_depth -= 1
59
+ return
60
+
61
+ lock_path = self.file_path.with_suffix(".lock")
62
+ target_dir = lock_path.parent
63
+ target_dir.mkdir(parents=True, exist_ok=True)
64
+ try:
65
+ os.chmod(target_dir, 0o700)
66
+ except OSError:
67
+ pass
68
+
69
+ flags = fcntl.LOCK_SH if shared else fcntl.LOCK_EX
70
+ try:
71
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
72
+ try:
73
+ os.chmod(lock_path, 0o600)
74
+ except OSError:
75
+ pass
76
+ fcntl.flock(fd, flags)
77
+ self._lock_fd = fd
78
+ self._lock_depth = 1
79
+ try:
80
+ yield
81
+ finally:
82
+ self._lock_depth = 0
83
+ self._lock_fd = None
84
+ try:
85
+ fcntl.flock(fd, fcntl.LOCK_UN)
86
+ except OSError:
87
+ pass
88
+ os.close(fd)
89
+ except OSError:
90
+ # Fallback if filesystem does not support flock
91
+ yield
92
+
93
+ def load_all(self) -> list[MonitoredSite]:
94
+ with self._file_lock(shared=True):
95
+ if not self.file_path.exists():
96
+ return []
97
+ try:
98
+ with open(self.file_path, "r", encoding="utf-8") as f:
99
+ data: list[dict[str, Any]] = json.load(f)
100
+ return [MonitoredSite.from_dict(item) for item in data]
101
+ except Exception:
102
+ return []
103
+
104
+ def save_all(self, sites: list[MonitoredSite]) -> None:
105
+ raw_data = [s.to_dict() for s in sites]
106
+ target_dir = self.file_path.parent
107
+ target_dir.mkdir(parents=True, exist_ok=True)
108
+ try:
109
+ os.chmod(target_dir, 0o700)
110
+ except OSError:
111
+ pass
112
+
113
+ with self._file_lock(shared=False):
114
+ # Unpredictable atomic temp file with 0600 permissions
115
+ with tempfile.NamedTemporaryFile(
116
+ mode="w",
117
+ dir=target_dir,
118
+ encoding="utf-8",
119
+ delete=False,
120
+ prefix=".sites_",
121
+ suffix=".tmp",
122
+ ) as tmp:
123
+ temp_path = Path(tmp.name)
124
+ try:
125
+ os.chmod(temp_path, 0o600)
126
+ except OSError:
127
+ pass
128
+ json.dump(raw_data, tmp, indent=2, ensure_ascii=False)
129
+ tmp.flush()
130
+ os.fsync(tmp.fileno())
131
+
132
+ # Atomic replacement on POSIX
133
+ temp_path.replace(self.file_path)
134
+ try:
135
+ os.chmod(self.file_path, 0o600)
136
+ except OSError:
137
+ pass
138
+
139
+ def get_by_id_or_url(self, id_or_url: str) -> MonitoredSite | None:
140
+ sites = self.load_all()
141
+ clean = id_or_url.strip()
142
+ for s in sites:
143
+ if s.id == clean or s.url == clean or s.display_name == clean or s.raw_input == clean:
144
+ return s
145
+ try:
146
+ normalized = normalize_url(clean)
147
+ for s in sites:
148
+ if s.url == normalized:
149
+ return s
150
+ except Exception:
151
+ pass
152
+ return None
153
+
154
+ def add(
155
+ self,
156
+ raw_url: str,
157
+ auto_open: bool = False,
158
+ notify_on_up: bool = True,
159
+ keep_watching: bool = False,
160
+ ) -> MonitoredSite:
161
+ with self._file_lock(shared=False):
162
+ normalized = normalize_url(raw_url)
163
+ site_id = generate_site_id(normalized)
164
+ sites = self.load_all()
165
+
166
+ existing = next((s for s in sites if s.id == site_id), None)
167
+ if existing:
168
+ existing.auto_open = auto_open or existing.auto_open
169
+ existing.notify_on_up = notify_on_up
170
+ existing.keep_watching = keep_watching
171
+ self.save_all(sites)
172
+ return existing
173
+
174
+ site = MonitoredSite(
175
+ id=site_id,
176
+ url=normalized,
177
+ display_name=get_display_name(normalized),
178
+ raw_input=raw_url,
179
+ auto_open=auto_open,
180
+ notify_on_up=notify_on_up,
181
+ keep_watching=keep_watching,
182
+ )
183
+ sites.append(site)
184
+ self.save_all(sites)
185
+ return site
186
+
187
+ def update_site(self, site: MonitoredSite) -> None:
188
+ with self._file_lock(shared=False):
189
+ sites = self.load_all()
190
+ for idx, s in enumerate(sites):
191
+ if s.id == site.id:
192
+ sites[idx] = site
193
+ break
194
+ else:
195
+ sites.append(site)
196
+ self.save_all(sites)
197
+
198
+ def remove(self, id_or_url: str) -> bool:
199
+ with self._file_lock(shared=False):
200
+ site = self.get_by_id_or_url(id_or_url)
201
+ if not site:
202
+ return False
203
+ sites = self.load_all()
204
+ sites = [s for s in sites if s.id != site.id]
205
+ self.save_all(sites)
206
+ return True
207
+
208
+ def clear(self) -> None:
209
+ with self._file_lock(shared=False):
210
+ self.save_all([])
@@ -0,0 +1,5 @@
1
+ """Utility functions for webstat."""
2
+
3
+ from webstat.utils.url import get_display_name, normalize_url
4
+
5
+ __all__ = ["normalize_url", "get_display_name"]
@@ -0,0 +1,74 @@
1
+ """Formatting helpers for rich terminal output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+ from webstat.core.models import CheckResult, MonitoredSite, SiteStatus
9
+
10
+
11
+ def get_status_badge(status: SiteStatus, status_code: int | None = None) -> Text:
12
+ """Generate a stylized text badge for a site status."""
13
+ code_text = f" {status_code}" if status_code else ""
14
+
15
+ if status == SiteStatus.ONLINE:
16
+ return Text(f"● ONLINE{code_text}", style="bold green")
17
+ elif status == SiteStatus.REDIRECT:
18
+ return Text(f"↗ REDIRECT{code_text}", style="bold cyan")
19
+ elif status == SiteStatus.CLIENT_ERROR:
20
+ return Text(f"▲ CLIENT ERROR{code_text}", style="bold yellow")
21
+ elif status == SiteStatus.SERVER_ERROR:
22
+ return Text(f"✖ SERVER ERROR{code_text}", style="bold red")
23
+ elif status == SiteStatus.UNREACHABLE:
24
+ return Text("✖ UNREACHABLE", style="bold red on default")
25
+ else:
26
+ return Text("? UNKNOWN", style="dim")
27
+
28
+
29
+ def get_latency_text(latency_ms: float | None) -> Text:
30
+ """Format latency with appropriate color coding."""
31
+ if latency_ms is None:
32
+ return Text("—", style="dim")
33
+ if latency_ms < 200:
34
+ return Text(f"{latency_ms} ms", style="green")
35
+ elif latency_ms < 800:
36
+ return Text(f"{latency_ms} ms", style="yellow")
37
+ else:
38
+ return Text(f"{latency_ms} ms", style="magenta")
39
+
40
+
41
+ def build_sites_table(sites: list[MonitoredSite]) -> Table:
42
+ """Construct a clean Rich table of all monitored sites."""
43
+ table = Table(
44
+ title="Webstat Monitored Sites",
45
+ title_style="bold cyan",
46
+ header_style="bold",
47
+ border_style="dim",
48
+ expand=True,
49
+ )
50
+
51
+ table.add_column("Site", style="bold white", no_wrap=True)
52
+ table.add_column("Status", justify="left")
53
+ table.add_column("Latency", justify="right")
54
+ table.add_column("Retries", justify="right")
55
+ table.add_column("Auto Open", justify="center")
56
+ table.add_column("Last Error / Details", style="dim")
57
+
58
+ for site in sites:
59
+ badge = get_status_badge(site.status, site.status_code)
60
+ latency = get_latency_text(site.latency_ms)
61
+ retries = str(site.retries) if site.retries > 0 else "—"
62
+ auto_open = "✓ Yes" if site.auto_open else "No"
63
+ details = site.last_error or "—"
64
+
65
+ table.add_row(
66
+ site.display_name,
67
+ badge,
68
+ latency,
69
+ retries,
70
+ auto_open,
71
+ details,
72
+ )
73
+
74
+ return table
webstat/utils/url.py ADDED
@@ -0,0 +1,62 @@
1
+ """URL utilities for webstat."""
2
+
3
+ import re
4
+ from urllib.parse import urlparse
5
+
6
+
7
+ def _strip_ansi_and_control(text: str) -> str:
8
+ """Remove ANSI escape sequences and non-printable control characters."""
9
+ # First strip ANSI escape sequences (e.g. \x1b[31m)
10
+ no_ansi = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", text)
11
+ # Then strip ASCII/Latin-1 C0 and C1 control characters
12
+ return re.sub(r"[\x00-\x1f\x7f-\x9f]", "", no_ansi).strip()
13
+
14
+
15
+ def normalize_url(raw_url: str) -> str:
16
+ """Normalize a user-provided URL or hostname.
17
+
18
+ Examples:
19
+ - "github.com" -> "https://github.com"
20
+ - "localhost:3000" -> "http://localhost:3000"
21
+ - "127.0.0.1:8000" -> "http://127.0.0.1:8000"
22
+ - "http://example.com" -> "http://example.com"
23
+ - "https://mysite.org/health" -> "https://mysite.org/health"
24
+ """
25
+ url = _strip_ansi_and_control(raw_url)
26
+ if not url:
27
+ raise ValueError("URL cannot be empty")
28
+
29
+ # If scheme is already present, validate that it is HTTP or HTTPS
30
+ if "://" in url:
31
+ if not re.match(r"^https?://", url, re.IGNORECASE):
32
+ raise ValueError(f"Unsupported URL scheme. Only http:// and https:// are supported: {raw_url}")
33
+ parsed = urlparse(url)
34
+ if not parsed.netloc:
35
+ raise ValueError(f"Invalid URL: {raw_url}")
36
+ return url
37
+
38
+ # Default to http:// for localhost or IP with port, https:// otherwise
39
+ if url.startswith("localhost") or url.startswith("127.0.0.1") or url.startswith("0.0.0.0"):
40
+ url = f"http://{url}"
41
+ else:
42
+ url = f"https://{url}"
43
+
44
+ parsed = urlparse(url)
45
+ if not parsed.netloc:
46
+ raise ValueError(f"Invalid URL or hostname: {raw_url}")
47
+
48
+ return url
49
+
50
+
51
+ def get_display_name(url: str) -> str:
52
+ """Extract a friendly clean name for display and notifications, omitting embedded credentials."""
53
+ try:
54
+ parsed = urlparse(url)
55
+ host = parsed.hostname or parsed.path
56
+ if parsed.port and parsed.port not in (80, 443):
57
+ host = f"{host}:{parsed.port}"
58
+ # Strip any ANSI escape sequences or control characters
59
+ host = _strip_ansi_and_control(host)
60
+ return host or _strip_ansi_and_control(url)
61
+ except Exception:
62
+ return _strip_ansi_and_control(url)