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.
- webstat/__init__.py +3 -0
- webstat/cli.py +407 -0
- webstat/core/checker.py +177 -0
- webstat/core/models.py +113 -0
- webstat/core/notifier.py +157 -0
- webstat/core/scheduler.py +110 -0
- webstat/core/store.py +210 -0
- webstat/utils/__init__.py +5 -0
- webstat/utils/formatting.py +74 -0
- webstat/utils/url.py +62 -0
- webstat-0.1.0.dist-info/METADATA +111 -0
- webstat-0.1.0.dist-info/RECORD +16 -0
- webstat-0.1.0.dist-info/WHEEL +5 -0
- webstat-0.1.0.dist-info/entry_points.txt +2 -0
- webstat-0.1.0.dist-info/licenses/LICENSE +21 -0
- webstat-0.1.0.dist-info/top_level.txt +1 -0
webstat/core/notifier.py
ADDED
|
@@ -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,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)
|