Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +307 -0
- 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>
|