devendergarg14 commited on
Commit
3b316e2
·
verified ·
1 Parent(s): 3b5f3ab

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +307 -0
  2. index.html +348 -0
app.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, jsonify, send_from_directory
2
+ import requests
3
+ import threading
4
+ import time
5
+ import uuid
6
+ import json
7
+ import os
8
+ from urllib.parse import urlparse
9
+ import socket
10
+
11
+ app = Flask(__name__, static_folder='.', static_url_path='')
12
+
13
+ # --- Configuration ---
14
+ DATA_FILE = "data.json"
15
+ PING_INTERVAL_SECONDS = 60 # Backend pings every 60 seconds
16
+ HISTORY_DURATION_SECONDS = 60 * 60 # Store history for 1 hour
17
+
18
+ # --- Data Store ---
19
+ # Structure: { "id": "uuid", "url": "string", "status": "pending/ok/error/checking",
20
+ # "ip": "string", "responseTime": float_ms, "lastChecked": "iso_string_utc",
21
+ # "history": [{"timestamp": float_unix_ts_seconds, "status": "ok/error"}],
22
+ # "_thread": threading.Thread_object, "_stop_event": threading.Event_object }
23
+ monitored_urls_store = {} # In-memory store: id -> url_data
24
+ lock = threading.Lock() # To protect access to monitored_urls_store
25
+
26
+ # --- Helper Functions ---
27
+ def save_data_to_json():
28
+ # This function must be called with 'lock' acquired
29
+ serializable_data = {}
30
+ for url_id, data in monitored_urls_store.items():
31
+ s_data = data.copy()
32
+ s_data.pop("_thread", None)
33
+ s_data.pop("_stop_event", None)
34
+ serializable_data[url_id] = s_data
35
+ try:
36
+ with open(DATA_FILE, 'w') as f:
37
+ json.dump(serializable_data, f, indent=2)
38
+ except IOError as e:
39
+ print(f"Error saving data to {DATA_FILE}: {e}")
40
+
41
+ def load_data_from_json():
42
+ global monitored_urls_store
43
+ if os.path.exists(DATA_FILE):
44
+ try:
45
+ with open(DATA_FILE, 'r') as f:
46
+ loaded_json_data = json.load(f)
47
+
48
+ temp_store = {}
49
+ for url_id_key, data_item in loaded_json_data.items():
50
+ # Ensure essential fields and use 'id' from data if present, else key
51
+ data_item.setdefault('id', url_id_key)
52
+ current_id = data_item['id']
53
+ data_item.setdefault('status', 'pending')
54
+ data_item.setdefault('ip', data_item.get('ip', 'N/A'))
55
+ data_item.setdefault('responseTime', None)
56
+ data_item.setdefault('lastChecked', None)
57
+ data_item.setdefault('history', data_item.get('history', []))
58
+ temp_store[current_id] = data_item
59
+
60
+ with lock: # Lock before modifying global monitored_urls_store
61
+ monitored_urls_store = temp_store
62
+
63
+ except json.JSONDecodeError:
64
+ print(f"Warning: Could not decode {DATA_FILE}. Starting with an empty list.")
65
+ with lock: monitored_urls_store = {}
66
+ except Exception as e:
67
+ print(f"Error loading data from {DATA_FILE}: {e}. Starting fresh.")
68
+ with lock: monitored_urls_store = {}
69
+ else:
70
+ with lock: monitored_urls_store = {}
71
+
72
+ url_ids_to_start_monitoring = []
73
+ with lock:
74
+ url_ids_to_start_monitoring = list(monitored_urls_store.keys())
75
+
76
+ for url_id in url_ids_to_start_monitoring:
77
+ start_url_monitoring_thread(url_id)
78
+
79
+ def get_host_ip_address(hostname_str):
80
+ try:
81
+ # Check if hostname_str is already a valid IP address
82
+ socket.inet_aton(hostname_str) # Throws an OSError if not a valid IPv4 string
83
+ return hostname_str
84
+ except OSError:
85
+ # It's not an IP, so try to resolve it as a hostname
86
+ try:
87
+ ip_address = socket.gethostbyname(hostname_str)
88
+ return ip_address
89
+ except socket.gaierror:
90
+ print(f"Could not resolve hostname: {hostname_str}")
91
+ return 'N/A'
92
+ except Exception as e:
93
+ print(f"Error processing hostname/IP for {hostname_str}: {e}")
94
+ return 'N/A'
95
+
96
+ def prune_url_history(url_data_entry):
97
+ # Assumes 'lock' is acquired or called from the thread managing this entry
98
+ cutoff_time = time.time() - HISTORY_DURATION_SECONDS
99
+ url_data_entry['history'] = [
100
+ entry for entry in url_data_entry.get('history', []) if entry['timestamp'] >= cutoff_time
101
+ ]
102
+
103
+ def execute_url_check(url_id_to_check):
104
+ url_config_snapshot = None
105
+ with lock:
106
+ if url_id_to_check not in monitored_urls_store: return
107
+
108
+ current_url_data = monitored_urls_store[url_id_to_check]
109
+ if current_url_data.get('_stop_event') and current_url_data['_stop_event'].is_set(): return
110
+
111
+ print(f"Checking {current_url_data['url']} (ID: {url_id_to_check})...")
112
+ current_url_data['status'] = 'checking'
113
+ url_config_snapshot = current_url_data.copy() # Snapshot for use outside lock
114
+
115
+ if not url_config_snapshot: return
116
+
117
+ check_start_time = time.perf_counter()
118
+ final_check_status = 'error'
119
+ http_response_time_ms = None
120
+ # Identify your bot to website owners
121
+ http_headers = {'User-Agent': 'URLPinger/1.0 (HuggingFace Space Bot)'}
122
+
123
+ try:
124
+ # Attempt HEAD request first
125
+ try:
126
+ head_response = requests.head(url_config_snapshot['url'], timeout=10, allow_redirects=True, headers=http_headers)
127
+ if 200 <= head_response.status_code < 400: # OK or Redirect
128
+ final_check_status = 'ok'
129
+ else:
130
+ print(f"HEAD for {url_config_snapshot['url']} returned {head_response.status_code}. Trying GET.")
131
+ except requests.exceptions.Timeout:
132
+ print(f"HEAD timeout for {url_config_snapshot['url']}. Trying GET...")
133
+ except requests.RequestException as e_head:
134
+ print(f"HEAD failed for {url_config_snapshot['url']}: {e_head}. Trying GET...")
135
+
136
+ # If HEAD was not conclusive, try GET
137
+ if final_check_status != 'ok':
138
+ try:
139
+ get_response = requests.get(url_config_snapshot['url'], timeout=15, allow_redirects=True, headers=http_headers)
140
+ if get_response.ok: # Only 2xx status codes
141
+ final_check_status = 'ok'
142
+ else:
143
+ print(f"GET for {url_config_snapshot['url']} status: {get_response.status_code}")
144
+ final_check_status = 'error'
145
+ except requests.exceptions.Timeout:
146
+ print(f"GET timeout for {url_config_snapshot['url']}")
147
+ final_check_status = 'error'
148
+ except requests.RequestException as e_get:
149
+ print(f"GET failed for {url_config_snapshot['url']}: {e_get}")
150
+ final_check_status = 'error'
151
+
152
+ if final_check_status == 'ok':
153
+ http_response_time_ms = (time.perf_counter() - check_start_time) * 1000
154
+
155
+ except Exception as e:
156
+ print(f"Outer check exception for {url_config_snapshot['url']}: {e}")
157
+ final_check_status = 'error'
158
+
159
+ with lock:
160
+ if url_id_to_check not in monitored_urls_store: return # URL might have been removed during check
161
+
162
+ live_url_data = monitored_urls_store[url_id_to_check]
163
+ live_url_data['status'] = final_check_status
164
+ live_url_data['responseTime'] = round(http_response_time_ms) if http_response_time_ms is not None else None
165
+ live_url_data['lastChecked'] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) # ISO 8601 UTC
166
+
167
+ current_history_list = live_url_data.get('history', [])
168
+ current_history_list.append({'timestamp': time.time(), 'status': final_check_status}) # timestamp in seconds
169
+ live_url_data['history'] = current_history_list
170
+ prune_url_history(live_url_data)
171
+
172
+ save_data_to_json()
173
+ print(f"Finished check for {live_url_data['url']}: {final_check_status}, {http_response_time_ms} ms")
174
+
175
+ def pinger_thread_function(url_id_param, stop_event_param):
176
+ while not stop_event_param.is_set():
177
+ execute_url_check(url_id_param)
178
+ # Sleep for PING_INTERVAL_SECONDS, but check stop_event periodically
179
+ for _ in range(PING_INTERVAL_SECONDS):
180
+ if stop_event_param.is_set(): break
181
+ time.sleep(1)
182
+ print(f"PingerThread for {url_id_param} stopped.")
183
+
184
+ def start_url_monitoring_thread(target_url_id):
185
+ with lock:
186
+ if target_url_id not in monitored_urls_store:
187
+ print(f"Cannot start monitoring: URL ID {target_url_id} not found.")
188
+ return
189
+
190
+ url_data_entry = monitored_urls_store[target_url_id]
191
+
192
+ # Stop existing thread if it's alive
193
+ if "_thread" in url_data_entry and url_data_entry["_thread"].is_alive():
194
+ print(f"Monitor for URL ID {target_url_id} already running. Attempting to restart.")
195
+ url_data_entry["_stop_event"].set()
196
+ url_data_entry["_thread"].join(timeout=3) # Wait for thread to stop
197
+
198
+ new_stop_event = threading.Event()
199
+ # daemon=True allows main program to exit even if threads are running
200
+ new_thread = threading.Thread(target=pinger_thread_function, args=(target_url_id, new_stop_event), daemon=True)
201
+
202
+ url_data_entry["_thread"] = new_thread
203
+ url_data_entry["_stop_event"] = new_stop_event
204
+
205
+ new_thread.start()
206
+ print(f"Started/Restarted monitoring for URL ID {target_url_id}: {url_data_entry['url']}")
207
+
208
+ def stop_url_monitoring_thread(target_url_id):
209
+ # This function must be called with 'lock' acquired
210
+ if target_url_id in monitored_urls_store:
211
+ url_data_entry = monitored_urls_store[target_url_id]
212
+ if "_thread" in url_data_entry and url_data_entry["_thread"].is_alive():
213
+ print(f"Signaling stop for monitor thread of URL ID {target_url_id}")
214
+ url_data_entry["_stop_event"].set()
215
+ # Not joining here to keep API responsive, daemon thread will exit.
216
+ url_data_entry.pop("_thread", None)
217
+ url_data_entry.pop("_stop_event", None)
218
+
219
+ # --- API Endpoints ---
220
+ @app.route('/')
221
+ def serve_index():
222
+ return send_from_directory(app.static_folder, 'index.html')
223
+
224
+ @app.route('/api/urls', methods=['GET'])
225
+ def get_all_urls():
226
+ with lock:
227
+ # Prepare data for sending: list of url data, no thread objects
228
+ response_list = []
229
+ for data_item in monitored_urls_store.values():
230
+ display_item = data_item.copy()
231
+ display_item.pop("_thread", None)
232
+ display_item.pop("_stop_event", None)
233
+ response_list.append(display_item)
234
+ return jsonify(response_list)
235
+
236
+ @app.route('/api/urls', methods=['POST'])
237
+ def add_new_url():
238
+ request_data = request.get_json()
239
+ if not request_data or 'url' not in request_data:
240
+ return jsonify({"error": "URL is required"}), 400
241
+
242
+ input_url = request_data['url'].strip()
243
+
244
+ if not input_url.startswith('http://') and not input_url.startswith('https://'):
245
+ input_url = 'https://' + input_url # Default to https
246
+
247
+ try:
248
+ parsed_input_url = urlparse(input_url)
249
+ if not parsed_input_url.scheme or not parsed_input_url.netloc:
250
+ raise ValueError("Invalid URL structure")
251
+ url_hostname = parsed_input_url.hostname
252
+ except ValueError:
253
+ return jsonify({"error": "Invalid URL format"}), 400
254
+
255
+ with lock:
256
+ # Check for duplicates (case-insensitive, ignoring trailing slashes)
257
+ normalized_new_url = input_url.rstrip('/').lower()
258
+ for existing_url in monitored_urls_store.values():
259
+ if existing_url['url'].rstrip('/').lower() == normalized_new_url:
260
+ return jsonify({"error": "URL already monitored"}), 409 # Conflict
261
+
262
+ new_url_id = str(uuid.uuid4())
263
+ resolved_ip = get_host_ip_address(url_hostname) if url_hostname else 'N/A'
264
+
265
+ url_entry_to_add = {
266
+ "id": new_url_id, "url": input_url, "status": 'pending',
267
+ "ip": resolved_ip, "responseTime": None, "lastChecked": None, "history": []
268
+ }
269
+ monitored_urls_store[new_url_id] = url_entry_to_add
270
+ save_data_to_json()
271
+
272
+ start_url_monitoring_thread(new_url_id)
273
+
274
+ # Return the newly created URL entry (without thread objects)
275
+ return jsonify(url_entry_to_add), 201
276
+
277
+
278
+ @app.route('/api/urls/<string:target_url_id>', methods=['DELETE'])
279
+ def delete_existing_url(target_url_id):
280
+ with lock:
281
+ if target_url_id in monitored_urls_store:
282
+ stop_url_monitoring_thread(target_url_id)
283
+ removed_url_entry = monitored_urls_store.pop(target_url_id)
284
+ save_data_to_json()
285
+
286
+ # Prepare data for response (without thread objects)
287
+ response_data = removed_url_entry.copy()
288
+ response_data.pop("_thread", None)
289
+ response_data.pop("_stop_event", None)
290
+ print(f"Deleted URL ID {target_url_id}")
291
+ return jsonify({"message": "URL removed", "url": response_data}), 200
292
+ else:
293
+ return jsonify({"error": "URL not found"}), 404
294
+
295
+ # --- Main Execution / Gunicorn Entry Point ---
296
+ # Load data once when the application module is initialized
297
+ # This handles both `flask run` and gunicorn scenarios.
298
+ if os.environ.get('WERKZEUG_RUN_MAIN') != 'true': # Avoids double load in Flask debug mode
299
+ load_data_from_json()
300
+
301
+ if __name__ == '__main__':
302
+ # This block is for local development (e.g., `python app.py`)
303
+ # `load_data_from_json()` is called above unless Werkzeug reloader is active.
304
+ app.run(debug=True, host='0.0.0.0', port=7860)
305
+
306
+ # When run with Gunicorn, Gunicorn imports `app` from this `app.py` file.
307
+ # `load_data_from_json()` will have been called during that import.
index.html ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>URL Pinger (Backend Mode)</title>
7
+ <meta name="theme-color" content="#e0e5ec">
8
+ <meta name="theme-color" content="#2c303a" media="(prefers-color-scheme: dark)">
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <style>
11
+ /* Custom styles (UNCHANGED from your original - Retained for brevity) */
12
+ :root {
13
+ --light-bg: #e0e5ec; --dark-bg: #2c303a; --light-shadow-outer-1: #a3b1c6;
14
+ --light-shadow-outer-2: #ffffff; --dark-shadow-outer-1: #22252e; --dark-shadow-outer-2: #363a46;
15
+ --light-shadow-inner-1: #a3b1c6; --light-shadow-inner-2: #ffffff; --dark-shadow-inner-1: #22252e;
16
+ --dark-shadow-inner-2: #363a46; --text-light: #4a5568; --text-dark: #e2e8f0;
17
+ --text-light-muted: #718096; --text-dark-muted: #a0aec0; --dot-ok: #22c55e;
18
+ --dot-error: #ef4444; --dot-pending: #9ca3af; --dot-checking: #3b82f6;
19
+ }
20
+ html.dark {
21
+ --light-bg: #2c303a; --light-shadow-outer-1: #22252e; --light-shadow-outer-2: #363a46;
22
+ --light-shadow-inner-1: #22252e; --light-shadow-inner-2: #363a46;
23
+ --text-light: #e2e8f0; --text-light-muted: #a0aec0;
24
+ }
25
+ body {
26
+ background-color: var(--light-bg); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
27
+ min-height: 100vh; transition: background-color 0.3s ease; color: var(--text-light);
28
+ }
29
+ .neumorphic-outset {
30
+ border-radius: 12px; background: var(--light-bg);
31
+ box-shadow: 6px 6px 12px var(--light-shadow-outer-1), -6px -6px 12px var(--light-shadow-outer-2);
32
+ transition: box-shadow 0.2s ease-out, background-color 0.3s ease;
33
+ }
34
+ .neumorphic-outset-sm {
35
+ border-radius: 8px; background: var(--light-bg);
36
+ box-shadow: 4px 4px 8px var(--light-shadow-outer-1), -4px -4px 8px var(--light-shadow-outer-2);
37
+ transition: box-shadow 0.2s ease-out, background-color 0.3s ease;
38
+ }
39
+ .neumorphic-outset-hover:hover { box-shadow: 4px 4px 8px var(--light-shadow-outer-1), -4px -4px 8px var(--light-shadow-outer-2); }
40
+ .neumorphic-outset-active:active, .neumorphic-outset-active:focus { box-shadow: inset 3px 3px 6px var(--light-shadow-inner-1), inset -3px -3px 6px var(--light-shadow-inner-2); }
41
+ .neumorphic-inset {
42
+ border-radius: 12px; background: var(--light-bg);
43
+ box-shadow: inset 6px 6px 12px var(--light-shadow-inner-1), inset -6px -6px 12px var(--light-shadow-inner-2);
44
+ transition: box-shadow 0.2s ease-out, background-color 0.3s ease;
45
+ }
46
+ .neumorphic-inset-sm {
47
+ border-radius: 8px; background: var(--light-bg);
48
+ box-shadow: inset 4px 4px 8px var(--light-shadow-inner-1), inset -4px -4px 8px var(--light-shadow-inner-2);
49
+ transition: box-shadow 0.2s ease-out, background-color 0.3s ease;
50
+ }
51
+ .status-dot { width: 0.75rem; height: 0.75rem; border-radius: 50%; display: inline-block; flex-shrink: 0; margin-top: 4px; }
52
+ .status-ok { background-color: var(--dot-ok); } .status-error { background-color: var(--dot-error); }
53
+ .status-pending { background-color: var(--dot-pending); } .status-checking { background-color: var(--dot-checking); }
54
+ .loader {
55
+ border: 2px solid var(--light-shadow-outer-1); border-top: 2px solid var(--dot-checking);
56
+ border-radius: 50%; width: 12px; height: 12px; animation: spin 1s linear infinite;
57
+ display: inline-block; flex-shrink: 0; margin-top: 4px;
58
+ }
59
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
60
+ .history-bar {
61
+ display: flex; height: 10px; overflow: hidden; flex-direction: row-reverse;
62
+ width: 100%; margin-top: 8px; border-radius: 3px; background-color: var(--light-shadow-outer-2);
63
+ }
64
+ .history-point { width: 3px; height: 100%; margin-right: 1px; flex-shrink: 0; flex-grow: 0; }
65
+ .history-bar .history-point:first-child { margin-right: 0; }
66
+ .history-ok { background-color: var(--dot-ok); } .history-error { background-color: var(--dot-error); }
67
+ #urlList { max-height: calc(100vh - 350px); overflow-y: auto; padding-right: 8px; }
68
+ #urlList::-webkit-scrollbar { width: 6px; }
69
+ #urlList::-webkit-scrollbar-track { background: transparent; border-radius: 3px; }
70
+ #urlList::-webkit-scrollbar-thumb { background: var(--light-shadow-outer-1); border-radius: 3px; }
71
+ #urlList::-webkit-scrollbar-thumb:hover { background: var(--text-light-muted); }
72
+ input::placeholder { color: var(--text-light-muted); opacity: 0.8; }
73
+ .removeUrlBtn svg { width: 0.875rem; height: 0.875rem; pointer-events: none; color: var(--text-light-muted); }
74
+ .removeUrlBtn:hover svg { color: #ef4444; }
75
+ html.dark .removeUrlBtn:hover svg { color: #f87171; }
76
+ </style>
77
+ <script>
78
+ tailwind.config = { darkMode: 'class', theme: { extend: {} } }
79
+ </script>
80
+ </head>
81
+ <body class="pt-10 pb-10 px-4">
82
+
83
+ <div id="app" class="max-w-md mx-auto neumorphic-outset p-6 md:p-8">
84
+ <div class="flex justify-between items-center mb-6">
85
+ <h1 class="text-2xl font-semibold text-center flex-grow">URL Pinger (Backend Mode)</h1>
86
+ <button id="theme-toggle" class="neumorphic-outset-sm neumorphic-outset-hover neumorphic-outset-active p-2 focus:outline-none">
87
+ <svg id="theme-toggle-light-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
88
+ <svg id="theme-toggle-dark-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
89
+ </button>
90
+ </div>
91
+
92
+ <div class="mb-6">
93
+ <label for="urlInput" class="block text-sm font-medium mb-2" style="color: var(--text-light-muted);">Add URL to Monitor:</label>
94
+ <div class="flex space-x-3">
95
+ <input type="url" id="urlInput" placeholder="https://example.com" class="flex-grow p-3 neumorphic-inset border-none focus:outline-none text-sm" style="color: var(--text-light);" required>
96
+ <button id="addUrlBtn" class="neumorphic-outset-sm neumorphic-outset-hover neumorphic-outset-active font-semibold py-2 px-5 transition duration-150 ease-in-out focus:outline-none">
97
+ Add
98
+ </button>
99
+ </div>
100
+ <p id="errorMsg" class="text-red-500 text-xs mt-2 h-4"></p>
101
+ </div>
102
+
103
+ <div class="mt-8">
104
+ <h2 class="text-lg font-semibold mb-3">Monitored URLs</h2>
105
+ <div id="urlList" class="space-y-4">
106
+ <p class="italic" style="color: var(--text-light-muted);">Loading URLs from server...</p>
107
+ </div>
108
+ </div>
109
+ </div>
110
+
111
+ <script>
112
+ const urlInput = document.getElementById('urlInput');
113
+ const addUrlBtn = document.getElementById('addUrlBtn');
114
+ const urlList = document.getElementById('urlList');
115
+ const errorMsg = document.getElementById('errorMsg');
116
+ const themeToggleBtn = document.getElementById('theme-toggle');
117
+ const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
118
+ const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
119
+
120
+ let monitoredUrlsCache = []; // Client-side cache of URL data from backend
121
+
122
+ const UI_REFRESH_INTERVAL_MS = 5000; // Refresh UI data from backend every 5 seconds
123
+ const HISTORY_DURATION_MS_FOR_DISPLAY = 60 * 60 * 1000; // For uptime % and history bar visualization
124
+ const MAX_HISTORY_POINTS_FOR_DISPLAY = 90;
125
+
126
+ // --- Theme Toggle (UNCHANGED logic) ---
127
+ function applyTheme(isDark) {
128
+ if (isDark) {
129
+ document.documentElement.classList.add('dark');
130
+ themeToggleLightIcon.classList.remove('hidden');
131
+ themeToggleDarkIcon.classList.add('hidden');
132
+ requestAnimationFrame(() => {
133
+ const darkBg = getComputedStyle(document.documentElement).getPropertyValue('--dark-bg').trim();
134
+ document.querySelector('meta[name="theme-color"][media="(prefers-color-scheme: dark)"]').setAttribute('content', darkBg);
135
+ });
136
+ } else {
137
+ document.documentElement.classList.remove('dark');
138
+ themeToggleLightIcon.classList.add('hidden');
139
+ themeToggleDarkIcon.classList.remove('hidden');
140
+ requestAnimationFrame(() => {
141
+ const lightBg = getComputedStyle(document.documentElement).getPropertyValue('--light-bg').trim();
142
+ document.querySelector('meta[name="theme-color"]:not([media])').setAttribute('content', lightBg);
143
+ });
144
+ }
145
+ requestAnimationFrame(() => {
146
+ document.body.style.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--light-bg').trim();
147
+ });
148
+ }
149
+ function toggleTheme() {
150
+ const isDark = document.documentElement.classList.toggle('dark');
151
+ localStorage.setItem('theme', isDark ? 'dark' : 'light');
152
+ applyTheme(isDark);
153
+ }
154
+ function initializeTheme() {
155
+ const storedTheme = localStorage.getItem('theme');
156
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
157
+ applyTheme(storedTheme === 'dark' || (!storedTheme && prefersDark));
158
+ }
159
+ initializeTheme();
160
+ themeToggleBtn.addEventListener('click', toggleTheme);
161
+ // --- End Theme Toggle ---
162
+
163
+ // --- API Communication Wrapper ---
164
+ async function apiRequest(endpoint, options = {}) {
165
+ try {
166
+ const response = await fetch(endpoint, options);
167
+ if (!response.ok) {
168
+ const errorData = await response.json().catch(() => ({ message: response.statusText }));
169
+ throw new Error(errorData.error || errorData.message || `HTTP Error ${response.status}`);
170
+ }
171
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
172
+ return null; // Handle No Content responses
173
+ }
174
+ return await response.json();
175
+ } catch (error) {
176
+ console.error(`API Request Error for ${endpoint}:`, error);
177
+ errorMsg.textContent = `Error: ${error.message}`; // Display error to user
178
+ throw error; // Re-throw for calling function to handle if needed
179
+ }
180
+ }
181
+
182
+ async function fetchAndRenderUrls() {
183
+ try {
184
+ const dataFromServer = await apiRequest('/api/urls');
185
+ monitoredUrlsCache = dataFromServer || []; // API might return empty list or null
186
+ renderUrlListUI();
187
+ } catch (e) {
188
+ // Error already logged by apiRequest and potentially shown in errorMsg
189
+ // Optionally, render a more specific error state for the list
190
+ urlList.innerHTML = `<p class="italic" style="color: var(--text-light-muted);">Could not load URLs. Server may be down.</p>`;
191
+ }
192
+ }
193
+
194
+ // --- Uptime and History Bar Calculation (for UI) ---
195
+ function getDisplayMetrics(backendHistoryArray) {
196
+ const historyForDisplay = (backendHistoryArray || []).map(h => ({ ...h, timestamp: h.timestamp * 1000 })); // Convert backend sec to ms
197
+
198
+ const cutoffTimeMs = Date.now() - HISTORY_DURATION_MS_FOR_DISPLAY;
199
+ const relevantHistory = historyForDisplay.filter(entry => entry.timestamp >= cutoffTimeMs);
200
+
201
+ if (relevantHistory.length === 0) return { percentage: 'N/A', points: [] };
202
+
203
+ const okCount = relevantHistory.filter(entry => entry.status === 'ok').length;
204
+ const uptimePercent = Math.round((okCount / relevantHistory.length) * 100);
205
+ // Newest history points first for the bar rendering logic (flex-direction: row-reverse)
206
+ const historyBarPoints = relevantHistory.slice(-MAX_HISTORY_POINTS_FOR_DISPLAY).map(entry => entry.status).reverse();
207
+
208
+ return { percentage: `${uptimePercent}%`, points: historyBarPoints };
209
+ }
210
+
211
+ // --- UI Rendering Logic ---
212
+ function createUrlItemDOM(urlData) {
213
+ const itemDiv = document.createElement('div');
214
+ itemDiv.className = 'neumorphic-outset-sm p-4 mb-4 last:mb-0 url-item';
215
+ itemDiv.dataset.id = urlData.id;
216
+
217
+ let statusIndicatorDOM;
218
+ if (urlData.status === 'checking') {
219
+ statusIndicatorDOM = '<span class="loader" title="Checking..."></span>';
220
+ } else {
221
+ const statusClass = urlData.status === 'ok' ? 'status-ok' : (urlData.status === 'error' ? 'status-error' : 'status-pending');
222
+ const statusTitle = urlData.status === 'ok' ? 'Reachable' : (urlData.status === 'error' ? 'Error/Unreachable' : 'Pending');
223
+ statusIndicatorDOM = `<span class="status-dot ${statusClass}" title="${statusTitle}"></span>`;
224
+ }
225
+
226
+ const respTimeStr = urlData.responseTime !== null ? `${urlData.responseTime} ms` : 'N/A';
227
+ const lastCheckStr = urlData.lastChecked ? `Last check: ${new Date(urlData.lastChecked).toLocaleString()}` : 'Not checked';
228
+ const { percentage: uptimeStr, points: historyBarData } = getDisplayMetrics(urlData.history);
229
+
230
+ let historyBarDOM = `<div class="history-bar" title="Recent History (Newest Left)">`;
231
+ historyBarData.forEach(status => {
232
+ const historyClass = status === 'ok' ? 'history-ok' : 'history-error';
233
+ historyBarDOM += `<div class="history-point ${historyClass}"></div>`;
234
+ });
235
+ historyBarDOM += '</div>';
236
+
237
+ const trashIcon = `
238
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
239
+ <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
240
+ </svg>`;
241
+
242
+ itemDiv.innerHTML = `
243
+ <div class="flex items-start justify-between space-x-3">
244
+ <div class="flex items-start space-x-3 flex-grow min-w-0">
245
+ ${statusIndicatorDOM}
246
+ <div class="min-w-0">
247
+ <p class="font-medium truncate text-sm" title="${urlData.url}" style="color: var(--text-light);">${urlData.url}</p>
248
+ <p class="text-xs mt-1" style="color: var(--text-light-muted);">
249
+ IP: ${urlData.ip || 'N/A'} | Resp: ${respTimeStr} | Uptime (1h): ${uptimeStr}
250
+ </p>
251
+ <p class="text-xs mt-0.5" style="color: var(--text-light-muted);">${lastCheckStr}</p>
252
+ </div>
253
+ </div>
254
+ <button class="removeUrlBtn flex-shrink-0 neumorphic-outset-sm neumorphic-outset-hover neumorphic-outset-active p-1.5 focus:outline-none" title="Stop Monitoring">
255
+ ${trashIcon}
256
+ </button>
257
+ </div>
258
+ ${historyBarDOM}`;
259
+
260
+ itemDiv.querySelector('.removeUrlBtn').addEventListener('click', (e) => {
261
+ e.stopPropagation();
262
+ handleRemoveUrl(urlData.id);
263
+ });
264
+ return itemDiv;
265
+ }
266
+
267
+ function renderUrlListUI() {
268
+ urlList.innerHTML = ''; // Clear current list
269
+ if (monitoredUrlsCache.length === 0) {
270
+ urlList.innerHTML = `<p class="italic" style="color: var(--text-light-muted);">No URLs being monitored. Add one to begin.</p>`;
271
+ return;
272
+ }
273
+ monitoredUrlsCache.forEach(urlData => {
274
+ const urlItemElement = createUrlItemDOM(urlData);
275
+ urlList.appendChild(urlItemElement);
276
+ });
277
+ }
278
+
279
+ // --- Event Handlers ---
280
+ async function handleAddUrl() {
281
+ let urlToAdd = urlInput.value.trim();
282
+ errorMsg.textContent = ''; // Clear previous errors
283
+
284
+ if (!urlToAdd) {
285
+ errorMsg.textContent = 'Please enter a URL.'; return;
286
+ }
287
+ if (!urlToAdd.startsWith('http://') && !urlToAdd.startsWith('https://')) {
288
+ urlToAdd = 'https://' + urlToAdd; // Default to https
289
+ }
290
+ try {
291
+ new URL(urlToAdd); // Basic client-side format validation
292
+ } catch (_) {
293
+ errorMsg.textContent = 'Invalid URL format.'; return;
294
+ }
295
+
296
+ // Optimistic client-side duplicate check (backend does final validation)
297
+ const normalizedUrl = urlToAdd.replace(/\/+$/, '').toLowerCase();
298
+ if (monitoredUrlsCache.some(u => u.url.replace(/\/+$/, '').toLowerCase() === normalizedUrl)) {
299
+ errorMsg.textContent = 'This URL appears to be already monitored.'; return;
300
+ }
301
+
302
+ addUrlBtn.disabled = true; addUrlBtn.textContent = '...'; urlInput.disabled = true;
303
+
304
+ try {
305
+ await apiRequest('/api/urls', {
306
+ method: 'POST',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({ url: urlToAdd })
309
+ });
310
+ urlInput.value = ''; // Clear input on success
311
+ await fetchAndRenderUrls(); // Refresh list to show the newly added URL
312
+ } catch (e) {
313
+ // apiRequest already displayed the error in errorMsg
314
+ } finally {
315
+ addUrlBtn.disabled = false; addUrlBtn.textContent = 'Add'; urlInput.disabled = false;
316
+ }
317
+ }
318
+
319
+ async function handleRemoveUrl(urlIdToRemove) {
320
+ // Optional: add a confirm() dialog here
321
+ // if (!confirm(`Are you sure you want to remove this URL?`)) return;
322
+ try {
323
+ await apiRequest(`/api/urls/${urlIdToRemove}`, { method: 'DELETE' });
324
+ // Remove from local cache and re-render for immediate UI update
325
+ monitoredUrlsCache = monitoredUrlsCache.filter(url => url.id !== urlIdToRemove);
326
+ renderUrlListUI();
327
+ } catch (e) {
328
+ // apiRequest already displayed the error
329
+ }
330
+ }
331
+
332
+ function setupPeriodicDataRefresh() {
333
+ setInterval(fetchAndRenderUrls, UI_REFRESH_INTERVAL_MS);
334
+ }
335
+
336
+ // --- Initialization ---
337
+ addUrlBtn.addEventListener('click', handleAddUrl);
338
+ urlInput.addEventListener('keypress', (event) => {
339
+ if (event.key === 'Enter') { event.preventDefault(); handleAddUrl(); }
340
+ });
341
+
342
+ document.addEventListener('DOMContentLoaded', () => {
343
+ fetchAndRenderUrls(); // Initial data load
344
+ setupPeriodicDataRefresh(); // Start refreshing data from backend
345
+ });
346
+ </script>
347
+ </body>
348
+ </html>