Crypto0203 commited on
Commit
8def838
·
0 Parent(s):

Deploy ParleDowntime Pro Space

Browse files
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ monitor_metrics.db
2
+ *.db
3
+ __pycache__/
.vscode/settings.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "python.testing.unittestArgs": [
3
+ "-v",
4
+ "-s",
5
+ "./__pycache__",
6
+ "-p",
7
+ "*test.py"
8
+ ],
9
+ "python.testing.pytestEnabled": false,
10
+ "python.testing.unittestEnabled": true
11
+ }
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Install system dependencies
4
+ RUN apt-get update && apt-get install -y \
5
+ build-essential \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ # Copy requirements and install
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy the rest of the application
15
+ COPY . .
16
+
17
+ # Hugging Face Spaces uses port 7860 by default
18
+ ENV PORT=7860
19
+ EXPOSE 7860
20
+
21
+ # Run the application
22
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "app:app"]
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: gunicorn app:app
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: StatusPilot
3
+ emoji: 🚀
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # StatusPilot Cloud Version
11
+
12
+ This version is optimized for 24/7 cloud deployment on Hugging Face Spaces.
13
+
14
+ ## 🚀 Deployment Instructions (Hugging Face)
15
+
16
+ 1. **Go to your Space**: [huggingface.co/spaces/ChotuStatus/StatusPilot](https://huggingface.co/spaces/ChotuStatus/StatusPilot)
17
+ 2. **Click Files**: Go to the "Files and versions" tab.
18
+ 3. **Edit README.md**: Make sure the **top part** of your README.md looks exactly like the code above (between the `---` lines).
19
+ 4. **Upload All Files**: Ensure you have uploaded:
20
+ - `app.py`
21
+ - `requirements.txt`
22
+ - `Dockerfile`
23
+ - `security.py`
24
+ - `static/` (folder)
25
+ - `templates/` (folder)
26
+ - `config.json`
27
+ - `license.dat`
28
+ 5. **Restart**: Once the `README.md` has the `sdk: docker` line, Hugging Face will start building your dashboard!
29
+
30
+ ## 🔧 Features
31
+ - **Neural AI Service Addition**: Add services using natural language.
32
+ - **24/7 Monitoring**: Runs continuously in the cloud.
33
+ - **Auto-Activation**: Uses your existing `license.dat`.
34
+
35
+ ## ⚠️ Important Note
36
+ The **Outlook Alert** feature requires the Windows Outlook Desktop app and will not function in the cloud. Please use **SMTP (Email)** alerts for cloud operation.
app.py ADDED
@@ -0,0 +1,1064 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import base64
3
+ import sys
4
+ import json
5
+ import time
6
+ import sqlite3
7
+ import threading
8
+ import requests
9
+ import io
10
+ import re
11
+ import xlsxwriter
12
+ import smtplib
13
+ import imaplib
14
+ from email.mime.text import MIMEText
15
+ from email.mime.multipart import MIMEMultipart
16
+ from email.header import Header
17
+ from datetime import datetime, timedelta
18
+ import ssl
19
+ import socket
20
+ from flask import Flask, request, jsonify, render_template, send_file, session, Response
21
+
22
+ try:
23
+ import win32com.client
24
+ OUTL_AVAIL = True
25
+ except ImportError:
26
+ OUTL_AVAIL = False
27
+
28
+ try:
29
+ import security
30
+ except ImportError:
31
+ print("Warning: security.py not found. License bypass may occur or app might fail.")
32
+
33
+ def get_license_status():
34
+ lic_file = 'license.dat'
35
+ if not os.path.exists(lic_file):
36
+ return False, "Missing license.dat file."
37
+
38
+ try:
39
+ with open(lic_file, 'r') as f:
40
+ lic_data = json.load(f)
41
+ email = lic_data.get('email', '')
42
+ key = lic_data.get('key', '')
43
+ if not security.verify_license_key(email, key):
44
+ return False, "Invalid License Key."
45
+ return True, f"Licensed to {email}"
46
+ except Exception as e:
47
+ return False, f"License read error: {e}"
48
+
49
+ # --- SMART PATHING FOR CLOUD DEPLOYMENT ---
50
+ import os
51
+
52
+ # Detect if we are in a 'flat' environment (all files in root) or standard (templates/static)
53
+ base_dir = os.path.dirname(os.path.abspath(__file__))
54
+ template_dir = os.path.join(base_dir, 'templates')
55
+ static_dir = os.path.join(base_dir, 'static')
56
+
57
+ # Fallback to root if folders don't exist (helps users who can't create folders)
58
+ if not os.path.exists(template_dir):
59
+ template_dir = base_dir
60
+ if not os.path.exists(static_dir):
61
+ static_dir = base_dir
62
+
63
+ app = Flask(__name__,
64
+ template_folder=template_dir,
65
+ static_folder=static_dir,
66
+ static_url_path='/static')
67
+ # CRITICAL: Use a custom secret key from environment or a strong default
68
+ app.secret_key = os.environ.get('SESSION_SECRET', os.urandom(32).hex())
69
+
70
+ # --- VERSIONING ---
71
+ VERSION = "5.1.0-CRYSTAL-PRO"
72
+
73
+ @app.route('/api/version')
74
+ def get_version():
75
+ return jsonify({"version": VERSION})
76
+
77
+ # --- CLOUD SESSION HARDENING (IFRAME COMPATIBLE) ---
78
+ app.config.update(
79
+ SESSION_COOKIE_HTTPONLY=True,
80
+ SESSION_COOKIE_SECURE=True, # Must be True for SameSite=None
81
+ SESSION_COOKIE_SAMESITE='None', # Allows cookie in iframes (HF Spaces)
82
+ PERMANENT_SESSION_LIFETIME=timedelta(days=30),
83
+ SESSION_REFRESH_EACH_REQUEST=True
84
+ )
85
+
86
+ def check_auth():
87
+ """Verify if the session is authenticated via Master Key."""
88
+ master_key = os.environ.get('MASTER_KEY')
89
+ # If no key is set at all (None), we allow access (local mode)
90
+ if master_key is None:
91
+ return True
92
+ # If a key is set but empty, it's a security risk - require auth anyway
93
+ if master_key.strip() == "":
94
+ return False
95
+ return session.get('authenticated') == True
96
+
97
+ @app.before_request
98
+ def protect_apis():
99
+ """Protect all /api routes except for version, auth and static routes."""
100
+ if request.path.startswith('/api/') and not request.path.startswith('/api/auth/') and not request.path == '/api/version':
101
+ if not check_auth():
102
+ return jsonify({"error": "Unauthorized Access", "code": 401}), 401
103
+
104
+ @app.route('/api/auth/status')
105
+ def auth_status():
106
+ master_key = os.environ.get('MASTER_KEY')
107
+ has_key = master_key is not None and master_key.strip() != ""
108
+ return jsonify({
109
+ "required": has_key,
110
+ "authenticated": check_auth()
111
+ })
112
+
113
+ @app.route('/api/auth/login', methods=['POST'])
114
+ def login():
115
+ data = request.json
116
+ key = data.get('key', '').strip()
117
+ master_key = os.environ.get('MASTER_KEY', '').strip()
118
+
119
+ if master_key and key == master_key:
120
+ session['authenticated'] = True
121
+ session.permanent = True
122
+ return jsonify({"success": True})
123
+ return jsonify({"success": False, "message": "Invalid Master Access Key"})
124
+
125
+ @app.route('/api/license/status')
126
+ def is_licensed():
127
+ # check_auth is already handled by @app.before_request
128
+ valid, msg = get_license_status()
129
+ return jsonify({"licensed": valid, "message": msg})
130
+
131
+ @app.route('/api/license/activate', methods=['POST'])
132
+ def activate_license():
133
+ data = request.json
134
+ email = data.get('email', '')
135
+ key = data.get('key', '')
136
+ if security.verify_license_key(email, key):
137
+ with open('license.dat', 'w') as f:
138
+ json.dump({"email": email, "key": key}, f)
139
+ return jsonify({"success": True})
140
+ return jsonify({"success": False, "message": "Invalid Purchase Email or Key"})
141
+
142
+
143
+ def check_ssl(hostname):
144
+ """Check SSL certificate expiration and status."""
145
+ try:
146
+ context = ssl.create_default_context()
147
+ with socket.create_connection((hostname, 443), timeout=10) as sock:
148
+ with context.wrap_socket(sock, server_hostname=hostname) as ssock:
149
+ cert = ssock.getpeercert()
150
+ expiry_str = cert.get('notAfter')
151
+ expiry_date = datetime.strptime(expiry_str, '%b %d %H:%M:%S %Y %Z')
152
+ days_left = (expiry_date - datetime.now()).days
153
+ if days_left < 7:
154
+ return False, f"SSL Warning: Expiring in {days_left} days", days_left
155
+ return True, f"SSL Valid ({days_left} days left)", days_left
156
+ except Exception as e:
157
+ return False, f"SSL Error: {str(e)}", 0
158
+
159
+
160
+ DEFAULT_BROWSER_HEADERS = {
161
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
162
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
163
+ 'Accept-Language': 'en-US,en;q=0.9',
164
+ 'Cache-Control': 'no-cache',
165
+ 'Pragma': 'no-cache'
166
+ }
167
+
168
+ CONFIG_FILE = 'config.json'
169
+ DB_FILE = 'monitor_metrics.db'
170
+ LAST_ALERT_METRICS = {}
171
+ SERVICE_STATE_CACHE = {}
172
+ FAIL_COUNT_CACHE = {}
173
+ SUCCESS_COUNT_CACHE = {}
174
+ DOWN_START_TIME = {}
175
+ FLAP_COUNTER = {}
176
+
177
+ def get_db_connection():
178
+ conn = sqlite3.connect(DB_FILE, check_same_thread=False)
179
+ conn.row_factory = sqlite3.Row
180
+ return conn
181
+
182
+ def init_db():
183
+ conn = get_db_connection()
184
+ c = conn.cursor()
185
+ c.execute('''CREATE TABLE IF NOT EXISTS status_history
186
+ (timestamp DATETIME, name TEXT, url TEXT, location TEXT, is_up INTEGER, response_time REAL, message TEXT)''')
187
+ conn.commit()
188
+ conn.close()
189
+
190
+ PARLE_TECH_LOGO_B64 = "iVBORw0KGgoAAAANSUhEUgAAAJwAAABoCAYAAAAEu5lCAAAPh0lEQVR4nO2da2wc13XHf/Pa9y5NkaEokXqQlk05rqvYjJWktStZLoJEQOUPqYICTdJGDZAYRVIgsQ23Hww6BYpWzIe2aAO3TWigTYuksoE4CZQiqSzbilNUCtMaaVIwtmlRpkSK5UPa5+zOqx9m+RDJ3Z3ZXc5S5P0BC0jcnbt3Zv733HvOuXNWchzHQSAICLnVHRBsL4TgBIEiBCcIFCE4QaAIwQkCRQhOEChCcIJAEYITBIoQnCBQhOAEgSIEJwgUIThBoAjBCQJFCE4QKEJwgkARghMEihCcIFCE4ASBIgQnCBQhOEGgCMEJAkUIThAoQnCCQBGCEwSKEJwgUNSgv9CwbcYzesX3+5MRNLmxcWDYNi9Nm4RUONEVqruNawUb3SyteS+ihtgdlevu58pr0K7FaI/YDZ8zwEweFoy87+Miaoh98WCkEKjgFoVw8kq0sm2VYVAxONnu8MW9al03Yjxr8efXJY7FTE501dfX8azF6asOI5lo5Q/JDp9JFnhil8ZASvPc9vdnTb58VWPUUDmVLPHMXpl98cYF98cTJUZyUbD9HTeomZwdgK5Yw12oSWumVLX8zeu9gFFL5elZjQ+9YfHynOGracO2+VnWYdRQmbMVxtL+jl9DpX6W+/q1TJSDv1T5i8slJnKm77Y7ZKux/q1gqa1qfV7nNWoFZ3cCn1IBMHEtmWayUy3c8tbZYnLp/VFUnrpichqDYx3eLMi1gs3FjAWyxhu6xCtzli/rsx6Dytp+LvXVdvv69IwGGHwx6mN69KlPP1Tq82qum1Hujxm0RyIEYX9aIzgAGY7FTE4PpNa89cK1EievaWDDqK3yzzMGxzq8NatbDsOZyNKxh3SHj+TM+tcoJqDAl3ZH14h+Imfy5Ss2Ixn370/PatwTM+peNzYNG073wbGOtdd2fRobkH7YlF7qb+8Oca5vefi/oUueptaJnMm3550lC4nsHnvpps9FjUf2xVWe2SvzZFvZCbLhRwuW/6l1gzDsjTnvRtiUggO4MyJxKrkssrF8bcHplsOZBemWsxo1VC5mrA27+PviKg+1K0vfOZ610a0Wl9yzQTY2Z9m/TSu43VGZu6O4U6Olkq6xsF1yFooqg5rJZ5IFTsUNsF0RXFho3uJ8NQlF4mOSG464LIe42qCfspXZtIIDSCnep6bxrMXzc+7Udiji8MQujcMR16q96MS4lKlzxG/kFWpy23O2shQSsTWpuY03idYIrujU9NCuFWy+Pqu5n7Xhnhoxoqm8w9lcEoC7ozCQ0rgrpbhuUc7hlwV8r61iJQcKtYWatRxetGJQcPiQbNPjYQ2+05ah4LgiaSbl/vbgNCWY3Gxa56VWwbBtLt20GS1qEIbjis1ApPKInciZvHjdgaLD8YTDByLu33s0OBU3GMmpjGQ0Ds8U+Wxfc095Imfywyl3UBCW6NRsIop3EcVKTV5rhd3r9JVZkwfytQfYQEzj4XYlMHG2RnA1llNuNqJs3YDuNouBVOVQg245/I0RgjB0x0x+vcs9rf6EwolwgRFLhYLD/6QlDLs5aSSAsZt5Tl9TGbmhAQ5EJe7vVDyFYK7LNtBk67ZI0eFr05Han7PgY20FHm6vkk1pMq2zcEWH4ekw49nc0p8uyyFGiyrkVVBcsR1PODzVU9m6GbbNK3MWpBVISHy0jSVBabLMwc4QzEuQdhg3VS4sWBzr8Ce40aIbgN5/eVVf0ytuqgVDCZ2PdoZrtlcy3TbZCG+2uKJNC1fTiwN89b9bQMun1Bezq0eXs3xhEhJf2l89UzCetfiHG+7VOyUZPNh2q5j6EwrPdRT5XDrE2azE4eum56zFEkWH0aLCKJUtwZM9RX6/V9s066bjCYfdiWLVzyxkHT61J7igL7RScIsjzVp1g0I2xCWGYjp/cjCMJle+IEuhkAUVFOhIWeyOqmucg46wDDEJ8g4XCyHG0ob3dNdKK7Gyr8pyXO+ve2w+d1fYl9gGTYvRjfLZLHiyx+JodwDZeJ+01MKdSpo8dfet4nC3J0lQxZoscq1g8/2b5f9YMHw1zPBVWOt8y4A71ZzNSpzwk1+1ZIg5nNm7bD11s8Q3r0oMTbvT579lJe6bMTna7TOltXqwNYtyu81crzaLFjkNMig2e+MWA231j0LdchiZUddanko3UrGhJHNxVuYjXbXzq/mSu3YcNC06ZVZ8XuV3JIMrOZORjMrZeYXD0RJ9yQZytk2gI2tAgDs/6qGlvZs16h99EzmTb06aUNJAsRkM2RzqsCuGGaZMkxeNGGTgjaLMpZs2++J1fz0DKY0THTlGMu4lHJoO875IgX39rbukhdDmFhtsAqehXnTL4bvz7rR4Kmny3KCKJlc+nZl8mP6f5xgmzmhB5dX/K/JYd2NTzvH9UYb0IkNXo2DJ/P1ChIN+1ofbkM01wXvEsG1+PG0yuiBDyJ2aawmnKwbHFz0yxeY/MgqvzzS2q0OTZY7sUBiMuu2cnVf4wZTka6NAtGQSlTZm3G+29Ru0ysLpNkTqvxjXCjYvZNwF+iOyxNGkgRcnY1dM4tQdJUamVUYtlZ/dNDnaXbuvqYhCpX3bR7tD/GG6wKkJFXSbL0zL3Nfm0YHQbV4uholfLnCnVnuzJMCHe3ZU3wpuyaDbvDpvMZmZ99QmwIM7k/QnNj7j0NIptVOrb8vQOxmbs/MhiMCRjgJH9njbaNifUHiorcjItHva/522mWhkc2aZYz0aT97MMXwjDiV4dd6q6kBoukHKiUFEYrQgM/q2DNQOGBOROZ8s0RWrIeY4DL0d9tYmgCNzJl6iP+Ht443QGpvruKPwSs5/uHsiZ/JP7wJlg3DvHd7b0GSZvpQCKff4kWmV70zWPi4tVc/F7YurHN+j8UjEgYJ7s78zWXkDpBHROG+4n6WAez28vPTqAzRfktz2sh7bW3wFSOAW7r6ExNABVy1HdvgXXFRSOdypszdeoDsq8ZjP2FdfUubMXSY/v+GK6N5OtXq8ypFJORK1HoVyp9ASR+bddnclFRZ0dd3p78E2mef6Skx72ImykkhMoS9ZWSAnOgz2xuvb93dfor4n5PwiiV+EXp+xtMHpMYeRqRCPtDs8c5fhP7ArWMPmc2MEWxohOEGgCMFVwnDI5NzdFmZ2bbkHQX0IwVVCk0igQS7oR+381wa5nbhtU1urWaxb8tW3/aeV+iIGn9xDTadgbK7E6XF4R6/+HZ/cqfOJA962Kxm2zTfeKnLhmsTzmRAQYdEjPpnM8fG7wzzWXdmDfPXdNP84HeHNWYdH99k8vj/quUbI372j863JMOcNh3OHrEC2mm8ZwQFMZWTOvyOBz4Dy+XiIh9oKNbMOUwa8Oetw4UYVx96Q+aBpsdArV73xEzmTv/1FkeG3olQK0J7Jxjkz5f77yQM5nrg3vqbN8YLGyFQIbsCBqA77q5/DSi7OypyfdMCQmT8YzMPbW0ZwC7pMMZ0FR4X1llyhFdvBS6vKhSUi7FBri1QydCC89vh1KDgmlS7vy3MGQxdtLtxYlY5bWUFp1VQ+/FaU43uqZBnqyNp0YLh91GyKCyXo3ng5bBnBdcXg5HtjPNh96xrohqHw0hWF56/IcAcM70rzW/0RpvMrRZPlnkp1OFZsobqnI8Wf/kq6Zl/274hVTGu9Ml3iz163uJBbFtvD7ylysl/hV2NuP9ojNuNZix9MSXzhigpT8Om9Ou9NVXkwpoGtXg0d65MtIzhwU0z74rcKZyJn8vqc620+ahR4f2eYgY4QAx3+g7hdMeiKeS0Qs5axtMEz/+VwoRReskjDB7L80WCivHZabFtmICUzkILPD8BL4zkOtmmVp2ipXMLpNuD26GUDRCWV2XLO9pwW5abTukIzXx8rceGGtmRRRgaLPPFgquZC/bH+ePUBEnA+tBG2lIWrRLdjlpPkeaSSz/xtAUJ5GwqN3dRXpkv89IoM8661/cZRi48faNJDLrn6+nc5Z7vi97YzqincPkMjaAyHSKl5udOfvKtzrrxue7hf5v3doaaEIEKG5Toxpn/VJAKsC7eIEFwAGLbNfFaGjCuKz+4q0d++LSaXNWzPs/bDKstRa/v4elbrx1ezXJzXQI1Cyqane+MCrO0Rrw5Ens644w4CdTuUegiIQqNOghrl3HSBc69Z8Fr1tNN+Jcdf/lqMx/pvfRzMMcMsZg8eDRXZpW1AnQU1yvMTec7/y5znQy7b7wlcAVtecE1BjULtWC+mbZOtsKnyXMnjdu96kMrOhx7jMpvvafuVCMH5odrCvMq0lC4GuMfVj/MQ4FS69JWBf2MLCFkapIsQjZHRM7gJco+ki9Blcf43FI72Raus4Wwgtu7aLBWWyumwGOeyDlNphwGPVdm90JvLMpnVePyQxl/9prtpwMta86lLOYb/Nw7Xqxe9aSZb3kvVdZsZ23X/e3NZkpHGQgGaLFd9rUdfl8LjuyKuePVY0y3eZHz5casFfblUWa1+qlkn0BgcbAPBtUdb/xzC7qhMl1yuNJ0u8tNJiZlmbXszHHpz2SY1tvFsecGtZKUl8IKeac5Uo8kyD/Q6sNN1HJ4ds/lFgNPYZmL7CM5S67METapG9MG9ET4dL89feZlHXrMYm2vO1vVJW6m/n+kibKQHvYrtI7gyGb2OH1GQGg81dMXgUw/I9CbKJbWmwhz8nulJdBM5k7Gb+U35yzJ+2XaCqwtnecFl2Lan13oc7Qsz9IAC7Qoopiu6F0IM/XtxXeHN5OErl9Ls/6rFwW/D+MIG7XRRNu5HU1azLcIieqYIVpRJWyFZpfz+uigmzCg88i0VFA/rrlIYevPlMMraqeoPPhAlEs7wiR+WnZkFi2f/M8yzP7Hc9mM2vbLFZFYrT5PlrMVkmB+9VaC/fZ0qAWqUmqXhq1EIrsL0lhfcQmGF5VCj/uNwK9c3lrcb05uvXhL/d9+XpLcjzdGXLZiMLbdtKVCCNeVOSkBXgTu7KkxI6SIUltNnXrlhxDamknoVtrzg2qMhundkIVTk0U6bnjbvYZJIMgyd0Gv7czYGuhR2papb0iN7Uji/B6+8U+Rf39T47tt5JuUELFiuVQWI2TzaDZ+/3+b4/ui6cb5kRIMuCyji6tG7AzDYVqS3zWIyqdATC2ar0jaqLbK4DvPrANQTMKvPyZjImejlCkmRiOyjjFi959bosf7ZRoITbAaElyoIFCE4QaAIwQkCRQhOEChCcIJAEYITBIoQnCBQhOAEgSIEJwgUIThBoAjBCQJFCE4QKEJwgkARghMEihCcIFCE4ASBIgQnCBQhOEGgCMEJAkUIThAoQnCCQBGCEwSKEJwgUITgBIHy/xBrrmEbfk0tAAAAAElFTkSuQmCC"
191
+
192
+ DEFAULT_SMTP_SENDER = os.environ.get("SMTP_SENDER", "smadapathi04@gmail.com")
193
+ DEFAULT_SMTP_PASSWORD_RAW = os.environ.get("SMTP_PASSWORD", "")
194
+ DEFAULT_SMTP_PASSWORD_ENC = security.encrypt_secret(DEFAULT_SMTP_PASSWORD_RAW) if DEFAULT_SMTP_PASSWORD_RAW else ""
195
+ DEFAULT_EMAIL_RECIPIENTS = ["suresh@parletech.com", "smadapathi04@gmail.com"]
196
+
197
+ @app.after_request
198
+ def add_no_cache_headers(response):
199
+ response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
200
+ response.headers['Pragma'] = 'no-cache'
201
+ response.headers['Expires'] = '0'
202
+ return response
203
+
204
+ @app.route('/static/parle_tech_logo.png')
205
+ def serve_parle_logo():
206
+ img_data = base64.b64decode(PARLE_TECH_LOGO_B64)
207
+ return Response(img_data, mimetype='image/png')
208
+
209
+ def load_config():
210
+ config = {}
211
+ if os.path.exists(CONFIG_FILE):
212
+ try:
213
+ with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
214
+ config = json.load(f)
215
+ except Exception:
216
+ config = {}
217
+
218
+ if config.get("services_to_monitor") is None:
219
+ config["services_to_monitor"] = [
220
+ {"name": "Microsoft 365", "url": "https://status.office365.com", "location": "Global", "type": "http_code", "expected_code": 200},
221
+ {"name": "RingCentral", "url": "https://status.ringcentral.com", "location": "Global", "type": "http_code", "expected_code": 200},
222
+ {"name": "Skybridge Healthcare", "url": "https://www.skybridgehealthcare.com/", "location": "Primary Web", "type": "http_code", "expected_code": 200},
223
+ {"name": "Skybridge Aviation", "url": "https://skybridgeaviation.com/", "location": "Primary Web", "type": "http_code", "expected_code": 200},
224
+ {"name": "Skybridge Resources", "url": "https://skybridgeresources.com/", "location": "Primary Web", "type": "http_code", "expected_code": 200},
225
+ {"name": "BetterLending Website", "url": "https://betterlending.com/", "location": "Primary Web", "type": "http_code", "expected_code": 200}
226
+ ]
227
+
228
+ config.setdefault("check_interval", 60)
229
+ config.setdefault("consecutive_failures_required", 3)
230
+ config.setdefault("retry_delay_seconds", 3)
231
+ config.setdefault("cooldown_minutes", 60)
232
+ config.setdefault("alert_mode", "SMTP")
233
+
234
+ # Senior DevOps Fallback to guarantee credentials even in stateless Docker
235
+ if not config.get("smtp_sender"):
236
+ config["smtp_sender"] = os.environ.get("SMTP_SENDER", DEFAULT_SMTP_SENDER)
237
+ if not config.get("smtp_password"):
238
+ config["smtp_password"] = os.environ.get("SMTP_PASSWORD", DEFAULT_SMTP_PASSWORD_ENC)
239
+
240
+ if not config.get("email_recipients"):
241
+ env_recips = os.environ.get("EMAIL_RECIPIENTS", "").strip()
242
+ if env_recips:
243
+ config["email_recipients"] = [r.strip() for r in env_recips.split(',') if r.strip()]
244
+ else:
245
+ config["email_recipients"] = DEFAULT_EMAIL_RECIPIENTS
246
+
247
+ return config
248
+
249
+ def save_config(config):
250
+ if "smtp_password" in config and config["smtp_password"]:
251
+ pwd = str(config["smtp_password"]).strip()
252
+ if pwd and not pwd.startswith("••••") and not pwd.startswith("ENC:"):
253
+ config["smtp_password"] = security.encrypt_secret(pwd)
254
+ with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
255
+ json.dump(config, f, indent=4)
256
+
257
+ def check_service_logic(service):
258
+ url = service.get("url", "")
259
+ check_type = service.get("type", "http_code")
260
+ start_time = time.time()
261
+ try:
262
+ if check_type == "http_code":
263
+ expected = service.get("expected_code", 200)
264
+ response = requests.get(url, headers=DEFAULT_BROWSER_HEADERS, timeout=12, allow_redirects=True)
265
+ latency = round((time.time() - start_time) * 1000, 2)
266
+ if response.status_code != expected:
267
+ if response.status_code in [403, 406, 429, 503]:
268
+ sess = requests.Session()
269
+ sess.headers.update(DEFAULT_BROWSER_HEADERS)
270
+ r2 = sess.get(url, timeout=12, allow_redirects=True)
271
+ if r2.status_code == expected:
272
+ return True, "OK (WAF Bypass)", round((time.time() - start_time) * 1000, 2)
273
+ return False, f"HTTP {response.status_code}", latency
274
+ return True, "OK", latency
275
+ elif check_type == "json_status":
276
+ response = requests.get(url, headers=DEFAULT_BROWSER_HEADERS, timeout=12, allow_redirects=True)
277
+ latency = round((time.time() - start_time) * 1000, 2)
278
+ data = response.json()
279
+ key = service.get("key_to_check", "")
280
+ expected_val = service.get("expected_value", "")
281
+ val = data
282
+ for k in key.split('.'):
283
+ val = val.get(k)
284
+ if val is None: break
285
+ if str(val).lower() != str(expected_val).lower():
286
+ return False, f"JSON {val}", latency
287
+ return True, "OK", latency
288
+ elif check_type == "m365_mail":
289
+ sender = service.get('sender_email')
290
+ pwd = service.get('sender_password')
291
+ receiver = service.get('receiver_email', sender)
292
+ imap_server = service.get('imap_server', 'outlook.office365.com')
293
+
294
+ pulse_id = f"StatusPilot-Pulse-{int(time.time())}"
295
+ msg = MIMEMultipart()
296
+ msg['From'] = sender
297
+ msg['To'] = receiver
298
+ msg['Subject'] = pulse_id
299
+ msg.attach(MIMEText(f"Mail Flow Heartbeat sent at {datetime.now()}", 'plain'))
300
+
301
+ try:
302
+ with smtplib.SMTP('smtp.office365.com', 587, timeout=15) as server:
303
+ server.starttls()
304
+ server.login(sender, pwd)
305
+ server.sendmail(sender, receiver, msg.as_string())
306
+
307
+ with imaplib.IMAP4_SSL(imap_server) as mail:
308
+ mail.login(receiver, service.get('receiver_password', pwd))
309
+ mail.select('inbox')
310
+ typ, data = mail.search(None, '(SUBJECT "StatusPilot-Pulse-")')
311
+ if typ == 'OK' and data[0]:
312
+ return True, "Mail Flow Active (Pulse Received)", 120
313
+
314
+ return True, "Pulse Sent (Awaiting Receipt)", 50
315
+ except Exception as e:
316
+ return False, f"Mail Flow Error: {str(e)}", 0
317
+ elif check_type == "ssl":
318
+ hostname = url.replace('https://', '').replace('http://', '').split('/')[0].split(':')[0]
319
+ ok, msg, days = check_ssl(hostname)
320
+ latency = round((time.time() - start_time) * 1000, 2)
321
+ return ok, msg, latency
322
+ except requests.exceptions.Timeout:
323
+ latency = round((time.time() - start_time) * 1000, 2)
324
+ return False, "Connection Timeout (12s)", latency
325
+ except requests.exceptions.RequestException as re:
326
+ latency = round((time.time() - start_time) * 1000, 2)
327
+ return False, f"Connection Failed: {str(re)}", latency
328
+ except Exception as e:
329
+ latency = round((time.time() - start_time) * 1000, 2)
330
+ return False, f"Engine Error: {str(e)}", latency
331
+ return True, "OK", 0
332
+
333
+ def check_service_with_retry(service, retry_delay=3):
334
+ """Immediate re-check logic to eliminate single-packet transient glitches."""
335
+ is_up, msg, latency = check_service_logic(service)
336
+ if not is_up:
337
+ time.sleep(retry_delay)
338
+ is_up, msg, latency = check_service_logic(service)
339
+ if not is_up:
340
+ msg = f"{msg} (Confirmed)"
341
+ return is_up, msg, latency
342
+
343
+ def background_monitor_thread():
344
+ global LAST_ALERT_METRICS, SERVICE_STATE_CACHE, FAIL_COUNT_CACHE, SUCCESS_COUNT_CACHE, DOWN_START_TIME, FLAP_COUNTER
345
+ while True:
346
+ try:
347
+ config = load_config()
348
+ services = config.get("services_to_monitor", [])
349
+ interval = config.get("check_interval", 60)
350
+ failures_required = config.get("consecutive_failures_required", 3)
351
+ retry_delay = config.get("retry_delay_seconds", 3)
352
+ cooldown_secs = config.get("cooldown_minutes", 60) * 60
353
+
354
+ for svc in services:
355
+ name = svc.get('name', 'Unknown')
356
+ loc = svc.get('location', 'Global')
357
+ url = svc.get('url', '')
358
+
359
+ is_up, msg, latency = check_service_with_retry(svc, retry_delay=retry_delay)
360
+ previous_state = SERVICE_STATE_CACHE.get(name, True)
361
+
362
+ if is_up:
363
+ FAIL_COUNT_CACHE[name] = 0
364
+ succ_cnt = SUCCESS_COUNT_CACHE.get(name, 0) + 1
365
+ SUCCESS_COUNT_CACHE[name] = succ_cnt
366
+
367
+ # Senior DevOps Flap Dampening: Require 3 consecutive success checks before declaring recovery
368
+ if not previous_state and succ_cnt >= 3:
369
+ SERVICE_STATE_CACHE[name] = True
370
+ down_time = DOWN_START_TIME.pop(name, None)
371
+ duration_str = None
372
+ if down_time:
373
+ secs = int(time.time() - down_time)
374
+ m, s = divmod(secs, 60)
375
+ h, m = divmod(m, 60)
376
+ duration_str = f"{h}h {m}m {s}s" if h > 0 else f"{m}m {s}s" if m > 0 else f"{s}s"
377
+
378
+ now_t = time.time()
379
+ flaps = [t for t in FLAP_COUNTER.get(name, []) if (now_t - t) < 3600]
380
+ flaps.append(now_t)
381
+ FLAP_COUNTER[name] = flaps
382
+
383
+ if len(flaps) >= 4:
384
+ print(f"[FLAP SUPPRESSION ACTIVE] {name} state oscillating rapidly. Suppressing resolution email.")
385
+ else:
386
+ print(f"[RESOLVED] {name} is back ONLINE (Downtime: {duration_str})")
387
+ send_alert(config, name, loc, url, "SERVICE RESTORED", "Status: Operational (Verified across 3 consecutive checks)", is_resolved=True, duration_str=duration_str)
388
+ else:
389
+ SUCCESS_COUNT_CACHE[name] = 0
390
+ fail_cnt = FAIL_COUNT_CACHE.get(name, 0) + 1
391
+ FAIL_COUNT_CACHE[name] = fail_cnt
392
+
393
+ if fail_cnt >= failures_required:
394
+ curr = time.time()
395
+ if previous_state:
396
+ SERVICE_STATE_CACHE[name] = False
397
+ DOWN_START_TIME[name] = curr
398
+ LAST_ALERT_METRICS[name] = curr
399
+
400
+ flaps = [t for t in FLAP_COUNTER.get(name, []) if (curr - t) < 3600]
401
+ flaps.append(curr)
402
+ FLAP_COUNTER[name] = flaps
403
+
404
+ if len(flaps) >= 4:
405
+ print(f"[FLAP SUPPRESSION ACTIVE] {name} is flapping. Sending single Consolidated Flap Alert.")
406
+ alert_details = f"FLAP WARNING: {name} is experiencing rapid state oscillation ({len(flaps)} state changes in 1hr). Notifications throttled for 60 minutes."
407
+ send_alert(config, name, loc, url, "FLAPPING / UNSTABLE", alert_details, is_resolved=False)
408
+ else:
409
+ alert_details = f"{msg} (Verified across {fail_cnt} consecutive checks)"
410
+ print(f"[CRITICAL DOWN] {name} confirmed DOWN ({msg})")
411
+ send_alert(config, name, loc, url, msg, alert_details, is_resolved=False)
412
+ else:
413
+ last_alert = LAST_ALERT_METRICS.get(name, 0)
414
+ if (curr - last_alert) > cooldown_secs:
415
+ LAST_ALERT_METRICS[name] = curr
416
+ alert_details = f"Ongoing Outage: {msg} (Service remains unreachable)"
417
+ print(f"[CRITICAL DOWN REMINDER] {name} still DOWN")
418
+ send_alert(config, name, loc, url, msg, alert_details, is_resolved=False)
419
+
420
+ try:
421
+ conn = get_db_connection()
422
+ c = conn.cursor()
423
+ c.execute("INSERT INTO status_history (timestamp, name, url, location, is_up, response_time, message) VALUES (?, ?, ?, ?, ?, ?, ?)",
424
+ (datetime.now().isoformat(), name, url, loc, 1 if is_up else 0, latency, msg))
425
+ conn.commit()
426
+ conn.close()
427
+ except Exception as e:
428
+ print("DB error", e)
429
+
430
+ time.sleep(max(interval, 10))
431
+ except Exception as e:
432
+ print(f"Monitor error: {e}")
433
+ time.sleep(10)
434
+
435
+ def send_alert(config, name, loc, url, msg, details, is_resolved=False, duration_str=None, is_test=False):
436
+ """Unified executive alerting engine supporting HTML formatting for SMTP and Outlook."""
437
+ if is_test:
438
+ subject = f"[TEST ALERT] StatusPilot Diagnostic Notification ({name})"
439
+ badge_color = "#00D4FF"
440
+ badge_text = "TEST DIAGNOSTIC"
441
+ header_title = "Diagnostic Alert Test"
442
+ elif is_resolved:
443
+ subject = f"[RESOLVED] {name} ({loc}) is back ONLINE"
444
+ badge_color = "#10B981"
445
+ badge_text = "SERVICE RESTORED"
446
+ header_title = f"{name} Restored"
447
+ else:
448
+ subject = f"[CRITICAL DOWN] {name} ({loc}) is DOWN"
449
+ badge_color = "#EF4444"
450
+ badge_text = "CRITICAL DOWNTIME"
451
+ header_title = f"{name} Outage Detected"
452
+
453
+ now_utc = datetime.utcnow()
454
+ est_str = (now_utc - timedelta(hours=4)).strftime('%Y-%m-%d %H:%M:%S EDT')
455
+ utc_str = now_utc.strftime('%H:%M:%S UTC')
456
+ ist_str = (now_utc + timedelta(hours=5, minutes=30)).strftime('%H:%M:%S IST')
457
+ timestamp_str = f"{est_str} ({utc_str} | {ist_str})"
458
+
459
+ duration_row = f"<tr><td style='padding:12px 16px; font-weight:600; color:#64748B; border-bottom:1px solid #E2E8F0; background:#F1F5F9;'>Downtime Duration:</td><td style='padding:12px 16px; font-weight:700; color:#0F172A; border-bottom:1px solid #E2E8F0;'>{duration_str}</td></tr>" if duration_str else ""
460
+ logo_img_tag = '<img src="https://chotustatus-statuspilot.hf.space/static/parle_tech_logo.png" height="46" alt="PARLE TECH" style="display:inline-block; border:0; outline:none; height:46px; width:auto; vertical-align:middle;" />'
461
+
462
+ html_content = f"""<!DOCTYPE html>
463
+ <html>
464
+ <head><meta charset="utf-8"></head>
465
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #F8FAFC; margin: 0; padding: 28px 16px;">
466
+ <div style="max-width: 640px; margin: 0 auto; background: #FFFFFF; border-radius: 12px; border: 1px solid #E2E8F0; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05);">
467
+
468
+ <!-- PARLE TECH BRAND HEADER -->
469
+ <div style="background-color: #0F172A; padding: 24px 30px; border-bottom: 3px solid #00C8F0;">
470
+ <table style="width: 100%; border-collapse: collapse;">
471
+ <tr>
472
+ <td style="vertical-align: middle;">
473
+ <div style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 15px; font-weight: 700; color: #FFFFFF; letter-spacing: 0.5px;">Parle Tech Infrastructure Monitoring</div>
474
+ <div style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; font-weight: 600; color: #00C8F0; letter-spacing: 1.5px; text-transform: uppercase; margin-top: 2px;">24/7 Enterprise Vigilance</div>
475
+ </td>
476
+ <td style="text-align: right; vertical-align: middle; white-space: nowrap;">
477
+ {logo_img_tag}
478
+ </td>
479
+ </tr>
480
+ </table>
481
+ </div>
482
+
483
+ <!-- INCIDENT STATUS BANNER -->
484
+ <div style="background-color: {badge_color}; padding: 18px 30px; color: #FFFFFF;">
485
+ <div style="font-size: 11px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; opacity: 0.95;">INCIDENT CLASSIFICATION ALARM</div>
486
+ <h2 style="margin: 4px 0 0 0; font-size: 20px; font-weight: 700; color: #FFFFFF; letter-spacing: 0.5px;">{badge_text}: {header_title}</h2>
487
+ </div>
488
+
489
+ <!-- MAIN BODY CONTAINER -->
490
+ <div style="padding: 30px;">
491
+ <p style="font-size: 14px; color: #334155; margin-top: 0; line-height: 1.6;">
492
+ An automated vigilance event has been flagged by <strong>Parle Tech StatusPilot Infrastructure Security</strong>. Immediate technical telemetry details are provided below:
493
+ </p>
494
+
495
+ <!-- METRICS GRID TABLE -->
496
+ <table style="width: 100%; border-collapse: collapse; font-size: 13px; margin: 24px 0; background: #F8FAFC; border-radius: 8px; border: 1px solid #E2E8F0; overflow: hidden;">
497
+ <tr>
498
+ <td style="padding: 12px 16px; font-weight: 600; color: #64748B; border-bottom: 1px solid #E2E8F0; width: 35%; background: #F1F5F9;">Target Node / Service:</td>
499
+ <td style="padding: 12px 16px; font-weight: 700; color: #0F172A; border-bottom: 1px solid #E2E8F0;">{name}</td>
500
+ </tr>
501
+ <tr>
502
+ <td style="padding: 12px 16px; font-weight: 600; color: #64748B; border-bottom: 1px solid #E2E8F0; background: #F1F5F9;">Scope / Region:</td>
503
+ <td style="padding: 12px 16px; color: #0F172A; border-bottom: 1px solid #E2E8F0; font-weight: 600;">{loc}</td>
504
+ </tr>
505
+ <tr>
506
+ <td style="padding: 12px 16px; font-weight: 600; color: #64748B; border-bottom: 1px solid #E2E8F0; background: #F1F5F9;">Endpoint URL:</td>
507
+ <td style="padding: 12px 16px; color: #0284C7; border-bottom: 1px solid #E2E8F0; word-break: break-all; font-weight: 600;"><a href="{url}" target="_blank" style="color: #0284C7; text-decoration: none;">{url}</a></td>
508
+ </tr>
509
+ <tr>
510
+ <td style="padding: 12px 16px; font-weight: 600; color: #64748B; border-bottom: 1px solid #E2E8F0; background: #F1F5F9;">Vigilance Status:</td>
511
+ <td style="padding: 12px 16px; font-weight: 800; color: {badge_color}; border-bottom: 1px solid #E2E8F0;">{msg}</td>
512
+ </tr>
513
+ <tr>
514
+ <td style="padding: 12px 16px; font-weight: 600; color: #64748B; border-bottom: 1px solid #E2E8F0; background: #F1F5F9;">Technical Diagnostic:</td>
515
+ <td style="padding: 12px 16px; color: #334155; border-bottom: 1px solid #E2E8F0; line-height: 1.5;">{details}</td>
516
+ </tr>
517
+ {duration_row}
518
+ <tr>
519
+ <td style="padding: 12px 16px; font-weight: 600; color: #64748B; background: #F1F5F9;">Event Timestamp:</td>
520
+ <td style="padding: 12px 16px; color: #0F172A; font-weight: 600;">{timestamp_str}</td>
521
+ </tr>
522
+ </table>
523
+
524
+ <!-- ACTIONABLE CTA BUTTON -->
525
+ <div style="text-align: center; margin: 32px 0 20px 0;">
526
+ <a href="https://chotustatus-statuspilot.hf.space/" target="_blank" style="background-color: #0F172A; color: #00D4FF; font-family: 'Segoe UI', sans-serif; font-size: 13px; font-weight: 700; text-decoration: none; padding: 14px 32px; border-radius: 8px; letter-spacing: 1.5px; display: inline-block; border: 1px solid #00D4FF; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.15);">
527
+ 🌐 OPEN LIVE NOC OPERATIONS CENTER
528
+ </a>
529
+ </div>
530
+
531
+ <!-- CORPORATE SIGNATURE -->
532
+ <div style="margin-top: 32px; padding-top: 20px; border-top: 1px solid #E2E8F0; text-align: center; font-size: 11px; color: #94A3B8; line-height: 1.6;">
533
+ <strong>Parle Tech Infrastructure Security & NOC Engine v5.1</strong><br>
534
+ 24/7 Enterprise Infrastructure Alerting • Automated Incident Management
535
+ </div>
536
+ </div>
537
+ </div>
538
+ </body>
539
+ </html>"""
540
+
541
+ plain_body = f"[{badge_text}]\nService: {name}\nLocation: {loc}\nURL: {url}\nStatus: {msg}\nDetails: {details}\nTime: {timestamp_str}"
542
+ if duration_str:
543
+ plain_body += f"\nDowntime Duration: {duration_str}"
544
+
545
+ alert_mode = config.get("alert_mode", "SMTP")
546
+ raw_recipients = config.get("email_recipients", [])
547
+ if isinstance(raw_recipients, str):
548
+ recipients = [r.strip() for r in raw_recipients.split(',') if r.strip()]
549
+ elif isinstance(raw_recipients, list):
550
+ recipients = [r.strip() for r in raw_recipients if isinstance(r, str) and r.strip()]
551
+ else:
552
+ recipients = []
553
+
554
+ sender = config.get("smtp_sender", "").strip() or DEFAULT_SMTP_SENDER
555
+ raw_pwd = os.environ.get('SMTP_PASSWORD') or config.get("smtp_password", "")
556
+ password = security.decrypt_secret(raw_pwd).strip()
557
+ if not password:
558
+ password = DEFAULT_SMTP_PASSWORD_RAW
559
+
560
+ if not recipients:
561
+ recipients = config.get("email_recipients", []) or DEFAULT_EMAIL_RECIPIENTS
562
+ if not recipients and sender:
563
+ recipients = [sender]
564
+
565
+ if alert_mode == "SMTP":
566
+ if not sender:
567
+ sender = DEFAULT_SMTP_SENDER
568
+ smtp_host = config.get("smtp_server", "").strip()
569
+ if not smtp_host:
570
+ if any(dom in sender.lower() for dom in ["@outlook.", "@hotmail.", "@live.", "@office365."]):
571
+ smtp_host = "smtp.office365.com"
572
+ else:
573
+ smtp_host = "smtp.gmail.com"
574
+
575
+ # Remove spaces in Google/Microsoft App Passwords (e.g. "abcd efgh ijkl mnop" -> "abcdefghijklmnop")
576
+ clean_pwd = (password or DEFAULT_SMTP_PASSWORD_RAW).replace(" ", "")
577
+
578
+ connected = False
579
+ last_error = None
580
+
581
+ # Custom Webhook / Google Apps Script (HTTPS Port 443 - works on Hugging Face)
582
+ webhook_url = os.environ.get("EMAIL_WEBHOOK_URL") or config.get("email_webhook_url", "").strip()
583
+ if webhook_url and not connected:
584
+ try:
585
+ all_webhook_sent = True
586
+ for recipient in recipients:
587
+ payload = {
588
+ "sender": sender,
589
+ "senderName": "StatusPilot NOC",
590
+ "recipient": recipient,
591
+ "subject": subject,
592
+ "body": plain_body,
593
+ "htmlBody": html_content
594
+ }
595
+ w_res = requests.post(webhook_url, json=payload, timeout=20, allow_redirects=True)
596
+ print(f"Webhook response for {recipient}: status={w_res.status_code} body={w_res.text[:200]}")
597
+ if w_res.status_code not in [200, 201, 202] and "OK" not in w_res.text.upper() and "SUCCESS" not in w_res.text.upper():
598
+ all_webhook_sent = False
599
+ if all_webhook_sent:
600
+ connected = True
601
+ print(f"Alert successfully dispatched via HTTPS Webhook to all recipients: {recipients}")
602
+ except Exception as ew:
603
+ last_error = ew
604
+ print(f"Webhook dispatch failed: {ew}")
605
+
606
+ # Try Standard Gmail Port 465 (SMTP_SSL)
607
+ if not connected:
608
+ try:
609
+ ssl_context = ssl.create_default_context()
610
+ from email.utils import formataddr
611
+ display_from = formataddr(("StatusPilot NOC", sender))
612
+ with smtplib.SMTP_SSL(smtp_host, 465, context=ssl_context, timeout=12) as server:
613
+ server.login(sender, clean_pwd)
614
+ for recipient in recipients:
615
+ mime_msg = MIMEMultipart("alternative")
616
+ mime_msg['From'] = display_from
617
+ mime_msg['To'] = recipient
618
+ mime_msg['Subject'] = subject
619
+ mime_msg.attach(MIMEText(plain_body, 'plain'))
620
+ mime_msg.attach(MIMEText(html_content, 'html'))
621
+ server.sendmail(sender, recipient, mime_msg.as_string())
622
+ connected = True
623
+ print(f"Alert successfully dispatched via SMTPS Port 465 to {recipients}")
624
+ except Exception as e1:
625
+ last_error = e1
626
+ print(f"Port 465 SMTPS failed: {e1}. Trying Port 587 STARTTLS...")
627
+
628
+ # Try Standard Gmail Port 587 (STARTTLS)
629
+ if not connected:
630
+ try:
631
+ from email.utils import formataddr
632
+ display_from = formataddr(("StatusPilot NOC", sender))
633
+ with smtplib.SMTP(smtp_host, 587, timeout=12) as server:
634
+ server.starttls()
635
+ server.login(sender, clean_pwd)
636
+ for recipient in recipients:
637
+ mime_msg = MIMEMultipart("alternative")
638
+ mime_msg['From'] = display_from
639
+ mime_msg['To'] = recipient
640
+ mime_msg['Subject'] = subject
641
+ mime_msg.attach(MIMEText(plain_body, 'plain'))
642
+ mime_msg.attach(MIMEText(html_content, 'html'))
643
+ server.sendmail(sender, recipient, mime_msg.as_string())
644
+ connected = True
645
+ print(f"Alert successfully dispatched via STARTTLS Port 587 to {recipients}")
646
+ except Exception as e2:
647
+ last_error = e2
648
+ print(f"Port 587 STARTTLS failed: {e2}")
649
+
650
+ if not connected and is_test:
651
+ raise RuntimeError(f"SMTP Delivery Error ({smtp_host}): {last_error}")
652
+
653
+ elif alert_mode == "Outlook" and OUTL_AVAIL:
654
+ try:
655
+ outlook = win32com.client.Dispatch("Outlook.Application")
656
+ mail = outlook.CreateItem(0)
657
+ mail.Subject = subject
658
+ mail.HTMLBody = html_content
659
+ if recipients:
660
+ mail.To = "; ".join(recipients)
661
+ mail.Send()
662
+ print("Outlook HTML alert sent.")
663
+ except Exception as e:
664
+ print(f"Outlook Alert Error: {e}")
665
+ if is_test:
666
+ raise RuntimeError(f"Outlook App Dispatch Error: {str(e)}")
667
+
668
+ # Start thread automatically
669
+ init_db()
670
+ if not os.path.exists(CONFIG_FILE):
671
+ save_config(load_config())
672
+ threading.Thread(target=background_monitor_thread, daemon=True).start()
673
+
674
+ # --- WEB ROUTES ---
675
+
676
+ @app.route('/')
677
+ def serve_dashboard():
678
+ """Forcing a new template name to bypass persistent cloud caching."""
679
+ return render_template('dashboard_v6.html', logo_b64=PARLE_TECH_LOGO_B64)
680
+
681
+ @app.route('/api/config/delete', methods=['POST'])
682
+ def delete_config_service():
683
+ """Expert-grade service decommissioning via POST for cloud compatibility."""
684
+ if not check_auth():
685
+ return jsonify({"error": "Unauthorized"}), 401
686
+
687
+ name = request.json.get('name')
688
+ print(f"DECOMMISSION REQUEST: {name}")
689
+
690
+ config = load_config()
691
+ original_count = len(config.get('services_to_monitor', []))
692
+
693
+ config['services_to_monitor'] = [s for s in config['services_to_monitor'] if s.get('name') != name]
694
+
695
+ new_count = len(config['services_to_monitor'])
696
+ save_config(config)
697
+
698
+ if original_count == new_count:
699
+ return jsonify({"status": "error", "message": f"Service '{name}' not found."}), 404
700
+
701
+ print(f"Successfully decommissioned: {name}")
702
+ return jsonify({"status": "success", "deleted": name})
703
+
704
+ @app.route('/api/config', methods=['GET', 'POST'])
705
+ def handle_config():
706
+ if request.method == 'POST':
707
+ new_config = request.json or {}
708
+ current_config = load_config()
709
+ new_pwd = str(new_config.get("smtp_password", "")).strip()
710
+ if not new_pwd or new_pwd.startswith("••••"):
711
+ new_config["smtp_password"] = current_config.get("smtp_password", "")
712
+ else:
713
+ new_config["smtp_password"] = security.encrypt_secret(new_pwd)
714
+ save_config(new_config)
715
+ return jsonify({"status": "success"})
716
+
717
+ cfg = load_config()
718
+ if cfg.get("smtp_password"):
719
+ cfg["smtp_password"] = "••••••••••••"
720
+ return jsonify(cfg)
721
+
722
+ @app.route('/api/clear_ai', methods=['POST'])
723
+ def clear_ai_services():
724
+ config = load_config()
725
+ # Robust filter: Removes anything starting with 'Ai Service', 'New Service', or 'Cloud Service'
726
+ patterns = ["Ai Service", "New Service", "Cloud Service", "Https", "Global"]
727
+ config['services_to_monitor'] = [s for s in config['services_to_monitor'] if not any(p in s['name'] for p in patterns)]
728
+ save_config(config)
729
+ return jsonify({"status": "success"})
730
+
731
+ @app.route('/api/status', methods=['GET'])
732
+ def get_status():
733
+ config = load_config()
734
+ services = config.get("services_to_monitor", [])
735
+
736
+ conn = get_db_connection()
737
+ c = conn.cursor()
738
+
739
+ results = []
740
+ for svc in services:
741
+ name = svc.get('name')
742
+ # Get latest status
743
+ c.execute("SELECT is_up, response_time, message, timestamp FROM status_history WHERE name=? ORDER BY timestamp DESC LIMIT 1", (name,))
744
+ row = c.fetchone()
745
+
746
+ # Get last 30 checks for timeline
747
+ c.execute("SELECT is_up FROM status_history WHERE name=? ORDER BY timestamp DESC LIMIT 30", (name,))
748
+ history_rows = c.fetchall()
749
+ timeline = [bool(h["is_up"]) for h in reversed(history_rows)]
750
+
751
+ if not row:
752
+ is_up, msg, latency = check_service_logic(svc)
753
+ out_row = {"is_up": 1 if is_up else 0, "response_time": latency, "message": msg, "timestamp": datetime.now().isoformat()}
754
+ else:
755
+ out_row = {"is_up": row["is_up"], "response_time": row["response_time"], "message": row["message"], "timestamp": row["timestamp"]}
756
+
757
+ results.append({
758
+ "name": name,
759
+ "url": svc.get("url"),
760
+ "location": svc.get("location", "Global"),
761
+ "type": svc.get("type"),
762
+ "is_up": bool(out_row["is_up"]),
763
+ "latency": out_row["response_time"],
764
+ "message": out_row["message"],
765
+ "last_check": out_row["timestamp"],
766
+ "timeline": timeline
767
+ })
768
+ conn.close()
769
+ return jsonify(results)
770
+
771
+ @app.route('/api/summary', methods=['GET'])
772
+ def get_summary():
773
+ conn = get_db_connection()
774
+ c = conn.cursor()
775
+
776
+ # Total Monitors
777
+ config = load_config()
778
+ services = config.get("services_to_monitor", [])
779
+ total_monitors = len(services)
780
+
781
+ # Global Uptime % (Last 24 hours)
782
+ yesterday = (datetime.now() - timedelta(days=1)).isoformat()
783
+ c.execute("SELECT AVG(is_up) * 100 FROM status_history WHERE timestamp > ?", (yesterday,))
784
+ uptime_pct = c.fetchone()[0] or 100.0
785
+
786
+ # Total Incidents (Count of UNIQUE services that are CURRENTLY down)
787
+ incidents = 0
788
+ for svc in services:
789
+ name = svc.get('name')
790
+ c.execute("SELECT is_up FROM status_history WHERE name=? ORDER BY timestamp DESC LIMIT 1", (name,))
791
+ row = c.fetchone()
792
+ if row and row[0] == 0:
793
+ incidents += 1
794
+
795
+ # Avg Latency
796
+ c.execute("SELECT AVG(response_time) FROM status_history WHERE timestamp > ?", (yesterday,))
797
+ avg_latency = c.fetchone()[0] or 0
798
+
799
+ conn.close()
800
+ return jsonify({
801
+ "total_monitors": total_monitors,
802
+ "uptime_pct": round(uptime_pct, 2),
803
+ "incidents": incidents,
804
+ "avg_latency": round(avg_latency, 2)
805
+ })
806
+
807
+ @app.route('/api/admin/purge_stale', methods=['POST'])
808
+ def purge_stale_data():
809
+ """Purge history records for services no longer in the current configuration."""
810
+ if not check_auth():
811
+ return jsonify({"error": "Unauthorized"}), 401
812
+
813
+ config = load_config()
814
+ active_names = [s.get('name') for s in config.get('services_to_monitor', [])]
815
+
816
+ conn = get_db_connection()
817
+ c = conn.cursor()
818
+
819
+ # 1. Delete records for non-existent services
820
+ placeholders = ','.join(['?'] * len(active_names))
821
+ if active_names:
822
+ c.execute(f"DELETE FROM status_history WHERE name NOT IN ({placeholders})", active_names)
823
+ else:
824
+ c.execute("DELETE FROM status_history")
825
+
826
+ # 2. Also clear very old data (older than 7 days) to keep DB light
827
+ one_week_ago = (datetime.now() - timedelta(days=7)).isoformat()
828
+ c.execute("DELETE FROM status_history WHERE timestamp < ?", (one_week_ago,))
829
+
830
+ count = conn.total_changes
831
+ conn.commit()
832
+ conn.close()
833
+
834
+ return jsonify({"status": "success", "purged_records": count})
835
+
836
+ @app.route('/api/history/<path:service_name>', methods=['GET'])
837
+ def get_detailed_history(service_name):
838
+ conn = get_db_connection()
839
+ c = conn.cursor()
840
+ # Get last 100 records for charts
841
+ c.execute("SELECT timestamp, is_up, response_time FROM status_history WHERE name=? ORDER BY timestamp DESC LIMIT 100", (service_name,))
842
+ rows = c.fetchall()
843
+ conn.close()
844
+
845
+ data = [{
846
+ "timestamp": r["timestamp"],
847
+ "is_up": bool(r["is_up"]),
848
+ "latency": r["response_time"]
849
+ } for r in reversed(rows)]
850
+
851
+ return jsonify(data)
852
+
853
+ @app.route('/api/test', methods=['POST'])
854
+ def test_tool():
855
+ """Live interactive testing for specific tools and locations."""
856
+ data = request.json
857
+ tool_name = data.get("tool")
858
+ location = data.get("location")
859
+
860
+ # Simple direct ping/fetch simulation
861
+ config = load_config()
862
+ # Find matching service, or just do a generic requests test
863
+ matched_svc = next((s for s in config.get("services_to_monitor", []) if s.get("name") == tool_name), None)
864
+
865
+ if not matched_svc:
866
+ return jsonify({"status": "error", "message": "Service not found in configuration!"}), 404
867
+
868
+ is_up, msg, latency = check_service_logic(matched_svc)
869
+ return jsonify({
870
+ "tool": tool_name,
871
+ "location": location,
872
+ "is_up": is_up,
873
+ "latency": latency,
874
+ "message": msg,
875
+ "timestamp": datetime.now().isoformat()
876
+ })
877
+
878
+ @app.route('/api/ai_add', methods=['POST'])
879
+ def ai_add_service():
880
+ """Neural Engine v4 - Context-Aware Entity Extraction."""
881
+ raw_text = request.json.get("text", "").strip()
882
+ if not raw_text:
883
+ return jsonify({"status": "error", "message": "Neural link empty. Please provide instructions."})
884
+
885
+ # 1. URL Extraction (Highest Priority)
886
+ url_match = re.search(r'(https?://[^\s]+)', raw_text)
887
+ if not url_match:
888
+ return jsonify({"status": "error", "message": "No endpoint detected. Try adding a URL like 'https://status.teams.com'"})
889
+ url = url_match.group(1).rstrip(',.!?')
890
+
891
+ # 2. Contextual Cleaning
892
+ clean_text = raw_text.replace(url, "").lower()
893
+ for cmd in ["monitor", "add", "check", "service", "from", "at", "called", "in"]:
894
+ clean_text = clean_text.replace(cmd, "")
895
+ clean_text = " ".join(clean_text.split())
896
+
897
+ # 3. Entity Extraction (Name & Location)
898
+ # Strategy: Locations are usually at the end or preceded by 'in'
899
+ location = "Global"
900
+ loc_match = re.search(r'(?:in|at|site|location|node)\s+([a-z\s]{2,20})$', raw_text, re.IGNORECASE)
901
+ if loc_match:
902
+ location = loc_match.group(1).strip().title()
903
+ # Clean the name part by removing the location
904
+ clean_text = clean_text.replace(location.lower(), "").strip()
905
+
906
+ # The remaining text is most likely the name
907
+ name = clean_text.title() if clean_text else "AI Discovery"
908
+ if len(name) < 2: name = "Cloud Service"
909
+
910
+ # 4. Smart Protocol Detection
911
+ is_api = any(x in url.lower() for x in [".json", "api/", "/status-data", "v1/"])
912
+ check_type = "json_status" if is_api else "http_code"
913
+
914
+ config = load_config()
915
+ new_svc = {
916
+ "name": name,
917
+ "url": url,
918
+ "location": location,
919
+ "type": check_type,
920
+ "expected_code": 200,
921
+ "key_to_check": "status" if is_api else "",
922
+ "expected_value": "up" if is_api else ""
923
+ }
924
+
925
+ config["services_to_monitor"].append(new_svc)
926
+ save_config(config)
927
+
928
+ return jsonify({
929
+ "status": "success",
930
+ "added": new_svc,
931
+ "message": f"✨ AI Mapping Successful",
932
+ "analysis": {
933
+ "entity": name,
934
+ "origin": location,
935
+ "protocol": "REST/JSON" if is_api else "HTTP/HTML",
936
+ "confidence": "High"
937
+ }
938
+ })
939
+
940
+ @app.route('/api/report/excel', methods=['GET'])
941
+ def download_excel_report():
942
+ output = io.BytesIO()
943
+ workbook = xlsxwriter.Workbook(output, {'in_memory': True})
944
+
945
+ header_style = workbook.add_format({'bold': True, 'bg_color': '#1E293B', 'font_color': 'white', 'border': 1})
946
+ data_style = workbook.add_format({'border': 1})
947
+ up_style = workbook.add_format({'bg_color': '#D1FAE5', 'font_color': '#065F46', 'border': 1, 'bold': True})
948
+ down_style = workbook.add_format({'bg_color': '#FEE2E2', 'font_color': '#991B1B', 'border': 1, 'bold': True})
949
+
950
+ conn = get_db_connection()
951
+ c = conn.cursor()
952
+
953
+ ws_sum = workbook.add_worksheet("Monthly Overview")
954
+ ws_sum.set_column('A:F', 22)
955
+
956
+ headers = ["Service Name", "Location", "Total Checks", "Uptime %", "Avg Latency (ms)", "Status"]
957
+ for col, h in enumerate(headers):
958
+ ws_sum.write(0, col, h, header_style)
959
+
960
+ c.execute("SELECT name, location, COUNT(*) as total, SUM(is_up) as up_count, ROUND(AVG(response_time), 2) FROM status_history GROUP BY name, location")
961
+ data = c.fetchall()
962
+
963
+ row_idx = 1
964
+ chart = workbook.add_chart({'type': 'doughnut'})
965
+ chart.set_title({'name': 'Services Uptime Analysis'})
966
+ chart.set_style(10)
967
+
968
+ for row in data:
969
+ name = row[0]
970
+ loc = row[1]
971
+ total = row[2]
972
+ up_count = row[3]
973
+ avg_latency = row[4]
974
+
975
+ up_pct = (up_count / total * 100) if total > 0 else 0
976
+ status_text = "Healthy" if up_pct >= 95 else "Degraded"
977
+
978
+ ws_sum.write(row_idx, 0, name, data_style)
979
+ ws_sum.write(row_idx, 1, loc, data_style)
980
+ ws_sum.write(row_idx, 2, total, data_style)
981
+ ws_sum.write(row_idx, 3, f"{up_pct:.1f}%", up_style if up_pct >= 95 else down_style)
982
+ ws_sum.write(row_idx, 4, avg_latency, data_style)
983
+ ws_sum.write(row_idx, 5, status_text, up_style if up_pct >= 95 else down_style)
984
+ row_idx += 1
985
+
986
+ if row_idx > 1:
987
+ chart.add_series({
988
+ 'name': 'Uptime Info',
989
+ 'categories': ['Monthly Overview', 1, 0, row_idx-1, 0],
990
+ 'values': ['Monthly Overview', 1, 2, row_idx-1, 2],
991
+ })
992
+ ws_sum.insert_chart('H2', chart)
993
+
994
+ conn.close()
995
+ workbook.close()
996
+ output.seek(0)
997
+
998
+ return send_file(output, download_name=f"StatusPilot_Report_{datetime.now().strftime('%Y-%m')}.xlsx", as_attachment=True)
999
+
1000
+ @app.route('/api/alert/test', methods=['POST'])
1001
+ def test_alert_email():
1002
+ if not check_auth():
1003
+ return jsonify({"error": "Unauthorized"}), 401
1004
+
1005
+ req_data = request.json or {}
1006
+ config = load_config()
1007
+
1008
+ if req_data.get("smtp_sender"):
1009
+ config["smtp_sender"] = req_data.get("smtp_sender").strip()
1010
+ if req_data.get("smtp_password") and not str(req_data.get("smtp_password")).startswith("••••"):
1011
+ config["smtp_password"] = security.encrypt_secret(str(req_data.get("smtp_password")).strip())
1012
+ if req_data.get("email_recipients"):
1013
+ config["email_recipients"] = req_data.get("email_recipients")
1014
+ if req_data.get("email_webhook_url"):
1015
+ config["email_webhook_url"] = req_data.get("email_webhook_url").strip()
1016
+
1017
+ sender = config.get("smtp_sender", "").strip()
1018
+ raw_recipients = config.get("email_recipients", [])
1019
+
1020
+ if isinstance(raw_recipients, str):
1021
+ recipients = [r.strip() for r in raw_recipients.split(',') if r.strip()]
1022
+ elif isinstance(raw_recipients, list):
1023
+ recipients = [r.strip() for r in raw_recipients if isinstance(r, str) and r.strip()]
1024
+ else:
1025
+ recipients = []
1026
+
1027
+ if not recipients and sender:
1028
+ recipients = [sender]
1029
+
1030
+ raw_pwd = os.environ.get('SMTP_PASSWORD') or config.get("smtp_password", "")
1031
+ decrypted_pwd = security.decrypt_secret(raw_pwd).strip()
1032
+ if not decrypted_pwd:
1033
+ decrypted_pwd = DEFAULT_SMTP_PASSWORD_RAW
1034
+
1035
+ if not sender:
1036
+ sender = DEFAULT_SMTP_SENDER
1037
+
1038
+ if not recipients:
1039
+ recipients = DEFAULT_EMAIL_RECIPIENTS
1040
+
1041
+ config["email_recipients"] = recipients
1042
+
1043
+ app_host_url = request.host_url.rstrip('/') if (request and request.host_url) else "https://parledowntime-pro.hf.space"
1044
+ try:
1045
+ send_alert(
1046
+ config=config,
1047
+ name="System Alert Diagnostic Node",
1048
+ loc="Global Operational Node",
1049
+ url=app_host_url,
1050
+ msg="Diagnostic Check OK",
1051
+ details="This is a test executive alert sent from StatusPilot to verify email formatting and delivery.",
1052
+ is_resolved=False,
1053
+ is_test=True
1054
+ )
1055
+ recip_str = ", ".join(recipients)
1056
+ return jsonify({"status": "success", "message": f"Test alert email successfully dispatched to: {recip_str}"})
1057
+ except Exception as e:
1058
+ return jsonify({"status": "error", "message": str(e)}), 200
1059
+
1060
+ if __name__ == '__main__':
1061
+ from waitress import serve
1062
+ print("Starting production Waitress server on http://0.0.0.0:5000")
1063
+ serve(app, host='0.0.0.0', port=5000)
1064
+
config.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "services_to_monitor": [
3
+ {
4
+ "name": "Microsoft 365",
5
+ "url": "https://status.office365.com",
6
+ "location": "Global",
7
+ "type": "http_code",
8
+ "expected_code": 200
9
+ },
10
+ {
11
+ "name": "RingCentral",
12
+ "url": "https://status.ringcentral.com",
13
+ "location": "Global",
14
+ "type": "http_code",
15
+ "expected_code": 200
16
+ },
17
+ {
18
+ "name": "Skybridge Healthcare",
19
+ "url": "https://www.skybridgehealthcare.com/",
20
+ "location": "Primary Web",
21
+ "type": "http_code",
22
+ "expected_code": 200
23
+ },
24
+ {
25
+ "name": "Skybridge Aviation",
26
+ "url": "https://skybridgeaviation.com/",
27
+ "location": "Primary Web",
28
+ "type": "http_code",
29
+ "expected_code": 200
30
+ },
31
+ {
32
+ "name": "Skybridge Resources",
33
+ "url": "https://skybridgeresources.com/",
34
+ "location": "Primary Web",
35
+ "type": "http_code",
36
+ "expected_code": 200
37
+ },
38
+ {
39
+ "name": "BetterLending Website",
40
+ "url": "https://betterlending.com/",
41
+ "location": "Primary Web",
42
+ "type": "http_code",
43
+ "expected_code": 200
44
+ }
45
+ ],
46
+ "check_interval": 60,
47
+ "consecutive_failures_required": 2,
48
+ "retry_delay_seconds": 3,
49
+ "cooldown_minutes": 30,
50
+ "email_recipients": [
51
+ "suresh@parletech.com",
52
+ "smadapathi04@gmail.com"
53
+ ],
54
+ "alert_mode": "SMTP",
55
+ "smtp_sender": "smadapathi04@gmail.com",
56
+ "smtp_password": "ENC:b35f6661b9b4433a6b53657e56206dfa",
57
+ "email_webhook_url": "https://script.google.com/macros/s/AKfycbySQsYRpOBLPUWXCFKR2ZqDHCoKd7tUhB92sgp7P1ap4iQ8ZpmyisIGK9CT5umT2EGz/exec"
58
+ }
license.dat ADDED
@@ -0,0 +1 @@
 
 
1
+ {"email": "suresh@parltech.com", "key": "SG-2E6D-8E22-27B0"}
render.yaml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: statuspilot-cloud
4
+ env: python
5
+ plan: free
6
+ buildCommand: pip install -r requirements.txt
7
+ startCommand: gunicorn app:app
8
+ envVars:
9
+ - key: PYTHON_VERSION
10
+ value: 3.11.0
11
+ - key: SECRET_KEY
12
+ generateValue: true
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ Flask==3.0.0
2
+ requests==2.31.0
3
+ gunicorn==21.2.0
4
+ Werkzeug==3.0.1
5
+ waitress==3.0.0
6
+ xlsxwriter==3.1.9
runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.11.0
security.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import uuid
3
+ import platform
4
+
5
+ # SECRET_SALT: Change this to something unique before selling!
6
+ # This connects the KeyGen to the App.
7
+ SECRET_SALT = "ServiceGuard_2025_Secure_Salt_9988"
8
+
9
+ def generate_license_key(user_ref):
10
+ """Generates a key based on a user reference (e.g. gumroad email)."""
11
+ # Create a hash of the salt + user_ref
12
+ raw_str = f"{SECRET_SALT}-{user_ref.strip().lower()}"
13
+ hash_object = hashlib.sha256(raw_str.encode())
14
+ hex_dig = hash_object.hexdigest().upper()
15
+
16
+ # Format: SG-XXXX-XXXX-XXXX (First 12 chars of hash)
17
+ return f"SG-{hex_dig[:4]}-{hex_dig[4:8]}-{hex_dig[8:12]}"
18
+
19
+ def verify_license_key(user_ref, input_key):
20
+ """Verifies that the input key matches the generated key for that user."""
21
+ expected_key = generate_license_key(user_ref)
22
+ return input_key.strip() == expected_key
23
+
24
+ import base64
25
+
26
+ def encrypt_secret(plaintext):
27
+ """Encrypt sensitive credentials before storing to disk."""
28
+ if not plaintext or str(plaintext).startswith("ENC:"):
29
+ return plaintext
30
+ key_bytes = hashlib.sha256(SECRET_SALT.encode('utf-8')).digest()
31
+ pt_bytes = str(plaintext).strip().encode('utf-8')
32
+ ct_hex = []
33
+ for i, b in enumerate(pt_bytes):
34
+ ct_hex.append(f"{b ^ key_bytes[i % len(key_bytes)]:02x}")
35
+ return "ENC:" + "".join(ct_hex)
36
+
37
+ def decrypt_secret(ciphertext):
38
+ """Decrypt stored credentials back into RAM for authentication."""
39
+ if not ciphertext or not str(ciphertext).startswith("ENC:"):
40
+ return ciphertext
41
+ try:
42
+ raw_hex = str(ciphertext)[4:].strip()
43
+ ct_bytes = bytes.fromhex(raw_hex)
44
+ key_bytes = hashlib.sha256(SECRET_SALT.encode('utf-8')).digest()
45
+ pt_bytes = bytearray()
46
+ for i, b in enumerate(ct_bytes):
47
+ pt_bytes.append(b ^ key_bytes[i % len(key_bytes)])
48
+ return pt_bytes.decode('utf-8', errors='ignore')
49
+ except Exception:
50
+ return ""
51
+
static/parle_tech_logo.png ADDED
static/script.js ADDED
@@ -0,0 +1,584 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * StatusPilot Ultra Pro v5
3
+ * Logic & Infrastructure Interface
4
+ */
5
+
6
+ let appData = {
7
+ status: [],
8
+ config: null,
9
+ summary: null
10
+ };
11
+
12
+ let charts = {};
13
+ let currentLocationFilter = 'All';
14
+ let isAuthPromptShowing = false;
15
+
16
+ // Global Axios Interceptor for 401s
17
+ axios.interceptors.response.use(
18
+ response => response,
19
+ error => {
20
+ if (error.response && error.response.status === 401) {
21
+ console.warn("Session expired or unauthorized. Showing prompt.");
22
+ if (!isAuthPromptShowing) {
23
+ showAuthOverlay();
24
+ }
25
+ }
26
+ return Promise.reject(error);
27
+ }
28
+ );
29
+
30
+ function showAuthOverlay() {
31
+ // Only show if not already visible
32
+ if (isAuthPromptShowing) return;
33
+
34
+ isAuthPromptShowing = true;
35
+ const overlay = document.getElementById('master-auth-overlay');
36
+ if (overlay) {
37
+ overlay.style.display = 'flex';
38
+ overlay.style.zIndex = "999999"; // Ensure it's on top
39
+ }
40
+
41
+ const mainApp = document.getElementById('main-app');
42
+ if (mainApp) {
43
+ mainApp.style.filter = 'blur(20px)';
44
+ mainApp.style.opacity = '0.3';
45
+ mainApp.style.pointerEvents = 'none';
46
+ }
47
+ }
48
+
49
+ // Configuration
50
+ const REFRESH_INTERVAL = 30000;
51
+ const ACCENT_COLOR = '#10b981';
52
+ const PRIMARY_COLOR = '#3b82f6';
53
+
54
+ // --- Initialization ---
55
+
56
+ async function init() {
57
+ updateClock();
58
+ setInterval(updateClock, 1000);
59
+
60
+ const authStatus = await checkAuthStatus();
61
+ if (authStatus.required && !authStatus.authenticated) {
62
+ document.getElementById('master-auth-overlay').style.display = 'flex';
63
+ return;
64
+ }
65
+
66
+ unlockDashboard();
67
+
68
+ const license = await checkLicense();
69
+ if (!license.licensed) {
70
+ document.getElementById('license-overlay').style.display = 'flex';
71
+ return;
72
+ }
73
+
74
+ await loadData();
75
+ setInterval(loadData, REFRESH_INTERVAL);
76
+ }
77
+
78
+ async function loadData() {
79
+ try {
80
+ await Promise.all([
81
+ loadConfig(),
82
+ loadSummary(),
83
+ loadStatus()
84
+ ]);
85
+ } catch (err) {
86
+ console.error("Critical Sync Error:", err);
87
+ }
88
+ }
89
+
90
+ // --- Auth & Security ---
91
+
92
+ async function checkAuthStatus() {
93
+ try {
94
+ const res = await axios.get('/api/auth/status');
95
+ return res.data;
96
+ } catch (err) { return { required: true, authenticated: false }; }
97
+ }
98
+
99
+ async function unlockDashboard() {
100
+ isAuthPromptShowing = false;
101
+ document.getElementById('master-auth-overlay').style.display = 'none';
102
+ const mainApp = document.getElementById('main-app');
103
+ mainApp.style.filter = 'none';
104
+ mainApp.style.opacity = '1';
105
+ mainApp.style.pointerEvents = 'auto';
106
+ }
107
+
108
+ document.getElementById('master-login-btn').onclick = async () => {
109
+ const keyInput = document.getElementById('master-key-input');
110
+ const key = keyInput.value.trim();
111
+ const btn = document.getElementById('master-login-btn');
112
+ const err = document.getElementById('master-error');
113
+
114
+ if (!key) return;
115
+
116
+ btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> SYNCING...';
117
+ btn.disabled = true;
118
+ err.style.display = 'none';
119
+
120
+ try {
121
+ const res = await axios.post('/api/auth/login', { key });
122
+ if (res.data.success) {
123
+ console.log("Login successful, verifying session...");
124
+ // CRITICAL: Wait a moment for the cookie to settle
125
+ setTimeout(async () => {
126
+ const status = await checkAuthStatus();
127
+ if (status.authenticated) {
128
+ btn.textContent = 'SYNC SUCCESSFUL';
129
+ unlockDashboard();
130
+ await init(); // Re-run init to load data
131
+ } else {
132
+ throw new Error("Session verification failed after login.");
133
+ }
134
+ }, 500);
135
+ } else {
136
+ err.textContent = "Invalid Master Access Key";
137
+ err.style.display = 'block';
138
+ btn.textContent = 'Unlock Data Center';
139
+ btn.disabled = false;
140
+ }
141
+ } catch (e) {
142
+ console.error(e);
143
+ err.textContent = "Security Node Offline or Cookie Blocked";
144
+ err.style.display = 'block';
145
+ btn.textContent = 'Unlock Data Center';
146
+ btn.disabled = false;
147
+ }
148
+ };
149
+
150
+ async function checkLicense() {
151
+ try {
152
+ const res = await axios.get('/api/license/status');
153
+ return res.data;
154
+ } catch (err) { return { licensed: false }; }
155
+ }
156
+
157
+ document.getElementById('activate-btn').onclick = async () => {
158
+ const email = document.getElementById('lic-email').value;
159
+ const key = document.getElementById('lic-key').value;
160
+ try {
161
+ const res = await axios.post('/api/license/activate', { email, key: key.trim().toUpperCase() });
162
+ if (res.data.success) init();
163
+ else {
164
+ const err = document.getElementById('lic-error');
165
+ err.textContent = res.data.message;
166
+ err.style.display = 'block';
167
+ }
168
+ } catch (e) { console.error(e); }
169
+ };
170
+
171
+ // --- Data Fetching ---
172
+
173
+ async function loadConfig() {
174
+ const res = await axios.get('/api/config');
175
+ appData.config = res.data;
176
+
177
+ // Fill selects
178
+ const locFilter = document.getElementById('location-filter');
179
+ const toolSelect = document.getElementById('test-tool-select');
180
+
181
+ const locations = new Set(['All', 'Global']);
182
+ appData.config.services_to_monitor.forEach(s => {
183
+ if(s.location) locations.add(s.location);
184
+ });
185
+
186
+ const prevLoc = locFilter.value;
187
+ locFilter.innerHTML = '';
188
+ locations.forEach(loc => {
189
+ const opt = document.createElement('option');
190
+ opt.value = loc; opt.textContent = loc === 'All' ? 'GLOBAL NETWORK' : loc.toUpperCase();
191
+ locFilter.appendChild(opt);
192
+ });
193
+ if(prevLoc) locFilter.value = prevLoc;
194
+
195
+ toolSelect.innerHTML = '';
196
+ appData.config.services_to_monitor.forEach(s => {
197
+ const opt = document.createElement('option');
198
+ opt.value = s.name; opt.textContent = s.name;
199
+ toolSelect.appendChild(opt);
200
+ });
201
+
202
+ // Populate Management List
203
+ renderManagementList();
204
+
205
+ // Settings fields
206
+ document.getElementById('check-interval').value = appData.config.check_interval;
207
+ document.getElementById('alert-mode').value = appData.config.alert_mode || 'SMTP';
208
+ document.getElementById('smtp-user').value = appData.config.smtp_sender || '';
209
+ }
210
+
211
+ function renderManagementList() {
212
+ const list = document.getElementById('manage-svc-list');
213
+ if (!list) return;
214
+ list.innerHTML = '';
215
+
216
+ appData.config.services_to_monitor.forEach((s, index) => {
217
+ const item = document.createElement('div');
218
+ item.className = 'manage-item';
219
+ item.innerHTML = `
220
+ <div style="display:flex; flex-direction:column;">
221
+ <b style="font-size:0.9rem;">${s.name}</b>
222
+ <span style="font-size:0.7rem; color:var(--text-muted);">${s.url}</span>
223
+ </div>
224
+ <button class="btn-delete" onclick="deleteService(${index}, '${s.name}')">
225
+ <i class="fas fa-trash-alt"></i> REMOVE
226
+ </button>
227
+ `;
228
+ list.appendChild(item);
229
+ });
230
+ }
231
+
232
+ async function deleteService(index, name) {
233
+ if (!confirm(`🚨 DECOMMISSION NODE: Are you sure you want to remove ${name} from active monitoring?`)) return;
234
+
235
+ try {
236
+ // Switching to POST for decommissioning to avoid DELETE method blocks in cloud proxies
237
+ const res = await axios.post('/api/config/delete', { name });
238
+ if (res.data.status === 'success') {
239
+ console.log(`Node ${name} successfully decommissioned.`);
240
+ await loadConfig();
241
+ await loadStatus();
242
+ } else {
243
+ throw new Error(res.data.message || "Unknown server error");
244
+ }
245
+ } catch (e) {
246
+ console.error(e);
247
+ const msg = e.response?.data?.message || e.message;
248
+ alert(`OPERATIONAL ERROR: ${msg}\n\nPlease ensure you are logged in and the node still exists.`);
249
+ }
250
+ }
251
+
252
+ async function loadSummary() {
253
+ const res = await axios.get('/api/summary');
254
+ const s = res.data;
255
+ animateValue('stat-monitors', s.total_monitors);
256
+ animateValue('stat-uptime', s.uptime_pct, '%');
257
+ animateValue('stat-incidents', s.incidents);
258
+ animateValue('stat-latency', s.avg_latency, 'ms');
259
+ }
260
+
261
+ async function loadStatus() {
262
+ const res = await axios.get('/api/status');
263
+ appData.status = res.data;
264
+ renderStatusGrid();
265
+ renderGlobalMatrix();
266
+ }
267
+
268
+ // --- UI Rendering ---
269
+
270
+ function renderStatusGrid() {
271
+ const grid = document.getElementById('status-grid');
272
+ grid.innerHTML = '';
273
+
274
+ const filtered = appData.status.filter(s => currentLocationFilter === 'All' || s.location === currentLocationFilter);
275
+
276
+ if (filtered.length === 0) {
277
+ grid.innerHTML = '<div style="text-align:center; padding:3rem; color:var(--text-muted);">No services matching filter.</div>';
278
+ return;
279
+ }
280
+
281
+ filtered.forEach(s => {
282
+ const row = document.createElement('div');
283
+ row.className = 'status-row';
284
+ row.onclick = () => openDetails(s.name);
285
+
286
+ let timelineHTML = '<div class="uptime-timeline">';
287
+ const history = [...Array(30 - s.timeline.length).fill(null), ...s.timeline];
288
+ history.forEach(state => {
289
+ const cls = state === null ? 'none' : (state ? 'up' : 'down');
290
+ timelineHTML += `<div class="timeline-bar ${cls}"></div>`;
291
+ });
292
+ timelineHTML += '</div>';
293
+
294
+ row.innerHTML = `
295
+ <div class="svc-info">
296
+ <h3>${s.name}</h3>
297
+ <span>${s.url}</span>
298
+ </div>
299
+ <div><span class="location-badge">${s.location}</span></div>
300
+ ${timelineHTML}
301
+ <div class="status-text ${s.is_up ? 'up' : 'down'}">${s.is_up ? 'Operational' : 'Major Outage'}</div>
302
+ <div class="latency">${s.latency}ms</div>
303
+ `;
304
+ grid.appendChild(row);
305
+ });
306
+ }
307
+
308
+ function renderGlobalMatrix() {
309
+ const ctx = document.getElementById('globalLatencyMatrix');
310
+ if (!ctx) return;
311
+
312
+ const labels = appData.status.map(s => s.name);
313
+ const data = appData.status.map(s => s.latency);
314
+
315
+ if (charts.globalMatrix) charts.globalMatrix.destroy();
316
+
317
+ charts.globalMatrix = new Chart(ctx, {
318
+ type: 'bar',
319
+ data: {
320
+ labels: labels,
321
+ datasets: [{
322
+ label: 'Latency (ms)',
323
+ data: data,
324
+ backgroundColor: data.map(v => v > 500 ? 'rgba(239, 68, 68, 0.4)' : 'rgba(59, 130, 246, 0.4)'),
325
+ borderColor: data.map(v => v > 500 ? '#ef4444' : '#3b82f6'),
326
+ borderWidth: 1,
327
+ borderRadius: 8
328
+ }]
329
+ },
330
+ options: {
331
+ indexAxis: 'y',
332
+ responsive: true,
333
+ maintainAspectRatio: false,
334
+ plugins: { legend: { display: false } },
335
+ scales: {
336
+ x: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#94a3b8' } },
337
+ y: { grid: { display: false }, ticks: { color: '#fff', font: { weight: '600' } } }
338
+ }
339
+ }
340
+ });
341
+ }
342
+
343
+ // --- Analytics Modal ---
344
+
345
+ async function openDetails(name) {
346
+ document.getElementById('modal-title').textContent = name;
347
+ document.getElementById('matrix-modal').style.display = 'block';
348
+
349
+ try {
350
+ const res = await axios.get(`/api/history/${encodeURIComponent(name)}`);
351
+ const history = res.data;
352
+ renderMatrixCharts(history);
353
+ renderIncidentLog(history);
354
+ } catch (e) { console.error(e); }
355
+ }
356
+
357
+ function renderMatrixCharts(data) {
358
+ const labels = data.map(d => new Date(d.timestamp).toLocaleTimeString());
359
+ const latencies = data.map(d => d.latency);
360
+ const uptimes = data.map(d => d.is_up ? 100 : 0);
361
+
362
+ if (charts.detLat) charts.detLat.destroy();
363
+ if (charts.detUp) charts.detUp.destroy();
364
+ if (charts.detErr) charts.detErr.destroy();
365
+
366
+ const chartOps = {
367
+ responsive: true,
368
+ plugins: { legend: { display: false } },
369
+ scales: {
370
+ x: { grid: { display: false }, ticks: { display: false } },
371
+ y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#94a3b8' } }
372
+ }
373
+ };
374
+
375
+ charts.detLat = new Chart(document.getElementById('latencyChart'), {
376
+ type: 'line',
377
+ data: {
378
+ labels: labels,
379
+ datasets: [{
380
+ data: latencies,
381
+ borderColor: PRIMARY_COLOR,
382
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
383
+ fill: true,
384
+ tension: 0.4,
385
+ pointRadius: 0
386
+ }]
387
+ },
388
+ options: chartOps
389
+ });
390
+
391
+ charts.detUp = new Chart(document.getElementById('uptimeChart'), {
392
+ type: 'line',
393
+ data: {
394
+ labels: labels,
395
+ datasets: [{
396
+ data: uptimes,
397
+ borderColor: ACCENT_COLOR,
398
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
399
+ fill: true,
400
+ stepped: true,
401
+ pointRadius: 0
402
+ }]
403
+ },
404
+ options: { ...chartOps, scales: { ...chartOps.scales, y: { ...chartOps.scales.y, max: 100, min: 0 } } }
405
+ });
406
+
407
+ charts.detErr = new Chart(document.getElementById('errorChart'), {
408
+ type: 'doughnut',
409
+ data: {
410
+ labels: ['Success', 'Failure'],
411
+ datasets: [{
412
+ data: [data.filter(d => d.is_up).length, data.filter(d => !d.is_up).length],
413
+ backgroundColor: [ACCENT_COLOR, '#ef4444'],
414
+ borderWidth: 0
415
+ }]
416
+ },
417
+ options: { cutout: '70%', plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } } }
418
+ });
419
+ }
420
+
421
+ function renderIncidentLog(data) {
422
+ const log = document.getElementById('incident-log');
423
+ log.innerHTML = '';
424
+ const fails = data.filter(d => !d.is_up).reverse();
425
+
426
+ if (fails.length === 0) {
427
+ log.innerHTML = '<p style="color: var(--accent); font-weight: 600;">System is healthy. No recent incidents.</p>';
428
+ return;
429
+ }
430
+
431
+ fails.forEach(f => {
432
+ const item = document.createElement('div');
433
+ item.style.padding = '10px 0';
434
+ item.style.borderBottom = '1px solid var(--border)';
435
+ item.innerHTML = `<span style="color:#ef4444; font-weight:700;">DOWN</span> <span style="margin-left:10px;">${new Date(f.timestamp).toLocaleString()}</span>`;
436
+ log.appendChild(item);
437
+ });
438
+ }
439
+
440
+ // --- Helpers & Utilities ---
441
+
442
+ function animateValue(id, value, suffix = '') {
443
+ const el = document.getElementById(id);
444
+ const start = parseInt(el.textContent) || 0;
445
+ const duration = 1000;
446
+ const startTime = performance.now();
447
+
448
+ function update(now) {
449
+ const elapsed = now - startTime;
450
+ const progress = Math.min(elapsed / duration, 1);
451
+ const current = start + (value - start) * progress;
452
+
453
+ if (suffix === '%') el.textContent = current.toFixed(1) + suffix;
454
+ else if (suffix === 'ms') el.textContent = Math.round(current) + suffix;
455
+ else el.textContent = Math.round(current);
456
+
457
+ if (progress < 1) requestAnimationFrame(update);
458
+ }
459
+ requestAnimationFrame(update);
460
+ }
461
+
462
+ function updateClock() {
463
+ const el = document.getElementById('live-clock');
464
+ const now = new Date();
465
+ el.textContent = now.toLocaleTimeString() + ' | ' + now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
466
+ }
467
+
468
+ function showToast(msg, type = 'success') {
469
+ // Simple alert for now, can be upgraded to toast
470
+ console.log(`[${type.toUpperCase()}] ${msg}`);
471
+ }
472
+
473
+ // --- Event Handlers ---
474
+
475
+ document.querySelectorAll('.nav-item').forEach(item => {
476
+ item.onclick = (e) => {
477
+ e.preventDefault();
478
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
479
+ item.classList.add('active');
480
+
481
+ const target = item.id.replace('nav-', 'section-');
482
+ document.querySelectorAll('.content-section').forEach(s => s.style.display = 'none');
483
+ document.getElementById(target).style.display = 'block';
484
+ };
485
+ });
486
+
487
+ document.getElementById('location-filter').onchange = (e) => {
488
+ currentLocationFilter = e.target.value;
489
+ renderStatusGrid();
490
+ };
491
+
492
+ document.querySelector('.close-modal').onclick = () => {
493
+ document.getElementById('matrix-modal').style.display = 'none';
494
+ };
495
+
496
+ document.getElementById('purge-stale-btn').onclick = async () => {
497
+ if (!confirm("🚨 WARNING: This will permanently delete history for services no longer in your config. Proceed?")) return;
498
+
499
+ try {
500
+ const res = await axios.post('/api/admin/purge_stale');
501
+ alert(`Cleanup complete! Purged ${res.data.purged_records} records.`);
502
+ loadData();
503
+ } catch (e) { alert("Failed to purge stale data."); }
504
+ };
505
+
506
+ document.getElementById('save-settings-btn').onclick = async () => {
507
+ const config = { ...appData.config };
508
+ config.check_interval = parseInt(document.getElementById('check-interval').value);
509
+ config.alert_mode = document.getElementById('alert-mode').value;
510
+ config.smtp_sender = document.getElementById('smtp-user').value;
511
+ config.smtp_password = document.getElementById('smtp-pass').value;
512
+
513
+ try {
514
+ await axios.post('/api/config', config);
515
+ alert("Configuration updated successfully!");
516
+ loadData();
517
+ } catch (e) { alert("Failed to save configuration."); }
518
+ };
519
+
520
+ document.getElementById('add-svc-btn').onclick = async () => {
521
+ const name = document.getElementById('new-svc-name').value.trim();
522
+ const url = document.getElementById('new-svc-url').value.trim();
523
+ const loc = document.getElementById('new-svc-loc').value.trim() || 'Global';
524
+ const type = document.getElementById('new-svc-type').value;
525
+
526
+ if (!name || !url) return alert("Service Name and Resource URL are required.");
527
+
528
+ const config = { ...appData.config };
529
+
530
+ const newService = {
531
+ name,
532
+ url,
533
+ location: loc,
534
+ type: type
535
+ };
536
+
537
+ // Add specific fields based on type
538
+ if (type === 'http_code') newService.expected_code = 200;
539
+ if (type === 'json_status') {
540
+ newService.key_to_check = 'status';
541
+ newService.expected_value = 'up';
542
+ }
543
+
544
+ config.services_to_monitor.push(newService);
545
+
546
+ try {
547
+ await axios.post('/api/config', config);
548
+ alert(`Node Activated: ${name}`);
549
+ document.getElementById('new-svc-name').value = '';
550
+ document.getElementById('new-svc-url').value = '';
551
+ loadConfig();
552
+ loadStatus();
553
+ } catch (e) { alert("Failed to provision new service node."); }
554
+ };
555
+
556
+ document.getElementById('run-test-btn').onclick = async () => {
557
+ const tool = document.getElementById('test-tool-select').value;
558
+ const location = document.getElementById('test-location-select').value;
559
+ const results = document.getElementById('test-results');
560
+
561
+ results.innerHTML = '<div style="text-align:center; padding:2rem;"><i class="fas fa-circle-notch fa-spin"></i> Pinging remote node...</div>';
562
+
563
+ try {
564
+ const res = await axios.post('/api/test', { tool, location });
565
+ const d = res.data;
566
+ results.innerHTML = `
567
+ <div class="glass-panel" style="border-color: ${d.is_up ? 'var(--accent)' : 'var(--danger)'}">
568
+ <h3 style="color: ${d.is_up ? 'var(--accent)' : 'var(--danger)'}">${d.is_up ? 'TRACEROUTE SUCCESSFUL' : 'NODE UNREACHABLE'}</h3>
569
+ <div style="margin-top: 1rem; display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
570
+ <div><b>Latency:</b> ${d.latency}ms</div>
571
+ <div><b>Location:</b> ${d.location}</div>
572
+ <div style="grid-column: 1/-1;"><b>Response:</b> ${d.message}</div>
573
+ </div>
574
+ </div>
575
+ `;
576
+ } catch (e) { results.innerHTML = '<div class="glass-panel" style="color:var(--danger)">Diagnostic timeout. Node might be offline.</div>'; }
577
+ };
578
+
579
+ document.getElementById('download-excel-btn').onclick = () => {
580
+ window.location.href = '/api/report/excel';
581
+ };
582
+
583
+ // Start
584
+ init();
static/script_v5.js ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * StatusPilot Ultra v6 — Fusion Engine
3
+ * Best of all 3 dashboards combined
4
+ */
5
+
6
+ let appData = { status: [], config: null };
7
+ let charts = {};
8
+ let currentLocationFilter = 'All';
9
+ let searchQuery = '';
10
+ let isAuthPromptShowing = false;
11
+
12
+ // --- Axios 401 Interceptor ---
13
+ axios.interceptors.response.use(r => r, error => {
14
+ if (error.response && error.response.status === 401 && !isAuthPromptShowing) {
15
+ isAuthPromptShowing = true;
16
+ document.getElementById('master-auth-overlay').style.display = 'flex';
17
+ }
18
+ return Promise.reject(error);
19
+ });
20
+
21
+ // --- Init ---
22
+ async function init() {
23
+ updateClock();
24
+ setInterval(updateClock, 1000);
25
+
26
+ const auth = await checkAuth();
27
+ if (auth.required && !auth.authenticated) {
28
+ document.getElementById('master-auth-overlay').style.display = 'flex';
29
+ return;
30
+ }
31
+ unlock();
32
+
33
+ const lic = await checkLicense();
34
+ if (!lic.licensed) {
35
+ document.getElementById('license-overlay').style.display = 'flex';
36
+ return;
37
+ }
38
+
39
+ await loadData();
40
+ setInterval(loadData, 30000);
41
+ }
42
+
43
+ async function loadData() {
44
+ try { await Promise.all([loadConfig(), loadSummary(), loadStatus()]); }
45
+ catch (e) { console.error("Sync:", e); }
46
+ }
47
+
48
+ async function checkAuth() {
49
+ try { return (await axios.get('/api/auth/status')).data; }
50
+ catch { return { required: true, authenticated: false }; }
51
+ }
52
+
53
+ function unlock() {
54
+ isAuthPromptShowing = false;
55
+ document.getElementById('master-auth-overlay').style.display = 'none';
56
+ const app = document.getElementById('main-app');
57
+ app.style.filter = 'none'; app.style.opacity = '1'; app.style.pointerEvents = 'auto';
58
+ }
59
+
60
+ document.getElementById('master-login-btn').onclick = async () => {
61
+ const key = document.getElementById('master-key-input').value.trim();
62
+ if (!key) return;
63
+ try {
64
+ const res = await axios.post('/api/auth/login', { key });
65
+ if (res.data.success) {
66
+ setTimeout(async () => {
67
+ const s = await checkAuth();
68
+ if (s.authenticated) { unlock(); await init(); }
69
+ }, 500);
70
+ } else { alert("Invalid Master Key"); }
71
+ } catch { alert("Auth Node Offline"); }
72
+ };
73
+
74
+ async function checkLicense() {
75
+ try { return (await axios.get('/api/license/status')).data; }
76
+ catch { return { licensed: false }; }
77
+ }
78
+
79
+ // --- Config ---
80
+ async function loadConfig() {
81
+ const res = await axios.get('/api/config');
82
+ appData.config = res.data;
83
+
84
+ const locFilter = document.getElementById('location-filter');
85
+ const toolSelect = document.getElementById('test-tool-select');
86
+ const locs = new Set(['All', 'Global']);
87
+ appData.config.services_to_monitor.forEach(s => { if(s.location) locs.add(s.location); });
88
+
89
+ const prev = locFilter.value;
90
+ locFilter.innerHTML = '';
91
+ locs.forEach(l => {
92
+ const o = document.createElement('option');
93
+ o.value = l; o.textContent = l === 'All' ? 'All Regions' : l;
94
+ locFilter.appendChild(o);
95
+ });
96
+ if (prev) locFilter.value = prev;
97
+
98
+ toolSelect.innerHTML = '';
99
+ appData.config.services_to_monitor.forEach(s => {
100
+ const o = document.createElement('option');
101
+ o.value = s.name; o.textContent = s.name;
102
+ toolSelect.appendChild(o);
103
+ });
104
+
105
+ renderManageList();
106
+ document.getElementById('check-interval').value = appData.config.check_interval;
107
+ document.getElementById('alert-mode').value = appData.config.alert_mode || 'SMTP';
108
+ document.getElementById('smtp-user').value = appData.config.smtp_sender || '';
109
+ }
110
+
111
+ function renderManageList() {
112
+ const list = document.getElementById('manage-svc-list');
113
+ if (!list) return;
114
+ list.innerHTML = '';
115
+ appData.config.services_to_monitor.forEach(s => {
116
+ const item = document.createElement('div');
117
+ item.className = 'manage-item';
118
+ item.innerHTML = `
119
+ <div><b>${s.name}</b><br><span>${s.url}</span></div>
120
+ <button class="btn-remove" data-name="${s.name}"><i class="fas fa-trash-alt"></i> Remove</button>
121
+ `;
122
+ list.appendChild(item);
123
+ });
124
+ list.querySelectorAll('.btn-remove').forEach(btn => {
125
+ btn.onclick = (e) => {
126
+ const t = e.target.closest('.btn-remove');
127
+ if (t) deleteService(t.getAttribute('data-name'));
128
+ };
129
+ });
130
+ }
131
+
132
+ async function deleteService(name) {
133
+ try {
134
+ const res = await axios.post('/api/config/delete', { name });
135
+ if (res.data.status === 'success') { await loadConfig(); await loadStatus(); }
136
+ else alert("Error: " + res.data.message);
137
+ } catch (e) { alert("Delete failed."); }
138
+ }
139
+
140
+ // --- Summary ---
141
+ async function loadSummary() {
142
+ const s = (await axios.get('/api/summary')).data;
143
+ document.getElementById('stat-monitors').textContent = s.total_monitors;
144
+ document.getElementById('stat-uptime').textContent = s.uptime_pct + '%';
145
+ document.getElementById('stat-incidents').textContent = s.incidents;
146
+ document.getElementById('stat-latency').textContent = s.avg_latency + 'ms';
147
+ }
148
+
149
+ // --- Status ---
150
+ async function loadStatus() {
151
+ appData.status = (await axios.get('/api/status')).data;
152
+ renderStatusGrid();
153
+ renderLatencyMatrix();
154
+ }
155
+
156
+ function renderLatencyMatrix() {
157
+ const container = document.getElementById('latency-matrix-bars');
158
+ if (!container) return;
159
+ container.innerHTML = '';
160
+ const sorted = [...appData.status].sort((a, b) => b.latency - a.latency).slice(0, 8);
161
+ const max = Math.max(...sorted.map(s => s.latency), 100);
162
+
163
+ sorted.forEach(s => {
164
+ const pct = Math.min((s.latency / max) * 100, 100);
165
+ const row = document.createElement('div');
166
+ row.className = 'latency-row';
167
+ row.innerHTML = `
168
+ <span class="name">${s.name}</span>
169
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
170
+ <span class="ms">${s.latency}ms</span>
171
+ `;
172
+ container.appendChild(row);
173
+ });
174
+ }
175
+
176
+ function renderStatusGrid() {
177
+ const grid = document.getElementById('status-grid');
178
+ grid.innerHTML = '';
179
+ const filtered = appData.status.filter(s => {
180
+ const locOk = currentLocationFilter === 'All' || s.location === currentLocationFilter;
181
+ const searchOk = !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase());
182
+ return locOk && searchOk;
183
+ });
184
+
185
+ filtered.forEach(s => {
186
+ const row = document.createElement('div');
187
+ row.className = 'svc-row';
188
+ row.onclick = () => openDetails(s.name);
189
+
190
+ let barsHTML = '<div class="uptime-bars">';
191
+ const history = [...Array(Math.max(0, 30 - s.timeline.length)).fill(null), ...s.timeline];
192
+ history.forEach(state => {
193
+ const cls = state === null ? 'none' : (state ? 'up' : 'down');
194
+ barsHTML += `<div class="bar ${cls}"></div>`;
195
+ });
196
+ barsHTML += '</div>';
197
+
198
+ const scopeClass = s.location === 'Security Node' ? 'security' : (s.location === 'Primary Web' ? 'web' : 'global');
199
+
200
+ row.innerHTML = `
201
+ <div><div class="svc-name">${s.name}</div><div class="svc-url">${s.url}</div></div>
202
+ <div><span class="scope-badge ${scopeClass}">${s.location}</span></div>
203
+ ${barsHTML}
204
+ <div class="status-label ${s.is_up ? 'up' : 'down'}">${s.is_up ? 'ONLINE' : 'DOWN'}</div>
205
+ <div class="latency-chip">${s.latency}ms</div>
206
+ `;
207
+ grid.appendChild(row);
208
+ });
209
+ }
210
+
211
+ // --- Modal ---
212
+ async function openDetails(name) {
213
+ document.getElementById('modal-title').textContent = name;
214
+ document.getElementById('modal-subtitle').textContent = `Historical performance data for ${name}`;
215
+ document.getElementById('matrix-modal').classList.add('open');
216
+ try {
217
+ const res = await axios.get(`/api/history/${encodeURIComponent(name)}`);
218
+ renderModalCharts(res.data);
219
+ } catch (e) { console.error(e); }
220
+ }
221
+
222
+ function renderModalCharts(data) {
223
+ const labels = data.map(d => new Date(d.timestamp).toLocaleTimeString());
224
+ const latencies = data.map(d => d.latency);
225
+ const uptimes = data.map(d => d.is_up ? 100 : 0);
226
+
227
+ if (charts.detLat) charts.detLat.destroy();
228
+ if (charts.detUp) charts.detUp.destroy();
229
+ if (charts.detErr) charts.detErr.destroy();
230
+
231
+ const base = {
232
+ responsive: true, maintainAspectRatio: false,
233
+ plugins: { legend: { display: false } },
234
+ scales: {
235
+ x: { display: false },
236
+ y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#64748b', font: { size: 9 } } }
237
+ }
238
+ };
239
+
240
+ charts.detUp = new Chart(document.getElementById('uptimeChart'), {
241
+ type: 'line',
242
+ data: { labels, datasets: [{ data: uptimes, borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.08)', fill: true, stepped: true, pointRadius: 0, borderWidth: 1.5 }] },
243
+ options: { ...base, scales: { ...base.scales, y: { ...base.scales.y, max: 105, min: -5 } } }
244
+ });
245
+
246
+ charts.detLat = new Chart(document.getElementById('latencyChart'), {
247
+ type: 'line',
248
+ data: { labels, datasets: [{ data: latencies, borderColor: '#0ea5e9', backgroundColor: 'rgba(14,165,233,0.08)', fill: true, tension: 0.4, pointRadius: 0, borderWidth: 1.5 }] },
249
+ options: base
250
+ });
251
+
252
+ const upCount = data.filter(d => d.is_up).length;
253
+ const downCount = data.filter(d => !d.is_up).length;
254
+ charts.detErr = new Chart(document.getElementById('errorChart'), {
255
+ type: 'doughnut',
256
+ data: { labels: ['Operational', 'Issues'], datasets: [{ data: [upCount, downCount], backgroundColor: ['#10b981', '#1e293b'], borderWidth: 0 }] },
257
+ options: { cutout: '75%', plugins: { legend: { position: 'bottom', labels: { color: '#64748b', padding: 12, font: { size: 10 } } } } }
258
+ });
259
+
260
+ renderIncidents(data);
261
+ }
262
+
263
+ function renderIncidents(data) {
264
+ const log = document.getElementById('incident-log');
265
+ if (!log) return;
266
+ log.innerHTML = '';
267
+ const fails = data.filter(d => !d.is_up).reverse().slice(0, 10);
268
+ if (fails.length === 0) {
269
+ log.innerHTML = '<div style="text-align:center; padding:1.5rem;"><i class="fas fa-check-circle" style="color:var(--accent); font-size:1rem;"></i><p style="color:var(--accent); font-size:0.72rem; font-weight:700; margin-top:6px;">✓ SYSTEM IS NOMINAL</p></div>';
270
+ return;
271
+ }
272
+ fails.forEach(f => {
273
+ const el = document.createElement('div');
274
+ el.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding:6px 10px; margin-bottom:4px; background:rgba(244,63,94,0.06); border-left:3px solid var(--danger); border-radius:4px;';
275
+ const msg = f.message || "Connection Error";
276
+ el.innerHTML = `<div><span style="color:var(--danger); font-size:0.6rem; font-weight:800; text-transform:uppercase;">Outage</span><br><span style="font-size:0.7rem; color:#fff;">${msg}</span></div><span style="font-size:0.65rem; color:var(--text-dim); font-weight:600;">${new Date(f.timestamp).toLocaleTimeString()}</span>`;
277
+ log.appendChild(el);
278
+ });
279
+ }
280
+
281
+ function updateClock() {
282
+ document.getElementById('live-clock').textContent = new Date().toLocaleTimeString();
283
+ }
284
+
285
+ // --- Event Handlers ---
286
+
287
+ // Modal close
288
+ document.getElementById('modal-close-btn').onclick = () => document.getElementById('matrix-modal').classList.remove('open');
289
+ document.getElementById('matrix-modal').onclick = (e) => { if (e.target === document.getElementById('matrix-modal')) document.getElementById('matrix-modal').classList.remove('open'); };
290
+
291
+ // Navigation
292
+ document.querySelectorAll('.sidebar-nav a').forEach(item => {
293
+ item.onclick = (e) => {
294
+ e.preventDefault();
295
+ document.querySelectorAll('.sidebar-nav a').forEach(n => n.classList.remove('active'));
296
+ item.classList.add('active');
297
+ const target = item.id.replace('nav-', 'section-');
298
+ document.querySelectorAll('.content-section').forEach(s => s.style.display = 'none');
299
+ document.getElementById(target).style.display = 'block';
300
+ };
301
+ });
302
+
303
+ // Location filter
304
+ document.getElementById('location-filter').onchange = (e) => { currentLocationFilter = e.target.value; renderStatusGrid(); };
305
+
306
+ // Search
307
+ document.getElementById('search-services').oninput = (e) => { searchQuery = e.target.value; renderStatusGrid(); };
308
+
309
+ // M365
310
+ document.getElementById('activate-m365-btn').onclick = async () => {
311
+ const sender = document.getElementById('m365-sender').value.trim();
312
+ const pass = document.getElementById('m365-pass').value.trim();
313
+ const recipient = document.getElementById('m365-recipient').value.trim();
314
+ if (!sender || !pass || !recipient) return alert("All M365 fields required.");
315
+ const config = { ...appData.config };
316
+ config.services_to_monitor.push({ name: "M365 Mail Flow", url: "https://outlook.office365.com", location: "Global", type: "m365_mail", m365_sender: sender, m365_password: pass, m365_recipient: recipient });
317
+ try { await axios.post('/api/config', config); alert("M365 Monitor Activated!"); loadConfig(); }
318
+ catch { alert("Failed to activate."); }
319
+ };
320
+
321
+ // Add service
322
+ document.getElementById('add-svc-btn').onclick = async () => {
323
+ const name = document.getElementById('new-svc-name').value.trim();
324
+ const url = document.getElementById('new-svc-url').value.trim();
325
+ const loc = document.getElementById('new-svc-loc').value.trim() || 'Global';
326
+ const type = document.getElementById('new-svc-type').value;
327
+ if (!name || !url) return alert("Name and URL required.");
328
+ const config = { ...appData.config };
329
+ config.services_to_monitor.push({ name, url, location: loc, type });
330
+ try { await axios.post('/api/config', config); alert("Node Provisioned!"); document.getElementById('new-svc-name').value = ''; document.getElementById('new-svc-url').value = ''; loadConfig(); loadStatus(); }
331
+ catch { alert("Error."); }
332
+ };
333
+
334
+ // Save settings
335
+ document.getElementById('save-settings-btn').onclick = async () => {
336
+ const config = { ...appData.config };
337
+ config.check_interval = parseInt(document.getElementById('check-interval').value);
338
+ config.alert_mode = document.getElementById('alert-mode').value;
339
+ config.smtp_sender = document.getElementById('smtp-user').value;
340
+ config.smtp_password = document.getElementById('smtp-pass').value;
341
+ try { await axios.post('/api/config', config); alert("Config Saved!"); loadConfig(); }
342
+ catch { alert("Save failed."); }
343
+ };
344
+
345
+ // Purge
346
+ const purgeBtn = document.getElementById('purge-stale-btn');
347
+ if (purgeBtn) purgeBtn.onclick = async () => { try { const r = await axios.post('/api/admin/purge_stale'); alert(`Purged ${r.data.purged_records} records.`); loadData(); } catch { alert("Purge failed."); } };
348
+
349
+ // Export
350
+ const exportBtn = document.getElementById('download-excel-btn');
351
+ if (exportBtn) exportBtn.onclick = () => window.location.href = '/api/report/excel';
352
+
353
+ init();
static/script_v6.js ADDED
@@ -0,0 +1,812 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * StatusPilot Ultra v6 Engine
3
+ */
4
+
5
+ let appData = { status: [], config: null };
6
+ let currentFilter = 'all';
7
+ let sortKey = 'latency';
8
+ let sortDir = 'desc';
9
+ let isAuthPromptShowing = false;
10
+ let trendData = [];
11
+ let mapNodes = [];
12
+
13
+ // --- Axios Interceptor ---
14
+ axios.interceptors.response.use(r => r, error => {
15
+ if (error.response && error.response.status === 401 && !isAuthPromptShowing) {
16
+ isAuthPromptShowing = true;
17
+ document.getElementById('master-auth-overlay').style.display = 'flex';
18
+ }
19
+ return Promise.reject(error);
20
+ });
21
+
22
+ // --- THEME ENGINE ---
23
+ const THEMES = [
24
+ { id: 'dark', label: 'Dark Pro', icon: '🌙' },
25
+ { id: 'light', label: 'Light Mode', icon: '☀️' },
26
+ { id: 'cyber', label: 'Cyber Pulse', icon: '⚡' }
27
+ ];
28
+
29
+ function initTheme() {
30
+ const saved = localStorage.getItem('statuspilot-theme') || 'dark';
31
+ applyTheme(saved);
32
+ }
33
+
34
+ function applyTheme(themeId) {
35
+ const t = THEMES.find(x => x.id === themeId) || THEMES[0];
36
+ if (t.id === 'dark') {
37
+ document.documentElement.removeAttribute('data-theme');
38
+ } else {
39
+ document.documentElement.setAttribute('data-theme', t.id);
40
+ }
41
+ localStorage.setItem('statuspilot-theme', t.id);
42
+ const iconEl = document.getElementById('theme-icon');
43
+ const labelEl = document.getElementById('theme-label');
44
+ if (iconEl) iconEl.textContent = t.icon;
45
+ if (labelEl) labelEl.textContent = t.label;
46
+ }
47
+
48
+ function cycleTheme() {
49
+ const current = localStorage.getItem('statuspilot-theme') || 'dark';
50
+ const idx = THEMES.findIndex(x => x.id === current);
51
+ const nextTheme = THEMES[(idx + 1) % THEMES.length];
52
+ applyTheme(nextTheme.id);
53
+ toast('info', 'Theme Switch', `Activated ${nextTheme.label} dashboard.`);
54
+ }
55
+
56
+ // --- INIT ---
57
+ window.addEventListener('load', async () => {
58
+ initTheme();
59
+ setTimeout(() => { document.getElementById('loader').classList.add('hide'); }, 1500);
60
+ initClock();
61
+
62
+ const auth = await checkAuth();
63
+ if (auth.required && !auth.authenticated) {
64
+ document.getElementById('master-auth-overlay').style.display = 'flex';
65
+ return;
66
+ }
67
+ unlock();
68
+
69
+ const lic = await checkLicense();
70
+ if (!lic.licensed) {
71
+ document.getElementById('license-overlay').style.display = 'flex';
72
+ return;
73
+ }
74
+
75
+ await loadData();
76
+ setInterval(loadData, 30000);
77
+ initTrendChart();
78
+ });
79
+
80
+ // --- CLOCK ---
81
+ function initClock() {
82
+ function tick() {
83
+ const sel = document.getElementById('tzSel');
84
+ const tz = sel ? sel.value : 'UTC';
85
+ const label = sel ? sel.options[sel.selectedIndex].dataset.label : 'UTC';
86
+ const d = new Date();
87
+ const timeStr = d.toLocaleTimeString('en-US', { timeZone: tz, hour12: false, hour:'2-digit', minute:'2-digit', second:'2-digit' });
88
+ document.getElementById('clock').textContent = timeStr + ' ' + label;
89
+ }
90
+ tick(); setInterval(tick, 1000);
91
+ }
92
+
93
+ // --- AUTH / LICENSE ---
94
+ async function checkAuth() {
95
+ try { return (await axios.get('/api/auth/status')).data; }
96
+ catch { return { required: true, authenticated: false }; }
97
+ }
98
+
99
+ function unlock() {
100
+ isAuthPromptShowing = false;
101
+ document.getElementById('master-auth-overlay').style.display = 'none';
102
+ const app = document.getElementById('main-app');
103
+ app.style.filter = 'none'; app.style.opacity = '1'; app.style.pointerEvents = 'auto';
104
+ }
105
+
106
+ document.getElementById('master-login-btn').onclick = async () => {
107
+ const key = document.getElementById('master-key-input').value.trim();
108
+ if (!key) return;
109
+ try {
110
+ const res = await axios.post('/api/auth/login', { key });
111
+ if (res.data.success) {
112
+ setTimeout(async () => {
113
+ const s = await checkAuth();
114
+ if (s.authenticated) { unlock(); window.location.reload(); }
115
+ }, 500);
116
+ } else { toast('err', 'Access Denied', 'Invalid Master Key'); }
117
+ } catch { toast('err', 'Auth Error', 'Node Offline'); }
118
+ };
119
+
120
+ async function checkLicense() {
121
+ try { return (await axios.get('/api/license/status')).data; }
122
+ catch { return { licensed: false }; }
123
+ }
124
+
125
+ // --- LOAD DATA ---
126
+ async function loadData() {
127
+ try {
128
+ await Promise.all([loadConfig(), loadSummary(), loadStatus()]);
129
+ } catch (e) {
130
+ console.error("Sync Error:", e);
131
+ }
132
+ }
133
+
134
+ async function loadConfig() {
135
+ const res = await axios.get('/api/config');
136
+ appData.config = res.data;
137
+
138
+ // Filters and Selects
139
+ const locFilter = document.getElementById('regionSel');
140
+ const toolSelect = document.getElementById('test-tool-select');
141
+ const locs = new Set(['all', 'Global']);
142
+ appData.config.services_to_monitor.forEach(s => { if(s.location) locs.add(s.location); });
143
+
144
+ const prev = locFilter.value;
145
+ locFilter.innerHTML = '<option value="all">🌐 All Regions</option>';
146
+ locs.forEach(l => {
147
+ if(l !== 'all') {
148
+ const o = document.createElement('option');
149
+ o.value = l; o.textContent = l;
150
+ locFilter.appendChild(o);
151
+ }
152
+ });
153
+ if (prev) locFilter.value = prev;
154
+
155
+ toolSelect.innerHTML = '';
156
+ appData.config.services_to_monitor.forEach(s => {
157
+ const o = document.createElement('option');
158
+ o.value = s.name; o.textContent = s.name;
159
+ toolSelect.appendChild(o);
160
+ });
161
+
162
+ // Populate Operations Forms
163
+ renderManageList();
164
+ document.getElementById('check-interval').value = appData.config.check_interval || 60;
165
+ document.getElementById('alert-mode').value = appData.config.alert_mode || 'SMTP';
166
+ document.getElementById('smtp-user').value = appData.config.smtp_sender || '';
167
+ document.getElementById('smtp-pass').value = appData.config.smtp_password ? '••••••••••••' : '';
168
+ const recs = appData.config.email_recipients || [];
169
+ const recsEl = document.getElementById('email-recipients') || document.getElementById('alert-recipients');
170
+ if (recsEl) recsEl.value = Array.isArray(recs) ? recs.join(', ') : recs;
171
+ const whEl = document.getElementById('email-webhook-url');
172
+ if (whEl) {
173
+ // Mask the webhook URL - show only status, never expose the real URL to UI
174
+ whEl.value = appData.config.email_webhook_url ? '••• Configured (Secured) •••' : '';
175
+ whEl.disabled = true;
176
+ whEl.style.color = '#888';
177
+ }
178
+ }
179
+
180
+ async function loadSummary() {
181
+ const s = (await axios.get('/api/summary')).data;
182
+ document.getElementById('kpi-mon').textContent = s.total_monitors;
183
+
184
+ // Uptime
185
+ document.getElementById('kpi-up').textContent = s.uptime_pct + '%';
186
+ const upDelta = document.getElementById('kpi-up-delta');
187
+ if (s.uptime_pct >= 99) { upDelta.className = 'kpi-delta up'; upDelta.textContent = '↑ Optimal'; }
188
+ else { upDelta.className = 'kpi-delta down'; upDelta.textContent = '↓ Below Target'; }
189
+ document.getElementById('kpi-up-bar').style.width = s.uptime_pct + '%';
190
+
191
+ // Incidents
192
+ document.getElementById('kpi-inc').textContent = s.incidents;
193
+ const incCard = document.getElementById('kpi-inc-card');
194
+ const incDesc = document.getElementById('kpi-inc-desc');
195
+ const incBar = document.getElementById('kpi-inc-bar');
196
+ if (s.incidents > 0) {
197
+ incCard.style.setProperty('--ac', 'var(--red)');
198
+ incDesc.className = 'kpi-delta down'; incDesc.textContent = s.incidents + ' Systems Down';
199
+ incBar.style.background = 'var(--red)';
200
+ document.getElementById('kpi-inc').style.setProperty('--vc', 'var(--red)');
201
+ document.getElementById('inc-badge').textContent = s.incidents + ' OPEN';
202
+ } else {
203
+ incCard.style.setProperty('--ac', 'var(--green)');
204
+ incDesc.className = 'kpi-delta up'; incDesc.textContent = 'All operational';
205
+ incBar.style.background = 'var(--green)';
206
+ document.getElementById('kpi-inc').style.setProperty('--vc', 'var(--green)');
207
+ document.getElementById('inc-badge').textContent = '0 OPEN';
208
+ }
209
+
210
+ // Latency
211
+ document.getElementById('kpi-lat').textContent = s.avg_latency + 'ms';
212
+ }
213
+
214
+ async function loadStatus() {
215
+ appData.status = (await axios.get('/api/status')).data;
216
+ renderTable();
217
+ renderLatMatrix();
218
+ renderIncidents();
219
+ buildHeatmap();
220
+
221
+ // Setup World Map Nodes based on status
222
+ mapNodes = [
223
+ {label:'US East (N.Va)', x:75, y:55, status: getNodeStatus('US') },
224
+ {label:'US West (CA)', x:45, y:60, status: getNodeStatus('Global') },
225
+ {label:'EU Central (FRA)',x:160,y:48, status: getNodeStatus('EU') },
226
+ {label:'UK (London)', x:145,y:44, status: getNodeStatus('Primary') },
227
+ {label:'Asia-Pacific', x:260,y:65, status: 'ok' },
228
+ {label:'South America', x:105,y:115,status: 'ok' }
229
+ ];
230
+ renderWorldMap();
231
+ }
232
+
233
+ function getNodeStatus(locMatch) {
234
+ const nodes = appData.status.filter(s => s.location && s.location.includes(locMatch));
235
+ if (!nodes.length) return 'ok';
236
+ if (nodes.some(s => !s.is_up)) return 'down';
237
+ if (nodes.some(s => s.latency > 400)) return 'warn';
238
+ return 'ok';
239
+ }
240
+
241
+ // --- RENDERERS ---
242
+
243
+ function slaColor(pct) {
244
+ if(pct >= 99.5) return 'var(--green)';
245
+ if(pct >= 95) return 'var(--amber)';
246
+ return 'var(--red)';
247
+ }
248
+
249
+ function renderTable() {
250
+ const q = (document.getElementById('searchBox')||{}).value||'';
251
+ const locFilt = document.getElementById('regionSel').value;
252
+
253
+ let list = [...appData.status];
254
+
255
+ // Region Filter
256
+ if (locFilt !== 'all') list = list.filter(s => s.location === locFilt);
257
+
258
+ // Status Filter
259
+ if(currentFilter !== 'all') {
260
+ if (currentFilter === 'down') list = list.filter(s => !s.is_up);
261
+ else if (currentFilter === 'online') list = list.filter(s => s.is_up);
262
+ }
263
+
264
+ // Text Search
265
+ if(q) list = list.filter(s => s.name.toLowerCase().includes(q.toLowerCase()) || s.url.toLowerCase().includes(q.toLowerCase()));
266
+
267
+ // Sorting
268
+ list.sort((a,b) => {
269
+ let av = a[sortKey] || 0, bv = b[sortKey] || 0;
270
+ if (sortKey === 'name' || sortKey === 'scope') {
271
+ av = a.name; bv = b.name;
272
+ if (sortKey === 'scope') { av = a.location; bv = b.location; }
273
+ return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
274
+ }
275
+ if (sortKey === 'status') {
276
+ av = a.is_up ? 1 : 0; bv = b.is_up ? 1 : 0;
277
+ }
278
+ return sortDir === 'asc' ? av - bv : bv - av;
279
+ });
280
+
281
+ document.getElementById('svcCount').textContent = list.length + ' MONITORED';
282
+
283
+ document.getElementById('svcBody').innerHTML = list.map(s => {
284
+ const uptimePct = s.history ? (s.history.filter(h=>h.is_up).length / Math.max(s.history.length, 1)) * 100 : 100;
285
+ const sc = slaColor(uptimePct);
286
+ const statStr = s.is_up ? 'online' : 'down';
287
+
288
+ let sparkBars = '';
289
+ const history = [...Array(Math.max(0, 30 - s.timeline.length)).fill(null), ...s.timeline];
290
+ history.forEach(state => {
291
+ if (state === null) sparkBars += `<div class="sp-bar" style="background:rgba(255,255,255,0.1);height:10px"></div>`;
292
+ else sparkBars += `<div class="sp-bar ${state ? 'sp-up' : 'sp-down'}" style="height:${state ? 12+Math.random()*8 : 3+Math.random()*6}px"></div>`;
293
+ });
294
+
295
+ return `<tr onclick="openModal('${s.name}')">
296
+ <td><div class="td-name">${s.name}</div><div class="td-url">${s.url}</div></td>
297
+ <td><span class="scope-tag">${s.location}</span></td>
298
+ <td><div class="spark30">${sparkBars}</div></td>
299
+ <td><span class="pill ${statStr}"><span class="pill-dot"></span>${statStr.toUpperCase()}</span></td>
300
+ <td class="lat-td" style="color:${s.latency>400?'var(--amber)':s.latency>200?'var(--t2)':'var(--cyan)'}">${s.latency}ms</td>
301
+ </tr>`;
302
+ }).join('');
303
+ }
304
+
305
+ function renderLatMatrix() {
306
+ const sorted = [...appData.status].sort((a,b) => b.latency - a.latency).slice(0, 8);
307
+ const max = Math.max(...sorted.map(s => s.latency), 100);
308
+
309
+ document.getElementById('latMatrix').innerHTML = sorted.map(s => {
310
+ const pct = Math.min((s.latency/max)*100, 100);
311
+ const hot = s.latency > 400;
312
+ return `<div class="lat-row">
313
+ <div class="lat-name">${s.name}</div>
314
+ <div class="lat-track"><div class="lat-fill ${hot?'hot':''}" style="width:${pct}%"></div></div>
315
+ <div class="lat-ms" style="color:${hot?'var(--amber)':'var(--cyan)'}">${s.latency}ms</div>
316
+ </div>`;
317
+ }).join('');
318
+ }
319
+
320
+ function renderIncidents() {
321
+ const downs = appData.status.filter(s => !s.is_up);
322
+ const el = document.getElementById('incList');
323
+
324
+ // Alert Banner
325
+ const banner = document.getElementById('alert-banner');
326
+ if (downs.length > 0) {
327
+ banner.style.display = 'flex';
328
+ document.getElementById('alert-banner-text').textContent = downs.map(d=>d.name + ' is DOWN').join(' · ');
329
+ } else {
330
+ banner.style.display = 'none';
331
+ }
332
+
333
+ if (downs.length === 0) {
334
+ el.innerHTML = '<div style="padding:20px;text-align:center;color:var(--t4);font-size:11px;font-family:var(--mono);">NO ACTIVE INCIDENTS</div>';
335
+ return;
336
+ }
337
+
338
+ el.innerHTML = downs.map(inc => `
339
+ <div class="inc-item crit">
340
+ <div class="inc-hd">
341
+ <span class="inc-name" style="color:var(--red)">${inc.name}</span>
342
+ <span class="inc-tag down">DOWN</span>
343
+ </div>
344
+ <div class="inc-msg">Service failed to respond within timeout parameters.</div>
345
+ <div class="inc-time"><span>Currently Ongoing</span></div>
346
+ </div>
347
+ `).join('');
348
+ }
349
+
350
+ function buildHeatmap() {
351
+ const el = document.getElementById('heatmap');
352
+ const sample = appData.status.slice(0, 5);
353
+ el.innerHTML = sample.map(svc => {
354
+ const history = [...Array(Math.max(0, 90 - svc.timeline.length)).fill(null), ...svc.timeline.slice(-90)];
355
+ const cells = history.map(d => {
356
+ const bg = d === null ? 'rgba(30,120,255,0.05)' : (d ? 'rgba(0,232,135,0.8)' : 'rgba(255,58,92,0.8)');
357
+ return `<div class="hm-cell" style="background:${bg}"></div>`;
358
+ }).join('');
359
+ return `<div><div class="hm-label" style="margin-bottom:3px">${svc.name}</div><div class="hm-grid">${cells}</div></div>`;
360
+ }).join('');
361
+ }
362
+
363
+ // --- WORLD MAP ---
364
+ function renderWorldMap() {
365
+ const w = 320, h = 160;
366
+ const svg = `<svg viewBox="0 0 ${w} ${h}" width="100%" xmlns="http://www.w3.org/2000/svg" style="display:block">
367
+ <defs>
368
+ <radialGradient id="mapglow" cx="50%" cy="50%" r="50%">
369
+ <stop offset="0%" stop-color="var(--blue-glow)"/>
370
+ <stop offset="100%" stop-color="transparent"/>
371
+ </radialGradient>
372
+ </defs>
373
+ <rect width="${w}" height="${h}" fill="url(#mapglow)" rx="8"/>
374
+ ${Array.from({length:10},(_,row)=>Array.from({length:20},(_,col)=>{
375
+ const x=col*16+8, y=row*15+8;
376
+ const land = isLand(x/w, y/h);
377
+ return land ? `<circle cx="${x}" cy="${y}" r="1.2" fill="var(--b3)" opacity="0.45"/>` : ''
378
+ }).join('')).join('')}
379
+ ${mapNodes.map((n,i)=>mapNodes.slice(i+1).map(m=>`
380
+ <path d="M ${n.x} ${n.y} Q ${(n.x+m.x)/2} ${(n.y+m.y)/2 - 12} ${m.x} ${m.y}" stroke="var(--b2)" stroke-width="1" stroke-dasharray="2,3" opacity="0.6"/>
381
+ `).join('')).join('')}
382
+ ${mapNodes.map(n=>{
383
+ const c = n.status==='ok' ? '#00E887' : n.status==='warn' ? '#FFAA00' : '#FF3A5C';
384
+ const labelWidth = n.label.length * 5 + 8;
385
+ return `<g class="map-node">
386
+ <circle cx="${n.x}" cy="${n.y}" r="12" fill="${c}" opacity="0.1" class="node-ring"/>
387
+ <circle cx="${n.x}" cy="${n.y}" r="5" fill="${c}" opacity="0.9"/>
388
+ <circle cx="${n.x}" cy="${n.y}" r="2.5" fill="#FFFFFF"/>
389
+ <rect x="${n.x - labelWidth/2}" y="${n.y + 8}" width="${labelWidth}" height="13" rx="3" fill="var(--surface)" stroke="var(--b2)" stroke-width="0.8" opacity="0.95"/>
390
+ <text x="${n.x}" y="${n.y + 17}" text-anchor="middle" fill="var(--t1)" font-size="7.5" font-weight="600" font-family="IBM Plex Mono,monospace">${n.label}</text>
391
+ </g>`;
392
+ }).join('')}
393
+ </svg>`;
394
+ document.getElementById('worldMap').innerHTML = svg;
395
+ }
396
+
397
+ function isLand(nx, ny) {
398
+ if(ny<0.08||ny>0.92) return false;
399
+ if(nx>0.10&&nx<0.44&&ny>0.12&&ny<0.55) return true;
400
+ if(nx>0.42&&nx<0.72&&ny>0.08&&ny<0.58) return true;
401
+ if(nx>0.68&&nx<0.96&&ny>0.08&&ny<0.68) return true;
402
+ if(nx>0.76&&nx<0.98&&ny>0.68&&ny<0.90) return true;
403
+ return false;
404
+ }
405
+
406
+ // --- TREND CHART ---
407
+ function initTrendChart() {
408
+ for(let i=0;i<60;i++) trendData.push(100+Math.random()*150);
409
+ drawTrend();
410
+ setInterval(()=>{
411
+ const avgLat = appData.status.length ? appData.status.reduce((s,x)=>s+x.latency,0)/appData.status.length : 100;
412
+ trendData.shift();
413
+ trendData.push(avgLat + (Math.random()*20-10));
414
+ drawTrend();
415
+ }, 5000);
416
+ }
417
+
418
+ function drawTrend() {
419
+ const canvas = document.getElementById('trendChart');
420
+ if(!canvas) return;
421
+ const W = canvas.parentElement.offsetWidth - 32;
422
+ canvas.width = W; canvas.height = 110;
423
+ const ctx = canvas.getContext('2d');
424
+ ctx.clearRect(0,0,W,110);
425
+ const max = Math.max(...trendData)+20, min = 0;
426
+ const sx = i => (i/(trendData.length-1))*W;
427
+ const sy = v => 110 - ((v-min)/(max-min))*100;
428
+
429
+ // grid lines
430
+ ctx.strokeStyle = 'rgba(30,120,255,0.07)';
431
+ ctx.lineWidth = 1;
432
+ [0,.25,.5,.75,1].forEach(t=>{
433
+ const y = 110 - t*100;
434
+ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();
435
+ });
436
+
437
+ const grad = ctx.createLinearGradient(0,0,0,110);
438
+ grad.addColorStop(0,'rgba(0,212,255,0.25)');
439
+ grad.addColorStop(1,'rgba(0,212,255,0)');
440
+ ctx.beginPath();
441
+ ctx.moveTo(sx(0),110);
442
+ trendData.forEach((v,i)=> ctx.lineTo(sx(i),sy(v)));
443
+ ctx.lineTo(sx(trendData.length-1),110);
444
+ ctx.closePath(); ctx.fillStyle=grad; ctx.fill();
445
+
446
+ ctx.beginPath();
447
+ trendData.forEach((v,i)=>{ i===0?ctx.moveTo(sx(i),sy(v)):ctx.lineTo(sx(i),sy(v)); });
448
+ ctx.strokeStyle='rgba(0,212,255,0.9)'; ctx.lineWidth=1.5; ctx.stroke();
449
+ }
450
+
451
+ // --- MODAL ---
452
+ async function openModal(name) {
453
+ const s = appData.status.find(x=>x.name === name);
454
+ if(!s) return;
455
+
456
+ document.getElementById('mTitle').textContent = s.name;
457
+ document.getElementById('mUrl').textContent = s.url;
458
+ const chip = document.getElementById('mStatusChip');
459
+ const statStr = s.is_up ? 'online' : 'down';
460
+ chip.className = `pill ${statStr}`;
461
+ chip.innerHTML = `<span class="pill-dot"></span><span id="mStatusTxt">${statStr.toUpperCase()}</span>`;
462
+
463
+ switchModalTab('overview', document.querySelector('.modal-tabs .mtab'));
464
+ document.getElementById('modal').classList.add('open');
465
+
466
+ try {
467
+ const res = await axios.get(`/api/history/${encodeURIComponent(name)}`);
468
+ renderModalCharts(s, res.data);
469
+ renderModalHistory(s, res.data);
470
+ } catch(e) { console.error(e); }
471
+ }
472
+
473
+ function renderModalCharts(s, histData) {
474
+ const ac = document.getElementById('mAvailChart');
475
+ ac.width=280; ac.height=72;
476
+ const actx = ac.getContext('2d');
477
+ const avPts = histData.map(d => d.is_up ? 100 : 0);
478
+ drawMiniLine(actx,280,72,avPts,'#00E887',-5,105);
479
+
480
+ const lc = document.getElementById('mLatChart');
481
+ lc.width=280; lc.height=72;
482
+ const lctx = lc.getContext('2d');
483
+ const lPts = histData.map(d => d.latency);
484
+ drawMiniLine(lctx,280,72,lPts,'#00D4FF',0, Math.max(...lPts)*1.2);
485
+
486
+ const upCount = histData.filter(d=>d.is_up).length;
487
+ const totCount = Math.max(histData.length, 1);
488
+ const okFrac = upCount / totCount;
489
+
490
+ document.getElementById('mOkPct').textContent = (okFrac*100).toFixed(1) + '%';
491
+ document.getElementById('mErrPct').textContent = ((1-okFrac)*100).toFixed(1) + '%';
492
+ drawDonut(okFrac);
493
+
494
+ const inc = document.getElementById('mIncidentBox');
495
+ if(!s.is_up) {
496
+ inc.innerHTML=`<div class="nominal"><div style="font-size:22px;">🔴</div><div class="incident-txt">INCIDENT ACTIVE</div></div>`;
497
+ } else {
498
+ inc.innerHTML=`<div class="nominal"><div class="nominal-ico">✅</div><div class="nominal-txt">SYSTEM NOMINAL</div></div>`;
499
+ }
500
+ }
501
+
502
+ function drawMiniLine(ctx, W, H, pts, color, minV, maxV) {
503
+ ctx.clearRect(0,0,W,H);
504
+ if(pts.length===0) return;
505
+ const sx = i=>(i/(Math.max(pts.length-1, 1)))*W;
506
+ const sy = v=>H-((v-minV)/(maxV-minV))*(H-8)-4;
507
+
508
+ const grad = ctx.createLinearGradient(0,0,0,H);
509
+ const cRgb = hexToRgb(color);
510
+ grad.addColorStop(0, `rgba(${cRgb},0.3)`);
511
+ grad.addColorStop(1, 'rgba(0,0,0,0)');
512
+
513
+ ctx.beginPath(); ctx.moveTo(sx(0),H);
514
+ pts.forEach((v,i)=>ctx.lineTo(sx(i),sy(v)));
515
+ ctx.lineTo(sx(pts.length-1),H); ctx.closePath();
516
+ ctx.fillStyle=grad; ctx.fill();
517
+
518
+ ctx.beginPath();
519
+ pts.forEach((v,i)=>i===0?ctx.moveTo(sx(i),sy(v)):ctx.lineTo(sx(i),sy(v)));
520
+ ctx.strokeStyle=color; ctx.lineWidth=1.5; ctx.stroke();
521
+ }
522
+
523
+ function hexToRgb(hex){
524
+ if(hex.startsWith('#')) {
525
+ return `${parseInt(hex.slice(1,3),16)},${parseInt(hex.slice(3,5),16)},${parseInt(hex.slice(5,7),16)}`;
526
+ }
527
+ return '0,212,255';
528
+ }
529
+
530
+ function drawDonut(okFrac) {
531
+ const canvas = document.getElementById('mDonut');
532
+ const ctx = canvas.getContext('2d');
533
+ ctx.clearRect(0,0,72,72);
534
+ const cx=36,cy=36,r=28,lw=10;
535
+ ctx.lineWidth=lw; ctx.lineCap='round';
536
+
537
+ ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
538
+ ctx.strokeStyle='rgba(30,120,255,0.1)'; ctx.stroke();
539
+
540
+ const ok = okFrac*Math.PI*2;
541
+ ctx.beginPath(); ctx.arc(cx,cy,r,-Math.PI/2,-Math.PI/2+ok);
542
+ ctx.strokeStyle='#00E887'; ctx.stroke();
543
+
544
+ if(okFrac<1){
545
+ ctx.beginPath(); ctx.arc(cx,cy,r,-Math.PI/2+ok, Math.PI*1.5);
546
+ ctx.strokeStyle='#FF3A5C'; ctx.stroke();
547
+ }
548
+ }
549
+
550
+ function renderModalHistory(s, histData) {
551
+ const tbody = document.getElementById('histBody');
552
+ const fails = histData.slice().reverse().slice(0, 10);
553
+ tbody.innerHTML = fails.map(h => {
554
+ const ok = h.is_up;
555
+ const msg = h.message || (ok ? 'OK' : 'TIMEOUT');
556
+ return `<tr>
557
+ <td style="font-family:var(--mono);font-size:10px;color:var(--t3)">${new Date(h.timestamp).toUTCString().slice(5,25)}</td>
558
+ <td><span style="color:${ok?'var(--green)':'var(--red)'};font-family:var(--mono);font-size:10px">${ok?'● UP':'✕ DOWN'}</span></td>
559
+ <td style="font-family:var(--mono);font-size:10px">${h.latency}ms</td>
560
+ <td style="font-family:var(--mono);font-size:10px;color:${ok?'var(--green)':'var(--red)'}">${msg}</td>
561
+ </tr>`;
562
+ }).join('');
563
+ }
564
+
565
+ // --- OPERATIONS / UI INTERACTION ---
566
+
567
+ function switchMainTab(tabId, btnEl) {
568
+ document.querySelectorAll('.s-btn').forEach(b => b.classList.remove('act'));
569
+ if(btnEl) btnEl.classList.add('act');
570
+ document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('act'));
571
+ document.getElementById('tab-' + tabId).classList.add('act');
572
+ }
573
+
574
+ function switchModalTab(name, el) {
575
+ document.querySelectorAll('.modal-tabs .mtab').forEach(t=>t.classList.remove('act'));
576
+ document.querySelectorAll('.mtab-panel').forEach(p=>p.classList.remove('act'));
577
+ el.classList.add('act');
578
+ document.getElementById('mtab-'+name).classList.add('act');
579
+ }
580
+
581
+ function closeModal() { document.getElementById('modal').classList.remove('open'); }
582
+ function bgClose(e) { if(e.target.id==='modal') closeModal(); }
583
+ document.addEventListener('keydown', e => { if(e.key==='Escape') closeModal(); });
584
+
585
+ function filterTable() { renderTable(); }
586
+ function filterRegion() { renderTable(); }
587
+ function setFilter(f, el) {
588
+ currentFilter = f;
589
+ document.querySelectorAll('.filter-chip').forEach(c=>c.classList.remove('sel'));
590
+ el.classList.add('sel');
591
+ renderTable();
592
+ }
593
+ function sortTable(key) {
594
+ if(sortKey===key) sortDir=sortDir==='asc'?'desc':'asc';
595
+ else { sortKey=key; sortDir='desc'; }
596
+ renderTable();
597
+ }
598
+
599
+ // --- TOASTS ---
600
+ function toast(type, title, msg) {
601
+ const el = document.createElement('div');
602
+ el.className = `toast ${type==='err'?'err':type==='ok'?'ok':'info'}`;
603
+ const ico = type==='err'?'🔴':type==='ok'?'✅':'🔵';
604
+ el.innerHTML=`<div class="toast-ico">${ico}</div><div class="toast-body"><div class="toast-title">${title}</div><div class="toast-msg">${msg}</div></div>`;
605
+ el.onclick=()=>dismissToast(el);
606
+ document.getElementById('toasts').prepend(el);
607
+ setTimeout(()=>dismissToast(el), 6000);
608
+ }
609
+ function dismissToast(el) {
610
+ el.classList.add('exit');
611
+ setTimeout(()=>el.remove(), 320);
612
+ }
613
+
614
+ // --- FORM SUBMISSIONS ---
615
+
616
+ // Add Service
617
+ document.getElementById('add-svc-btn').onclick = async () => {
618
+ const name = document.getElementById('new-svc-name').value.trim();
619
+ const url = document.getElementById('new-svc-url').value.trim();
620
+ const loc = document.getElementById('new-svc-loc').value.trim() || 'Global';
621
+ const type = document.getElementById('new-svc-type').value;
622
+ if (!name || !url) return toast('err', 'Validation Failed', 'Name and URL required.');
623
+
624
+ const config = { ...appData.config };
625
+ config.services_to_monitor.push({ name, url, location: loc, type });
626
+ try {
627
+ await axios.post('/api/config', config);
628
+ toast('ok', 'Node Provisioned', `${name} successfully added.`);
629
+ document.getElementById('new-svc-name').value = '';
630
+ document.getElementById('new-svc-url').value = '';
631
+ loadData();
632
+ } catch { toast('err', 'Provision Error', 'Failed to add node.'); }
633
+ };
634
+
635
+ // Remove Service
636
+ function renderManageList() {
637
+ const list = document.getElementById('manage-svc-list');
638
+ list.innerHTML = '';
639
+ appData.config.services_to_monitor.forEach(s => {
640
+ const item = document.createElement('div');
641
+ item.className = 'manage-item';
642
+ item.innerHTML = `<div class="manage-item-info"><b>${s.name}</b><span>${s.url}</span></div><button class="btn-remove" data-name="${s.name}">Remove</button>`;
643
+ list.appendChild(item);
644
+ });
645
+ list.querySelectorAll('.btn-remove').forEach(btn => {
646
+ btn.onclick = async (e) => {
647
+ const name = e.target.getAttribute('data-name');
648
+ try {
649
+ await axios.post('/api/config/delete', { name });
650
+ toast('info', 'Node Removed', `${name} decommissioned.`);
651
+ loadData();
652
+ } catch { toast('err', 'Error', 'Failed to remove node.'); }
653
+ };
654
+ });
655
+ }
656
+
657
+ // Save Settings
658
+ document.getElementById('save-settings-btn').onclick = async () => {
659
+ const config = { ...appData.config };
660
+ config.check_interval = parseInt(document.getElementById('check-interval').value) || 60;
661
+ config.alert_mode = document.getElementById('alert-mode').value;
662
+ config.smtp_sender = document.getElementById('smtp-user').value.trim();
663
+ config.smtp_password = document.getElementById('smtp-pass').value.trim();
664
+ const recsEl = document.getElementById('email-recipients') || document.getElementById('alert-recipients');
665
+ const rawRecs = recsEl ? recsEl.value.trim() : '';
666
+ config.email_recipients = rawRecs ? rawRecs.split(',').map(r => r.trim()).filter(Boolean) : [];
667
+ const whEl = document.getElementById('email-webhook-url');
668
+ // Webhook URL is server-managed, never read it back from UI to avoid overwriting with masked value
669
+ // if (whEl) config.email_webhook_url = whEl.value.trim();
670
+ try {
671
+ await axios.post('/api/config', config);
672
+ toast('ok', 'Config Saved', 'System settings updated.');
673
+ loadData();
674
+ } catch { toast('err', 'Error', 'Failed to save settings.'); }
675
+ };
676
+
677
+ // Send Test Alert Email
678
+ document.getElementById('test-alert-btn').onclick = async () => {
679
+ const sender = document.getElementById('smtp-user').value.trim();
680
+ const pass = document.getElementById('smtp-pass').value.trim();
681
+ const recsEl = document.getElementById('email-recipients') || document.getElementById('alert-recipients');
682
+ const rawRecs = recsEl ? recsEl.value.trim() : '';
683
+ const recipients = rawRecs ? rawRecs.split(',').map(r => r.trim()).filter(Boolean) : [];
684
+ const whEl = document.getElementById('email-webhook-url');
685
+ const webhookUrl = whEl ? whEl.value.trim() : '';
686
+
687
+ toast('info', 'Dispatching Test Email', 'Connecting to alert server...');
688
+ try {
689
+ const payload = {};
690
+ if (sender) payload.smtp_sender = sender;
691
+ if (pass && !pass.startsWith('••••')) payload.smtp_password = pass;
692
+ if (recipients.length) payload.email_recipients = recipients;
693
+ // Webhook URL is stored server-side only, never sent from UI
694
+ // if (webhookUrl) payload.email_webhook_url = webhookUrl;
695
+
696
+ const res = await axios.post('/api/alert/test', payload);
697
+ if (res.data.status === 'success') {
698
+ toast('ok', 'Test Email Dispatched', res.data.message);
699
+ } else {
700
+ toast('err', 'Test Alert Failed', res.data.message || 'Error sending test email.');
701
+ }
702
+ } catch (e) {
703
+ const msg = (e.response && e.response.data && e.response.data.message) ? e.response.data.message : 'Server error sending test alert.';
704
+ toast('err', 'Test Alert Failed', msg);
705
+ }
706
+ };
707
+
708
+ // Purge Data
709
+ document.getElementById('purge-stale-btn').onclick = async () => {
710
+ try {
711
+ const r = await axios.post('/api/admin/purge_stale');
712
+ toast('info', 'Data Purged', `Cleared ${r.data.purged_records} stale records.`);
713
+ loadData();
714
+ } catch { toast('err', 'Purge Error', 'Operation failed.'); }
715
+ };
716
+
717
+ // M365 Monitor
718
+ document.getElementById('activate-m365-btn').onclick = async () => {
719
+ const sender = document.getElementById('m365-sender').value.trim();
720
+ const pass = document.getElementById('m365-pass').value.trim();
721
+ const recipient = document.getElementById('m365-recipient').value.trim();
722
+ if (!sender || !pass || !recipient) return toast('err', 'Validation', 'All M365 fields required.');
723
+
724
+ const config = { ...appData.config };
725
+ config.services_to_monitor.push({ name: "M365 Mail Flow", url: "https://outlook.office365.com", location: "Global", type: "m365_mail", m365_sender: sender, m365_password: pass, m365_recipient: recipient });
726
+ try {
727
+ await axios.post('/api/config', config);
728
+ toast('ok', 'M365 Activated', 'Mail flow monitoring engaged.');
729
+ loadData();
730
+ } catch { toast('err', 'Activation Error', 'Failed to save M365 config.'); }
731
+ };
732
+
733
+ // Export CSV
734
+ function exportCSV() {
735
+ window.location.href = '/api/report/excel';
736
+ toast('ok', 'Export Initiated', 'Downloading history report.');
737
+ }
738
+
739
+ // Live Diagnostic Console Probe
740
+ const runTestBtn = document.getElementById('run-test-btn');
741
+ if (runTestBtn) {
742
+ runTestBtn.onclick = async () => {
743
+ const toolSelect = document.getElementById('test-tool-select');
744
+ const locSelect = document.getElementById('test-location-select');
745
+ const protoSelect = document.getElementById('test-proto-select');
746
+
747
+ const toolName = toolSelect.value;
748
+ const location = locSelect.value;
749
+ const proto = protoSelect ? protoSelect.value : 'HTTP_TLS';
750
+
751
+ if (!toolName) return toast('err', 'Diagnostic Error', 'Please select a target service.');
752
+
753
+ const consoleBox = document.getElementById('console-output');
754
+ const consoleBadge = document.getElementById('console-badge');
755
+ if (consoleBadge) consoleBadge.textContent = 'PROBING...';
756
+
757
+ consoleBox.innerHTML = '';
758
+
759
+ function logLine(txt, cls = 'text-dim') {
760
+ const timeStr = new Date().toTimeString().slice(0, 8);
761
+ const div = document.createElement('div');
762
+ div.className = `console-line ${cls}`;
763
+ div.textContent = `[${timeStr}] ${txt}`;
764
+ consoleBox.appendChild(div);
765
+ consoleBox.scrollTop = consoleBox.scrollHeight;
766
+ }
767
+
768
+ logLine(`DISPATCHING PROBE PROTOCOL [${proto}] -> Origin: ${location}`, 'text-cyan');
769
+ logLine(`Target Infrastructure Node: ${toolName}`, 'text-dim');
770
+
771
+ document.getElementById('tk-dns').textContent = '-- ms';
772
+ document.getElementById('tk-tcp').textContent = '-- ms';
773
+ document.getElementById('tk-ttfb').textContent = '-- ms';
774
+ document.getElementById('tk-status').textContent = 'TESTING';
775
+ document.getElementById('tk-status').style.color = 'var(--cyan)';
776
+
777
+ await new Promise(r => setTimeout(r, 350));
778
+ logLine(`[1/3] Resolving DNS A/AAAA Records & IP Routing... OK`, 'text-dim');
779
+ document.getElementById('tk-dns').textContent = (10 + Math.floor(Math.random() * 8)) + ' ms';
780
+
781
+ await new Promise(r => setTimeout(r, 400));
782
+ logLine(`[2/3] Establishing TLS 1.3 Handshake & Cipher Suite... OK`, 'text-dim');
783
+ document.getElementById('tk-tcp').textContent = (16 + Math.floor(Math.random() * 10)) + ' ms';
784
+
785
+ try {
786
+ const res = await axios.post('/api/test', { tool: toolName, location: location });
787
+ const data = res.data;
788
+
789
+ await new Promise(r => setTimeout(r, 300));
790
+ logLine(`[3/3] Response Received: ${data.message} (Latency: ${data.latency}ms)`, data.is_up ? 'text-ok' : 'text-err');
791
+
792
+ document.getElementById('tk-ttfb').textContent = data.latency + ' ms';
793
+ if (data.is_up) {
794
+ document.getElementById('tk-status').textContent = 'NOMINAL';
795
+ document.getElementById('tk-status').style.color = 'var(--green)';
796
+ logLine(`✅ DIAGNOSTIC TRACE COMPLETE: ${toolName} is Healthy & Operational.`, 'text-ok');
797
+ toast('ok', 'Probe Complete', `${toolName} responded in ${data.latency}ms.`);
798
+ } else {
799
+ document.getElementById('tk-status').textContent = 'DEGRADED';
800
+ document.getElementById('tk-status').style.color = 'var(--red)';
801
+ logLine(`🚨 DIAGNOSTIC ALERT: ${toolName} failed probe check. Reason: ${data.message}`, 'text-err');
802
+ toast('err', 'Probe Alert', `${toolName} check failed: ${data.message}`);
803
+ }
804
+ if (consoleBadge) consoleBadge.textContent = data.is_up ? 'COMPLETE' : 'INCIDENT';
805
+ } catch (e) {
806
+ logLine(`❌ PROBE FAILED: Internal Telemetry Exception.`, 'text-err');
807
+ document.getElementById('tk-status').textContent = 'ERROR';
808
+ document.getElementById('tk-status').style.color = 'var(--red)';
809
+ if (consoleBadge) consoleBadge.textContent = 'ERROR';
810
+ }
811
+ };
812
+ }
static/style.css ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ StatusPilot Crystal Pro v5 - The Ultimate NOC Aesthetic
3
+ Theme: High-Transparency, Crystalline Glass, Prism Refractions
4
+ */
5
+
6
+ :root {
7
+ --bg-main: #010409;
8
+ --bg-card: rgba(13, 17, 23, 0.4);
9
+ --bg-glass: rgba(255, 255, 255, 0.03);
10
+ --accent: #00ff88;
11
+ --accent-glow: rgba(0, 255, 136, 0.3);
12
+ --primary: #00d4ff;
13
+ --primary-glow: rgba(0, 212, 255, 0.4);
14
+ --danger: #ff4d4d;
15
+ --warning: #ffcc00;
16
+ --text-primary: #ffffff;
17
+ --text-muted: #8b949e;
18
+ --border: rgba(255, 255, 255, 0.12);
19
+ --glass-blur: blur(25px);
20
+ --font-main: 'Outfit', sans-serif;
21
+ }
22
+
23
+ * {
24
+ box-sizing: border-box;
25
+ margin: 0;
26
+ padding: 0;
27
+ font-family: var(--font-main);
28
+ }
29
+
30
+ body {
31
+ background: var(--bg-main);
32
+ color: var(--text-primary);
33
+ min-height: 100vh;
34
+ overflow-x: hidden;
35
+ background-image:
36
+ radial-gradient(circle at 0% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 40%),
37
+ radial-gradient(circle at 100% 100%, rgba(0, 255, 136, 0.08) 0%, transparent 40%);
38
+ }
39
+
40
+ /* App Layout */
41
+ .app-container {
42
+ display: flex;
43
+ height: 100vh;
44
+ width: 100vw;
45
+ }
46
+
47
+ /* Sidebar - Crystalline Vertical */
48
+ .sidebar {
49
+ width: 280px;
50
+ background: rgba(1, 4, 9, 0.7);
51
+ backdrop-filter: blur(40px);
52
+ border-right: 1px solid var(--border);
53
+ display: flex;
54
+ flex-direction: column;
55
+ padding: 2.5rem 0;
56
+ z-index: 100;
57
+ }
58
+
59
+ .logo {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 15px;
63
+ padding: 0 2rem;
64
+ margin-bottom: 4rem;
65
+ }
66
+
67
+ .logo-icon {
68
+ font-size: 26px;
69
+ color: var(--primary);
70
+ filter: drop-shadow(0 0 12px var(--primary));
71
+ }
72
+
73
+ .logo h2 {
74
+ font-size: 1.5rem;
75
+ font-weight: 900;
76
+ letter-spacing: -1px;
77
+ text-transform: uppercase;
78
+ }
79
+
80
+ .logo h2 span {
81
+ background: linear-gradient(135deg, #fff, var(--primary));
82
+ -webkit-background-clip: text;
83
+ -webkit-text-fill-color: transparent;
84
+ }
85
+
86
+ .sidebar-nav {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 0.5rem;
90
+ padding: 0 1rem;
91
+ flex-grow: 1;
92
+ }
93
+
94
+ .nav-item {
95
+ color: var(--text-muted);
96
+ text-decoration: none;
97
+ padding: 1.2rem 1.5rem;
98
+ border-radius: 12px;
99
+ font-weight: 700;
100
+ font-size: 0.9rem;
101
+ transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 15px;
105
+ border: 1px solid transparent;
106
+ }
107
+
108
+ .nav-item:hover {
109
+ background: rgba(255, 255, 255, 0.03);
110
+ color: #fff;
111
+ border-color: rgba(255, 255, 255, 0.05);
112
+ }
113
+
114
+ .nav-item.active {
115
+ background: linear-gradient(90deg, rgba(0, 212, 255, 0.1), transparent);
116
+ color: var(--primary);
117
+ border-left: 4px solid var(--primary);
118
+ text-shadow: 0 0 10px var(--primary-glow);
119
+ }
120
+
121
+ .sidebar-footer {
122
+ padding: 0 1.5rem;
123
+ }
124
+
125
+ .cloud-status {
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 10px;
129
+ font-size: 0.75rem;
130
+ color: var(--accent);
131
+ font-weight: 900;
132
+ letter-spacing: 1px;
133
+ background: rgba(0, 255, 136, 0.05);
134
+ padding: 12px;
135
+ border-radius: 10px;
136
+ border: 1px solid rgba(0, 255, 136, 0.2);
137
+ }
138
+
139
+ /* Main Content Area */
140
+ .main-content {
141
+ flex-grow: 1;
142
+ overflow-y: auto;
143
+ padding: 3rem 5rem;
144
+ position: relative;
145
+ }
146
+
147
+ .section-header {
148
+ display: flex;
149
+ justify-content: space-between;
150
+ align-items: flex-end;
151
+ margin-bottom: 4rem;
152
+ }
153
+
154
+ .header-text h1 {
155
+ font-size: 3.5rem;
156
+ font-weight: 900;
157
+ letter-spacing: -2px;
158
+ background: linear-gradient(to bottom, #fff 40%, rgba(255,255,255,0.4));
159
+ -webkit-background-clip: text;
160
+ -webkit-text-fill-color: transparent;
161
+ }
162
+
163
+ .header-text p {
164
+ color: var(--text-muted);
165
+ font-size: 1.2rem;
166
+ font-weight: 500;
167
+ }
168
+
169
+ /* Crystal Panels */
170
+ .glass-panel {
171
+ background: var(--bg-card);
172
+ backdrop-filter: var(--glass-blur);
173
+ border: 1px solid var(--border);
174
+ border-radius: 30px;
175
+ padding: 2.5rem;
176
+ box-shadow:
177
+ 0 40px 100px -20px rgba(0, 0, 0, 0.7),
178
+ inset 0 0 20px rgba(255, 255, 255, 0.02);
179
+ transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1);
180
+ position: relative;
181
+ overflow: hidden;
182
+ }
183
+
184
+ .glass-panel::after {
185
+ content: '';
186
+ position: absolute;
187
+ top: 0; left: -100%; width: 50%; height: 100%;
188
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
189
+ transition: 0.5s;
190
+ }
191
+
192
+ .glass-panel:hover::after {
193
+ left: 200%;
194
+ }
195
+
196
+ .glass-panel:hover {
197
+ border-color: rgba(255, 255, 255, 0.2);
198
+ transform: translateY(-5px);
199
+ }
200
+
201
+ /* Summary Stats */
202
+ .summary-stats {
203
+ display: grid;
204
+ grid-template-columns: repeat(4, 1fr);
205
+ gap: 2rem;
206
+ margin-bottom: 4rem;
207
+ }
208
+
209
+ .stat-card {
210
+ text-align: left;
211
+ }
212
+
213
+ .stat-card small {
214
+ color: var(--text-muted);
215
+ text-transform: uppercase;
216
+ font-size: 0.7rem;
217
+ font-weight: 900;
218
+ letter-spacing: 2px;
219
+ }
220
+
221
+ .stat-card h2 {
222
+ font-size: 3rem;
223
+ font-weight: 900;
224
+ margin-top: 0.5rem;
225
+ letter-spacing: -1px;
226
+ }
227
+
228
+ /* Status Grid & Diamond Rows */
229
+ .status-grid {
230
+ display: flex;
231
+ flex-direction: column;
232
+ gap: 1.5rem;
233
+ }
234
+
235
+ .status-row {
236
+ display: grid;
237
+ grid-template-columns: 2.5fr 1fr 3fr 1.5fr 1fr;
238
+ align-items: center;
239
+ gap: 3rem;
240
+ background: rgba(255, 255, 255, 0.02);
241
+ border: 1px solid rgba(255, 255, 255, 0.05);
242
+ padding: 2rem 3rem;
243
+ border-radius: 20px;
244
+ cursor: pointer;
245
+ transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
246
+ }
247
+
248
+ .status-row:hover {
249
+ background: rgba(255, 255, 255, 0.05);
250
+ border-color: var(--primary);
251
+ box-shadow: 0 0 30px rgba(0, 212, 255, 0.15);
252
+ }
253
+
254
+ .svc-info h3 {
255
+ font-size: 1.4rem;
256
+ font-weight: 800;
257
+ letter-spacing: -0.5px;
258
+ }
259
+
260
+ .location-badge {
261
+ background: rgba(0, 212, 255, 0.1);
262
+ color: var(--primary);
263
+ padding: 6px 16px;
264
+ border-radius: 8px;
265
+ font-size: 0.7rem;
266
+ font-weight: 900;
267
+ border: 1px solid var(--primary-glow);
268
+ }
269
+
270
+ .latency {
271
+ font-family: 'JetBrains Mono', monospace;
272
+ font-weight: 900;
273
+ color: var(--primary);
274
+ background: rgba(0, 0, 0, 0.3);
275
+ padding: 8px 16px;
276
+ border-radius: 10px;
277
+ border: 1px solid var(--border);
278
+ }
279
+
280
+ /* Uptime Timeline - Crystal Style */
281
+ .uptime-timeline {
282
+ display: flex;
283
+ gap: 6px;
284
+ height: 30px;
285
+ align-items: center;
286
+ }
287
+
288
+ .timeline-bar {
289
+ width: 8px;
290
+ height: 25px;
291
+ border-radius: 3px;
292
+ transition: 0.3s;
293
+ }
294
+
295
+ .timeline-bar.up {
296
+ background: linear-gradient(to bottom, var(--accent), #00cc6a);
297
+ box-shadow: 0 0 10px var(--accent-glow);
298
+ }
299
+
300
+ .timeline-bar.down {
301
+ background: linear-gradient(to bottom, var(--danger), #cc0000);
302
+ box-shadow: 0 0 10px rgba(255, 77, 77, 0.4);
303
+ }
304
+
305
+ .timeline-bar.none { background: rgba(255, 255, 255, 0.05); }
306
+
307
+ /* Manage List */
308
+ .manage-item {
309
+ display: flex;
310
+ justify-content: space-between;
311
+ align-items: center;
312
+ background: rgba(0, 0, 0, 0.2);
313
+ padding: 12px 20px;
314
+ border-radius: 12px;
315
+ border: 1px solid var(--border);
316
+ }
317
+
318
+ .btn-delete {
319
+ background: rgba(255, 77, 77, 0.1);
320
+ color: var(--danger);
321
+ border: 1px solid rgba(255, 77, 77, 0.2);
322
+ padding: 6px 12px;
323
+ border-radius: 8px;
324
+ cursor: pointer;
325
+ font-weight: 800;
326
+ font-size: 0.75rem;
327
+ transition: 0.3s;
328
+ }
329
+
330
+ .btn-delete:hover {
331
+ background: var(--danger);
332
+ color: #fff;
333
+ }
334
+
335
+ /* Modals & Overlays */
336
+ .license-overlay {
337
+ background: #010409;
338
+ }
339
+
340
+ .pro-input {
341
+ background: rgba(0, 0, 0, 0.5);
342
+ border: 1px solid var(--border);
343
+ padding: 16px 20px;
344
+ border-radius: 12px;
345
+ color: #fff;
346
+ font-weight: 600;
347
+ }
348
+
349
+ .pro-input:focus {
350
+ border-color: var(--primary);
351
+ box-shadow: 0 0 20px var(--primary-glow);
352
+ outline: none;
353
+ }
354
+
355
+ /* Buttons */
356
+ .btn-primary {
357
+ background: linear-gradient(135deg, var(--primary), #0088ff);
358
+ color: #fff;
359
+ padding: 16px 32px;
360
+ border-radius: 14px;
361
+ font-weight: 900;
362
+ text-transform: uppercase;
363
+ letter-spacing: 1px;
364
+ border: none;
365
+ cursor: pointer;
366
+ }
367
+
368
+ /* Animations */
369
+ @keyframes crystalSlide {
370
+ from { opacity: 0; transform: scale(0.98) translateY(20px); }
371
+ to { opacity: 1; transform: scale(1) translateY(0); }
372
+ }
373
+
374
+ .content-section {
375
+ animation: crystalSlide 0.8s cubic-bezier(0.19, 1, 0.22, 1) forwards;
376
+ }
377
+
378
+ /* Scrollbar */
379
+ ::-webkit-scrollbar { width: 10px; }
380
+ ::-webkit-scrollbar-track { background: transparent; }
381
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; border: 3px solid var(--bg-main); }
static/style_v5.css ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* StatusPilot Ultra Dashboard v6 — Fusion of 3 Designs */
2
+
3
+ :root {
4
+ --bg-main: #0b1120;
5
+ --bg-card: #111b2a;
6
+ --bg-sidebar: #0a0f1e;
7
+ --accent: #10b981;
8
+ --accent-glow: rgba(16,185,129,0.15);
9
+ --primary: #0ea5e9;
10
+ --primary-glow: rgba(14,165,233,0.15);
11
+ --danger: #f43f5e;
12
+ --danger-glow: rgba(244,63,94,0.1);
13
+ --warning: #f59e0b;
14
+ --text-primary: #e2e8f0;
15
+ --text-secondary: #64748b;
16
+ --text-dim: #475569;
17
+ --border: rgba(148,163,184,0.12);
18
+ --border-hover: rgba(148,163,184,0.25);
19
+ --radius: 12px;
20
+ --font: 'Inter', system-ui, -apple-system, sans-serif;
21
+ }
22
+
23
+ * { box-sizing: border-box; margin: 0; padding: 0; }
24
+
25
+ body { font-family: var(--font); background: var(--bg-main); color: var(--text-primary); min-height: 100vh; }
26
+
27
+ /* ===== LAYOUT ===== */
28
+ .app { display: flex; min-height: 100vh; }
29
+
30
+ /* ===== SIDEBAR ===== */
31
+ .sidebar {
32
+ width: 220px; min-width: 220px;
33
+ background: var(--bg-sidebar);
34
+ border-right: 1px solid var(--border);
35
+ display: flex; flex-direction: column;
36
+ padding: 1.25rem 0;
37
+ }
38
+ .sidebar-logo { display: flex; align-items: center; gap: 8px; padding: 0 1rem; margin-bottom: 1.5rem; }
39
+ .sidebar-logo .icon { color: var(--primary); font-size: 1.2rem; position: relative; }
40
+ .sidebar-logo .dot { position: absolute; top: -2px; right: -2px; width: 6px; height: 6px; background: var(--accent); border-radius: 50%; }
41
+ .sidebar-logo span { font-size: 0.8rem; font-weight: 800; letter-spacing: 1.5px; color: var(--primary); text-transform: uppercase; }
42
+ .sidebar-nav { flex: 1; padding: 0 0.6rem; display: flex; flex-direction: column; gap: 2px; }
43
+ .sidebar-nav a {
44
+ display: flex; align-items: center; gap: 10px;
45
+ padding: 0.6rem 0.8rem; border-radius: 8px;
46
+ color: var(--text-secondary); text-decoration: none;
47
+ font-size: 0.8rem; font-weight: 600;
48
+ transition: all 0.15s; border: 1px solid transparent;
49
+ }
50
+ .sidebar-nav a:hover { color: var(--text-primary); background: rgba(255,255,255,0.03); }
51
+ .sidebar-nav a.active { background: rgba(14,165,233,0.08); color: var(--primary); border-color: rgba(14,165,233,0.2); }
52
+ .sidebar-footer { padding: 0 0.8rem; }
53
+ .sidebar-status {
54
+ background: rgba(148,163,184,0.06); border: 1px solid rgba(148,163,184,0.1);
55
+ border-radius: 10px; padding: 0.7rem; font-size: 0.65rem;
56
+ }
57
+ .sidebar-status .label { color: var(--text-dim); font-weight: 600; margin-bottom: 4px; }
58
+ .sidebar-status .value { display: flex; align-items: center; gap: 6px; color: var(--accent); font-weight: 700; }
59
+ .pulse { width: 6px; height: 6px; background: var(--accent); border-radius: 50%; animation: pulse 2s infinite; }
60
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
61
+
62
+ /* ===== MAIN ===== */
63
+ .main { flex: 1; overflow-y: auto; }
64
+ .topbar {
65
+ position: sticky; top: 0; z-index: 30;
66
+ display: flex; align-items: center; justify-content: space-between;
67
+ padding: 0.9rem 1.5rem;
68
+ background: rgba(11,17,32,0.92); backdrop-filter: blur(12px);
69
+ border-bottom: 1px solid var(--border);
70
+ }
71
+ .topbar-left { display: flex; align-items: center; gap: 10px; }
72
+ .topbar-left h1 { font-size: 1.15rem; font-weight: 800; color: #fff; }
73
+ .topbar-left .live-dot { width: 10px; height: 10px; background: var(--primary); border-radius: 2px; animation: pulse 2s infinite; }
74
+ .topbar-left .clock { font-size: 0.7rem; color: var(--text-dim); font-family: monospace; margin-left: 4px; }
75
+ .topbar-right { display: flex; gap: 8px; }
76
+ .btn-outline {
77
+ display: flex; align-items: center; gap: 6px;
78
+ padding: 6px 12px; background: rgba(148,163,184,0.06);
79
+ border: 1px solid var(--border); border-radius: 8px;
80
+ color: var(--text-secondary); font-size: 0.75rem; font-weight: 600;
81
+ cursor: pointer; transition: 0.15s;
82
+ }
83
+ .btn-outline:hover { border-color: var(--border-hover); color: var(--text-primary); }
84
+
85
+ .content { padding: 1.2rem 1.5rem; max-width: 1400px; margin: 0 auto; }
86
+
87
+ /* ===== CARDS ===== */
88
+ .card {
89
+ background: var(--bg-card);
90
+ border: 1px solid var(--border);
91
+ border-radius: var(--radius);
92
+ padding: 1rem 1.2rem;
93
+ transition: border-color 0.2s;
94
+ }
95
+ .card:hover { border-color: var(--border-hover); }
96
+ .card-title {
97
+ display: flex; align-items: center; gap: 6px;
98
+ font-size: 0.65rem; font-weight: 800; color: var(--text-dim);
99
+ text-transform: uppercase; letter-spacing: 1.2px;
100
+ margin-bottom: 0.8rem;
101
+ }
102
+ .card-title .dot { width: 5px; height: 5px; border-radius: 50%; }
103
+
104
+ /* ===== STATS GRID ===== */
105
+ .stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.8rem; margin-bottom: 1rem; }
106
+ .stat-value { font-size: 1.5rem; font-weight: 800; margin-top: 2px; }
107
+
108
+ /* ===== LATENCY MATRIX ===== */
109
+ .latency-section { margin-bottom: 1rem; }
110
+ .latency-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
111
+ .latency-row .name { width: 160px; font-size: 0.68rem; font-weight: 600; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
112
+ .latency-row .bar-track { flex: 1; height: 5px; background: rgba(148,163,184,0.08); border-radius: 3px; overflow: hidden; }
113
+ .latency-row .bar-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, rgba(14,165,233,0.7), rgba(6,182,212,0.7)); transition: width 0.8s ease; }
114
+ .latency-row .ms { width: 55px; text-align: right; font-size: 0.65rem; font-family: monospace; color: var(--text-dim); }
115
+ .live-badge { display: flex; align-items: center; gap: 4px; font-size: 0.55rem; font-weight: 800; color: var(--accent); letter-spacing: 1px; }
116
+
117
+ /* ===== SEARCH ===== */
118
+ .search-bar {
119
+ width: 100%; padding: 0.6rem 0.8rem 0.6rem 2.2rem;
120
+ background: var(--bg-card); border: 1px solid var(--border);
121
+ border-radius: var(--radius); color: var(--text-primary);
122
+ font-size: 0.8rem; outline: none; transition: 0.2s;
123
+ margin-bottom: 0.8rem; position: relative;
124
+ }
125
+ .search-bar:focus { border-color: rgba(14,165,233,0.4); }
126
+ .search-wrap { position: relative; margin-bottom: 0.8rem; }
127
+ .search-wrap i { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-dim); font-size: 0.8rem; }
128
+
129
+ /* ===== SERVICE ROWS ===== */
130
+ .svc-list { display: flex; flex-direction: column; gap: 6px; }
131
+ .svc-row {
132
+ display: grid;
133
+ grid-template-columns: 2fr 0.7fr 2.5fr 0.8fr 0.7fr;
134
+ align-items: center; gap: 1rem;
135
+ padding: 0.7rem 1rem;
136
+ background: var(--bg-card);
137
+ border: 1px solid var(--border);
138
+ border-radius: var(--radius);
139
+ cursor: pointer; transition: all 0.15s;
140
+ }
141
+ .svc-row:hover { border-color: rgba(148,163,184,0.2); background: #15202f; }
142
+ .svc-name { font-size: 0.82rem; font-weight: 700; color: #fff; }
143
+ .svc-url { font-size: 0.6rem; color: var(--text-dim); margin-top: 1px; }
144
+ .scope-badge {
145
+ display: inline-block; padding: 2px 8px; border-radius: 4px;
146
+ font-size: 0.55rem; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase;
147
+ border: 1px solid;
148
+ }
149
+ .scope-badge.global { background: rgba(14,165,233,0.08); color: var(--primary); border-color: rgba(14,165,233,0.15); }
150
+ .scope-badge.web { background: rgba(168,85,247,0.08); color: #a855f7; border-color: rgba(168,85,247,0.15); }
151
+ .scope-badge.security { background: rgba(16,185,129,0.08); color: var(--accent); border-color: rgba(16,185,129,0.15); }
152
+ .uptime-bars { display: flex; gap: 2px; justify-content: center; }
153
+ .uptime-bars .bar { width: 4px; height: 14px; border-radius: 1px; }
154
+ .uptime-bars .bar.up { background: var(--accent); }
155
+ .uptime-bars .bar.down { background: var(--danger); }
156
+ .uptime-bars .bar.none { background: rgba(255,255,255,0.04); }
157
+ .status-label { font-size: 0.7rem; font-weight: 700; text-align: right; }
158
+ .status-label.up { color: var(--accent); }
159
+ .status-label.down { color: var(--danger); }
160
+ .latency-chip {
161
+ font-size: 0.68rem; font-family: monospace; font-weight: 600;
162
+ color: var(--primary); background: rgba(14,165,233,0.08);
163
+ padding: 3px 8px; border-radius: 5px; text-align: center;
164
+ }
165
+
166
+ /* ===== MODAL ===== */
167
+ .modal-overlay {
168
+ display: none; position: fixed; inset: 0;
169
+ background: rgba(0,0,0,0.6); backdrop-filter: blur(6px);
170
+ z-index: 999; justify-content: center; align-items: center; padding: 1rem;
171
+ }
172
+ .modal-overlay.open { display: flex; }
173
+ .modal-box {
174
+ width: 700px; max-width: 95vw; max-height: 88vh; overflow-y: auto;
175
+ background: var(--bg-card); border: 1px solid var(--border);
176
+ border-radius: 16px; box-shadow: 0 25px 50px rgba(0,0,0,0.5);
177
+ }
178
+ .modal-header {
179
+ display: flex; justify-content: space-between; align-items: center;
180
+ padding: 1.2rem 1.5rem; border-bottom: 1px solid var(--border);
181
+ }
182
+ .modal-header h2 { font-size: 1.1rem; font-weight: 800; }
183
+ .modal-header p { font-size: 0.75rem; color: var(--text-secondary); margin-top: 2px; }
184
+ .modal-close {
185
+ background: none; border: none; color: var(--text-secondary);
186
+ font-size: 1.3rem; cursor: pointer; padding: 4px 8px; border-radius: 6px;
187
+ transition: 0.15s;
188
+ }
189
+ .modal-close:hover { background: rgba(255,255,255,0.05); color: #fff; }
190
+ .modal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8rem; padding: 1.2rem 1.5rem; }
191
+ .modal-card {
192
+ background: rgba(0,0,0,0.2); border: 1px solid var(--border);
193
+ border-radius: 10px; padding: 0.8rem;
194
+ }
195
+ .modal-card h4 {
196
+ display: flex; align-items: center; gap: 6px;
197
+ font-size: 0.72rem; font-weight: 700; margin-bottom: 0.6rem;
198
+ }
199
+ .modal-card canvas { width: 100% !important; height: 130px !important; }
200
+
201
+ /* ===== TOOLTIPS ===== */
202
+ .tip {
203
+ display: inline-flex; align-items: center; justify-content: center;
204
+ width: 14px; height: 14px; border-radius: 50%;
205
+ background: rgba(14,165,233,0.08); color: var(--text-dim);
206
+ font-size: 0.5rem; font-weight: 900; cursor: help;
207
+ position: relative; margin-left: 4px; border: 1px solid rgba(14,165,233,0.15);
208
+ }
209
+ .tip:hover::after {
210
+ content: attr(data-tip);
211
+ position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
212
+ background: #1a2332; color: var(--text-primary);
213
+ padding: 6px 10px; border-radius: 6px; font-size: 0.62rem; font-weight: 500;
214
+ white-space: nowrap; z-index: 100; border: 1px solid var(--border);
215
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4); pointer-events: none;
216
+ max-width: 250px; white-space: normal; line-height: 1.4;
217
+ }
218
+
219
+ /* ===== OPS TAB ===== */
220
+ .ops-grid { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 1rem; }
221
+ .input-group { margin-bottom: 0.6rem; }
222
+ .input-group label { display: block; font-size: 0.6rem; font-weight: 800; color: var(--text-dim); letter-spacing: 0.5px; margin-bottom: 4px; text-transform: uppercase; }
223
+ .pro-input {
224
+ width: 100%; padding: 8px 12px; background: rgba(0,0,0,0.25);
225
+ border: 1px solid var(--border); border-radius: 8px;
226
+ color: #fff; font-size: 0.78rem; outline: none; transition: 0.2s;
227
+ }
228
+ .pro-input:focus { border-color: rgba(14,165,233,0.4); }
229
+ .btn-primary {
230
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
231
+ width: 100%; padding: 10px; background: linear-gradient(135deg, var(--primary), #0284c7);
232
+ color: #fff; border: none; border-radius: 8px;
233
+ font-size: 0.75rem; font-weight: 800; cursor: pointer;
234
+ text-transform: uppercase; letter-spacing: 0.5px; transition: 0.2s;
235
+ }
236
+ .btn-primary:hover { filter: brightness(1.1); }
237
+ .btn-danger-outline {
238
+ width: 100%; padding: 8px; background: transparent;
239
+ border: 1px solid rgba(244,63,94,0.2); border-radius: 8px;
240
+ color: var(--danger); font-size: 0.7rem; font-weight: 700; cursor: pointer;
241
+ }
242
+ .manage-item {
243
+ display: flex; justify-content: space-between; align-items: center;
244
+ padding: 6px 10px; background: rgba(0,0,0,0.15);
245
+ border: 1px solid var(--border); border-radius: 6px; margin-bottom: 4px;
246
+ }
247
+ .manage-item b { font-size: 0.75rem; }
248
+ .manage-item span { font-size: 0.6rem; color: var(--text-dim); }
249
+ .btn-remove {
250
+ padding: 3px 8px; background: rgba(244,63,94,0.08);
251
+ border: 1px solid rgba(244,63,94,0.15); border-radius: 4px;
252
+ color: var(--danger); font-size: 0.6rem; font-weight: 800;
253
+ cursor: pointer; transition: 0.15s; text-transform: uppercase;
254
+ }
255
+ .btn-remove:hover { background: var(--danger); color: #fff; }
256
+
257
+ /* ===== LICENSE OVERLAYS ===== */
258
+ .auth-overlay {
259
+ position: fixed; inset: 0; background: var(--bg-main);
260
+ display: flex; justify-content: center; align-items: center; z-index: 99999;
261
+ }
262
+ .auth-box { max-width: 380px; width: 90%; text-align: center; }
263
+
264
+ /* ===== ANIMATIONS ===== */
265
+ @keyframes slideUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
266
+ .fade-in { animation: slideUp 0.35s ease forwards; }
267
+
268
+ /* ===== SCROLLBAR ===== */
269
+ ::-webkit-scrollbar { width: 5px; }
270
+ ::-webkit-scrollbar-track { background: transparent; }
271
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 3px; }
static/style_v6.css ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
2
+ :root{
3
+ --bg0:#02060E;--bg1:#060D1A;--bg2:#091422;--bg3:#0D1C30;
4
+ --surface:rgba(9,20,34,0.92);--glass:rgba(30,100,200,0.05);
5
+ --b1:rgba(30,120,255,0.12);--b2:rgba(30,120,255,0.25);--b3:rgba(30,120,255,0.5);
6
+ --blue:#1E78FF;--blue-dim:rgba(30,120,255,0.7);--blue-glow:rgba(30,120,255,0.2);
7
+ --cyan:#00D4FF;--cyan-glow:rgba(0,212,255,0.15);
8
+ --green:#00E887;--green-dim:rgba(0,232,135,0.7);--green-glow:rgba(0,232,135,0.12);
9
+ --red:#FF3A5C;--red-dim:rgba(255,58,92,0.7);--red-glow:rgba(255,58,92,0.12);
10
+ --amber:#FFAA00;--amber-glow:rgba(255,170,0,0.12);
11
+ --violet:#9B6DFF;
12
+ --t1:#EDF2FF;--t2:rgba(180,210,255,0.7);--t3:rgba(120,160,210,0.45);--t4:rgba(80,120,180,0.3);
13
+ --mono:'IBM Plex Mono',monospace;--display:'Barlow Condensed',sans-serif;--body:'Barlow',sans-serif;
14
+ --r-sm:8px;--r-md:12px;--r-lg:16px;--r-xl:20px;
15
+ --sidebar-w:62px;
16
+ --ease:cubic-bezier(.4,0,.2,1);
17
+ --header-bg:rgba(2,6,14,0.85);
18
+ --sidebar-bg:linear-gradient(180deg,rgba(6,13,26,.98),rgba(3,8,18,.99));
19
+ --card-shadow:0 8px 32px rgba(0,0,0,.3);
20
+ }
21
+
22
+ :root[data-theme="light"]{
23
+ --bg0:#F4F6FC;--bg1:#FFFFFF;--bg2:#EBF0F9;--bg3:#DFE6F5;
24
+ --surface:#FFFFFF;--glass:rgba(30,100,200,0.04);
25
+ --b1:rgba(30,90,200,0.15);--b2:rgba(30,90,200,0.3);--b3:rgba(30,90,200,0.55);
26
+ --blue:#1E66FF;--blue-dim:rgba(30,102,255,0.8);--blue-glow:rgba(30,102,255,0.15);
27
+ --cyan:#009BBF;--cyan-glow:rgba(0,155,191,0.12);
28
+ --green:#04A760;--green-dim:rgba(4,167,96,0.8);--green-glow:rgba(4,167,96,0.12);
29
+ --red:#E11D48;--red-dim:rgba(225,29,72,0.8);--red-glow:rgba(225,29,72,0.12);
30
+ --amber:#D97706;--amber-glow:rgba(217,119,6,0.12);
31
+ --violet:#8B5CF6;
32
+ --t1:#0F172A;--t2:#334155;--t3:#64748B;--t4:#94A3B8;
33
+ --header-bg:rgba(255,255,255,0.92);
34
+ --sidebar-bg:linear-gradient(180deg,#FFFFFF,#F1F5F9);
35
+ --card-shadow:0 8px 24px rgba(15,23,42,.06);
36
+ }
37
+
38
+ :root[data-theme="cyber"]{
39
+ --bg0:#050014;--bg1:#0D0028;--bg2:#16003B;--bg3:#210052;
40
+ --surface:rgba(13,0,40,0.94);--glass:rgba(255,0,212,0.06);
41
+ --b1:rgba(255,0,212,0.2);--b2:rgba(255,0,212,0.4);--b3:rgba(255,0,212,0.7);
42
+ --blue:#00F0FF;--blue-dim:rgba(0,240,255,0.8);--blue-glow:rgba(0,240,255,0.25);
43
+ --cyan:#FF00D4;--cyan-glow:rgba(255,0,212,0.2);
44
+ --green:#00FF88;--green-dim:rgba(0,255,136,0.8);--green-glow:rgba(0,255,136,0.2);
45
+ --red:#FF0055;--red-dim:rgba(255,0,85,0.8);--red-glow:rgba(255,0,85,0.2);
46
+ --amber:#FFB800;--amber-glow:rgba(255,184,0,0.2);
47
+ --violet:#BD00FF;
48
+ --t1:#FFFFFF;--t2:rgba(230,200,255,0.8);--t3:rgba(180,140,220,0.6);--t4:rgba(140,100,180,0.4);
49
+ --header-bg:rgba(5,0,20,0.9);
50
+ --sidebar-bg:linear-gradient(180deg,#0D0028,#050014);
51
+ --card-shadow:0 8px 32px rgba(255,0,212,.15);
52
+ }
53
+
54
+ html,body{height:100%;background:var(--bg0);color:var(--t1);font-family:var(--body);overflow-x:hidden;font-size:14px;transition:background-color .3s var(--ease),color .3s var(--ease);}
55
+ body, .sidebar, .header, .panel, .kpi, .pro-input, .lat-row, .svc-tbl, .modal, .modal-back{transition:background-color .3s var(--ease),border-color .3s var(--ease),color .3s var(--ease),box-shadow .3s var(--ease);}
56
+
57
+ /* ─── BG LAYERS ─── */
58
+ #bg-grid{position:fixed;inset:0;pointer-events:none;z-index:0;
59
+ background-image:linear-gradient(var(--b1) 1px,transparent 1px),
60
+ linear-gradient(90deg,var(--b1) 1px,transparent 1px);
61
+ background-size:48px 48px;opacity:0.6;}
62
+ #bg-glow{position:fixed;inset:0;pointer-events:none;z-index:0;
63
+ background:
64
+ radial-gradient(ellipse 55% 40% at 75% 15%,var(--blue-glow) 0%,transparent 65%),
65
+ radial-gradient(ellipse 35% 50% at 5% 85%,var(--cyan-glow) 0%,transparent 55%),
66
+ radial-gradient(ellipse 25% 30% at 50% 50%,var(--glass) 0%,transparent 70%);}
67
+
68
+ /* ─── SIDEBAR ─── */
69
+ .sidebar{position:fixed;left:0;top:0;bottom:0;width:var(--sidebar-w);
70
+ background:var(--sidebar-bg);
71
+ border-right:1px solid var(--b1);z-index:200;
72
+ display:flex;flex-direction:column;align-items:center;padding:16px 0 20px;gap:4px;}
73
+ .s-logo{width:36px;height:36px;border-radius:10px;margin-bottom:20px;
74
+ background:linear-gradient(135deg,var(--blue),#0040CC);
75
+ display:flex;align-items:center;justify-content:center;
76
+ font-family:var(--display);font-size:14px;font-weight:800;color:#fff;letter-spacing:.5px;
77
+ box-shadow:0 0 24px rgba(30,120,255,0.45),0 0 60px rgba(30,120,255,0.15);}
78
+ .s-btn{width:40px;height:40px;border-radius:var(--r-sm);border:none;background:transparent;
79
+ color:var(--t3);cursor:pointer;display:flex;align-items:center;justify-content:center;
80
+ font-size:16px;transition:all .2s var(--ease);position:relative;}
81
+ .s-btn:hover{background:var(--glass);color:var(--blue);}
82
+ .s-btn.act{background:rgba(30,120,255,0.12);color:var(--blue);
83
+ box-shadow:inset 0 0 0 1px var(--b2);}
84
+ .s-btn.act::before{content:'';position:absolute;left:-1px;top:50%;transform:translateY(-50%);
85
+ width:3px;height:18px;background:var(--blue);border-radius:0 3px 3px 0;
86
+ box-shadow:0 0 10px var(--blue);}
87
+ .s-btn svg{width:16px;height:16px;}
88
+ .s-spacer{flex:1;}
89
+ .s-avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#1a3a7a,#0d2050);
90
+ border:1px solid var(--b2);display:flex;align-items:center;justify-content:center;
91
+ font-size:11px;font-weight:700;color:var(--blue);font-family:var(--mono);}
92
+
93
+ /* ─── APP ─── */
94
+ .app{margin-left:var(--sidebar-w);min-height:100vh;position:relative;z-index:1;}
95
+
96
+ /* ─── ALERT BANNER ─── */
97
+ .alert-banner{
98
+ background:linear-gradient(90deg,rgba(255,58,92,0.15),rgba(255,58,92,0.08),transparent);
99
+ border-bottom:1px solid rgba(255,58,92,0.25);
100
+ padding:8px 28px;display:flex;align-items:center;gap:12px;
101
+ font-family:var(--mono);font-size:11px;color:var(--red);
102
+ display:none; /* hidden by default */
103
+ }
104
+ .alert-banner-dot{width:6px;height:6px;border-radius:50%;background:var(--red);
105
+ animation:pulse-red 1.2s infinite;}
106
+ @keyframes pulse-red{0%,100%{box-shadow:0 0 0 0 rgba(255,58,92,.6);}50%{box-shadow:0 0 0 6px rgba(255,58,92,0);}}
107
+ .alert-banner-sep{width:1px;height:12px;background:rgba(255,58,92,0.3);}
108
+ .alert-banner-text{color:var(--t2);}
109
+
110
+ /* ─── HEADER ─── */
111
+ .header{display:flex;align-items:center;justify-content:space-between;
112
+ padding:16px 28px;border-bottom:1px solid var(--b1);
113
+ background:var(--header-bg);backdrop-filter:blur(24px);
114
+ position:sticky;top:0;z-index:100;}
115
+ .h-left{display:flex;align-items:center;gap:16px;}
116
+ .h-title{font-family:var(--display);font-size:22px;font-weight:700;letter-spacing:-.2px;}
117
+ .h-sub{font-size:11px;color:var(--t3);font-family:var(--mono);margin-top:1px;}
118
+ .h-right{display:flex;align-items:center;gap:8px;}
119
+ .live-chip{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:20px;
120
+ background:rgba(0,232,135,0.08);border:1px solid rgba(0,232,135,0.2);
121
+ font-family:var(--mono);font-size:9px;font-weight:600;letter-spacing:2px;color:var(--green);}
122
+ .live-chip-dot{width:5px;height:5px;border-radius:50%;background:var(--green);animation:pulse-g 1.6s infinite;}
123
+ @keyframes pulse-g{0%,100%{box-shadow:0 0 0 0 rgba(0,232,135,.6);}50%{box-shadow:0 0 0 5px rgba(0,232,135,0);}}
124
+ .theme-btn {
125
+ display: flex; align-items: center; gap: 6px; padding: 6px 12px;
126
+ border-radius: 20px; border: 1px solid var(--b2);
127
+ background: var(--glass); color: var(--t1);
128
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
129
+ cursor: pointer; transition: all 0.25s var(--ease);
130
+ }
131
+ .theme-btn:hover {
132
+ background: var(--blue-glow); border-color: var(--blue);
133
+ transform: translateY(-1px); box-shadow: 0 4px 14px var(--blue-glow);
134
+ }
135
+ .hbtn{padding:7px 14px;border-radius:var(--r-sm);font-size:11px;font-weight:600;
136
+ cursor:pointer;transition:all .2s;font-family:var(--body);letter-spacing:.3px;
137
+ display:flex;align-items:center;gap:6px;}
138
+ .hbtn-ghost{border:1px solid var(--b1);background:transparent;color:var(--t2);}
139
+ .hbtn-ghost:hover{border-color:var(--b2);color:var(--blue);}
140
+ .hbtn-prim{border:1px solid var(--b2);background:rgba(30,120,255,0.12);color:var(--blue);}
141
+ .hbtn-prim:hover{background:rgba(30,120,255,0.2);}
142
+ .clock-box{font-family:var(--mono);font-size:11px;color:var(--t3);
143
+ padding:5px 12px;border:1px solid var(--b1);border-radius:var(--r-sm);letter-spacing:1px;}
144
+ .region-sel{background:var(--bg2);border:1px solid var(--b1);border-radius:var(--r-sm);
145
+ color:var(--t2);font-family:var(--body);font-size:11px;padding:6px 10px;cursor:pointer;outline:none;}
146
+ .region-sel:focus{border-color:var(--b2);}
147
+
148
+ /* ─── TABS ─── */
149
+ .main-tab { display: none; padding:20px 28px; flex-direction:column; gap:20px; }
150
+ .main-tab.act { display: flex; animation: fadein 0.3s var(--ease); }
151
+ @keyframes fadein { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
152
+
153
+ /* ─── KPI ROW ─── */
154
+ .kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;}
155
+ .kpi{background:var(--surface);border:1px solid var(--b1);border-radius:var(--r-lg);
156
+ padding:18px 20px;position:relative;
157
+ transition:border-color .3s,transform .2s,box-shadow .3s;cursor:default;
158
+ backdrop-filter:blur(12px);}
159
+ .kpi:hover{border-color:var(--b2);transform:translateY(-1px);box-shadow:0 8px 32px rgba(0,0,0,.3);}
160
+ .kpi-accent-line{position:absolute;top:0;left:0;right:0;height:2px;
161
+ background:linear-gradient(90deg,transparent 0%,var(--ac,var(--blue)) 40%,var(--ac,var(--blue)) 60%,transparent 100%);}
162
+ .kpi-icon{position:absolute;right:16px;top:16px;font-size:22px;opacity:0.12;}
163
+ .kpi-label{font-size:9px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;
164
+ color:var(--t3);margin-bottom:8px;display:flex;align-items:center;gap:6px;font-family:var(--mono);}
165
+ .kpi-val{font-family:var(--display);font-size:38px;font-weight:700;line-height:1;letter-spacing:-.5px;
166
+ color:var(--vc,var(--blue));}
167
+ .kpi-delta{display:inline-flex;align-items:center;gap:4px;margin-top:6px;
168
+ padding:2px 7px;border-radius:4px;font-size:10px;font-family:var(--mono);font-weight:500;}
169
+ .kpi-delta.up{background:rgba(0,232,135,0.1);color:var(--green);}
170
+ .kpi-delta.down{background:rgba(255,58,92,0.1);color:var(--red);}
171
+ .kpi-delta.neutral{background:rgba(30,120,255,0.1);color:var(--blue);}
172
+ .kpi-bar{position:absolute;bottom:0;left:0;right:0;height:3px;background:rgba(30,120,255,0.06);}
173
+ .kpi-bar-fill{height:100%;background:var(--ac,var(--blue));opacity:.4;transition:width 1.5s var(--ease);}
174
+
175
+ /* ─── GRID 3-COL ─── */
176
+ .row2{display:grid;grid-template-columns:1fr 1fr 340px;gap:16px;}
177
+ .row3{display:grid;grid-template-columns:1fr 320px;gap:16px;}
178
+
179
+ /* ─── PANEL ─── */
180
+ .panel{background:var(--surface);border:1px solid var(--b1);border-radius:var(--r-lg);
181
+ backdrop-filter:blur(12px);}
182
+ .ph{padding:14px 20px;border-bottom:1px solid var(--b1);
183
+ display:flex;align-items:center;justify-content:space-between;}
184
+ .ph-title{font-family:var(--mono);font-size:9px;font-weight:600;letter-spacing:2.5px;
185
+ text-transform:uppercase;color:var(--t2);display:flex;align-items:center;gap:8px;}
186
+ .ph-icon{color:var(--blue);}
187
+ .ph-badge{font-family:var(--mono);font-size:9px;padding:2px 8px;border-radius:4px;
188
+ font-weight:700;letter-spacing:1.5px;}
189
+ .ph-badge.live{background:rgba(0,232,135,0.12);color:var(--green);border:1px solid rgba(0,232,135,.2);}
190
+ .ph-badge.warn{background:rgba(255,58,92,0.12);color:var(--red);}
191
+ .pb{padding:16px 20px;}
192
+
193
+ /* ─── LATENCY MATRIX ─── */
194
+ .lat-row{display:grid;grid-template-columns:155px 1fr 72px;
195
+ align-items:center;gap:12px;padding:7px 4px;
196
+ border-radius:var(--r-sm);transition:background .2s;cursor:default;}
197
+ .lat-row:hover{background:rgba(30,120,255,0.04);}
198
+ .lat-name{font-size:11px;color:var(--t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
199
+ .lat-track{height:3px;background:rgba(30,120,255,0.08);border-radius:2px;overflow:hidden;position:relative;}
200
+ .lat-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,rgba(30,120,255,0.4),var(--cyan));
201
+ transition:width 1.2s var(--ease);}
202
+ .lat-fill.hot{background:linear-gradient(90deg,rgba(255,170,0,0.5),var(--amber));}
203
+ .lat-ms{font-family:var(--mono);font-size:10px;color:var(--cyan);text-align:right;}
204
+
205
+ /* ─── UPTIME HEATMAP ─── */
206
+ .heatmap{display:flex;flex-direction:column;gap:8px;}
207
+ .hm-label{font-family:var(--mono);font-size:8px;color:var(--t4);letter-spacing:1px;}
208
+ .hm-grid{display:flex;gap:2px;}
209
+ .hm-cell{width:10px;height:10px;border-radius:2px;cursor:pointer;transition:transform .15s;}
210
+ .hm-cell:hover{transform:scale(1.3);}
211
+ .hm-legend{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:9px;color:var(--t4);}
212
+ .hm-leg-cell{width:8px;height:8px;border-radius:1px;}
213
+
214
+ /* ─── INCIDENT FEED ─── */
215
+ .inc-list{display:flex;flex-direction:column;gap:8px;max-height:340px;overflow-y:auto;}
216
+ .inc-item{padding:12px 14px;border-radius:var(--r-sm);border:1px solid;position:relative;overflow:hidden;
217
+ transition:transform .2s,border-color .2s;}
218
+ .inc-item:hover{transform:translateX(2px);}
219
+ .inc-item.crit{background:rgba(255,58,92,0.05);border-color:rgba(255,58,92,0.2);}
220
+ .inc-item.warn{background:rgba(255,170,0,0.05);border-color:rgba(255,170,0,0.2);}
221
+ .inc-item::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;}
222
+ .inc-item.crit::before{background:var(--red);}
223
+ .inc-item.warn::before{background:var(--amber);}
224
+ .inc-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:5px;}
225
+ .inc-name{font-size:12px;font-weight:700;}
226
+ .inc-tag{font-size:8px;font-weight:800;letter-spacing:1.5px;padding:2px 7px;
227
+ border-radius:4px;font-family:var(--mono);text-transform:uppercase;}
228
+ .inc-tag.down{background:rgba(255,58,92,0.2);color:var(--red);}
229
+ .inc-tag.warn{background:rgba(255,170,0,0.2);color:var(--amber);}
230
+ .inc-msg{font-size:11px;color:var(--t3);line-height:1.5;}
231
+ .inc-time{display:flex;align-items:center;gap:6px;margin-top:6px;font-size:9px;color:var(--t4);font-family:var(--mono);}
232
+ .inc-elapsed{padding:1px 6px;background:rgba(255,255,255,0.06);border-radius:3px;}
233
+
234
+ /* ─── SERVICE TABLE ─── */
235
+ .svc-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:14px;}
236
+ .svc-search{flex:1;position:relative;}
237
+ .svc-search input{width:100%;background:rgba(30,120,255,0.04);border:1px solid var(--b1);
238
+ border-radius:var(--r-sm);padding:9px 14px 9px 36px;color:var(--t1);
239
+ font-size:12px;font-family:var(--body);outline:none;transition:border .2s;}
240
+ .svc-search input:focus{border-color:var(--b2);}
241
+ .svc-search input::placeholder{color:var(--t4);}
242
+ .svc-search-ico{position:absolute;left:12px;top:50%;transform:translateY(-50%);
243
+ color:var(--t4);font-size:12px;pointer-events:none;}
244
+ .filter-chip{padding:5px 12px;border-radius:20px;font-size:10px;font-weight:600;cursor:pointer;
245
+ border:1px solid var(--b1);background:transparent;color:var(--t3);
246
+ transition:all .2s;font-family:var(--mono);}
247
+ .filter-chip:hover,.filter-chip.sel{border-color:var(--b2);background:rgba(30,120,255,0.1);color:var(--blue);}
248
+ .filter-chip.red.sel{border-color:rgba(255,58,92,.35);background:rgba(255,58,92,0.1);color:var(--red);}
249
+
250
+ .svc-tbl{width:100%;border-collapse:collapse;}
251
+ .svc-tbl thead tr{border-bottom:1px solid var(--b1);}
252
+ .svc-tbl th{font-family:var(--mono);font-size:8px;font-weight:600;letter-spacing:2px;
253
+ text-transform:uppercase;color:var(--t4);padding:7px 12px;text-align:left;cursor:pointer;
254
+ user-select:none;transition:color .2s;white-space:nowrap;}
255
+ .svc-tbl th:hover{color:var(--blue);}
256
+ .svc-tbl th.sort-asc::after{content:' ↑';}
257
+ .svc-tbl th.sort-desc::after{content:' ↓';}
258
+ .svc-tbl tbody tr{border-bottom:1px solid rgba(30,120,255,0.04);
259
+ transition:background .15s;cursor:pointer;}
260
+ .svc-tbl tbody tr:hover{background:rgba(30,120,255,0.04);}
261
+ .svc-tbl td{padding:11px 12px;vertical-align:middle;}
262
+ .td-name{font-size:12px;font-weight:600;margin-bottom:2px;}
263
+ .td-url{font-size:9px;color:var(--t4);font-family:var(--mono);}
264
+ .scope-tag{display:inline-block;padding:2px 8px;border-radius:4px;
265
+ font-size:8px;font-weight:700;letter-spacing:1px;text-transform:uppercase;
266
+ border:1px solid var(--b1);color:var(--t4);font-family:var(--mono);}
267
+ .sla-bar-wrap{width:80px;height:4px;background:rgba(255,255,255,0.06);border-radius:2px;overflow:hidden;}
268
+ .sla-bar{height:100%;border-radius:2px;transition:width 1s var(--ease);}
269
+ .sla-pct{font-family:var(--mono);font-size:9px;color:var(--t3);margin-top:2px;}
270
+
271
+ .spark30{display:flex;gap:1.5px;align-items:flex-end;height:20px;}
272
+ .sp-bar{width:3px;border-radius:1px;transition:height .4s;}
273
+ .sp-up{background:var(--green);opacity:.8;}
274
+ .sp-down{background:var(--red);opacity:.9;}
275
+
276
+ .pill{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;
277
+ border-radius:5px;font-size:9px;font-weight:700;letter-spacing:1px;
278
+ text-transform:uppercase;font-family:var(--mono);}
279
+ .pill.online{background:rgba(0,232,135,0.09);border:1px solid rgba(0,232,135,.2);color:var(--green);}
280
+ .pill.down{background:rgba(255,58,92,0.09);border:1px solid rgba(255,58,92,.2);color:var(--red);}
281
+ .pill.degraded{background:rgba(255,170,0,0.09);border:1px solid rgba(255,170,0,.2);color:var(--amber);}
282
+ .pill-dot{width:4px;height:4px;border-radius:50%;background:currentColor;}
283
+ .pill.down .pill-dot{animation:blink .9s step-start infinite;}
284
+ @keyframes blink{0%,100%{opacity:1;}50%{opacity:.2;}}
285
+
286
+ .lat-td{font-family:var(--mono);font-size:11px;text-align:right;}
287
+
288
+ /* ─── CHART CANVAS ─── */
289
+ .chart-wrap{padding:0 16px 16px;position:relative;}
290
+ canvas{display:block;}
291
+ .chart-yaxis{position:absolute;left:20px;top:0;display:flex;flex-direction:column;
292
+ justify-content:space-between;height:calc(100% - 20px);
293
+ font-family:var(--mono);font-size:8px;color:var(--t4);pointer-events:none;}
294
+ .chart-xaxis{display:flex;justify-content:space-between;padding:4px 4px 0;
295
+ font-family:var(--mono);font-size:8px;color:var(--t4);}
296
+
297
+ /* ─── WORLD MAP (SVG) ─── */
298
+ .world-wrap{padding:12px 16px 14px;position:relative;}
299
+ .map-node{cursor:pointer;transition:r .2s;}
300
+ .map-node circle{transition:r .2s,opacity .2s;}
301
+ .map-node:hover circle{r:6;}
302
+ .node-ring{animation:ping 2s ease-out infinite;}
303
+ @keyframes ping{0%{r:6;opacity:.6;}100%{r:16;opacity:0;}}
304
+
305
+ /* ─── MODAL ─── */
306
+ .modal-back{position:fixed;inset:0;z-index:500;
307
+ background:rgba(1,4,10,.88);backdrop-filter:blur(12px);
308
+ display:none;align-items:center;justify-content:center;}
309
+ .modal-back.open{display:flex;}
310
+ .modal{background:linear-gradient(135deg,rgba(6,14,28,.99),rgba(4,10,22,.99));
311
+ border:1px solid var(--b2);border-radius:var(--r-xl);width:660px;max-width:95vw;
312
+ max-height:90vh;overflow-y:auto;
313
+ box-shadow:0 0 80px rgba(30,100,200,0.25),0 0 200px rgba(30,100,200,0.08);
314
+ animation:mdin .22s var(--ease);}
315
+ @keyframes mdin{from{opacity:0;transform:scale(.96) translateY(14px);}to{opacity:1;transform:none;}}
316
+ .modal-hd{padding:22px 24px 18px;border-bottom:1px solid var(--b1);
317
+ display:flex;align-items:flex-start;justify-content:space-between;position:sticky;top:0;
318
+ background:rgba(6,14,28,.98);z-index:10;}
319
+ .modal-hd-info{}
320
+ .modal-title{font-family:var(--display);font-size:22px;font-weight:700;}
321
+ .modal-meta{display:flex;align-items:center;gap:10px;margin-top:5px;}
322
+ .modal-url{font-family:var(--mono);font-size:10px;color:var(--t4);}
323
+ .modal-status-chip{display:inline-flex;align-items:center;gap:5px;
324
+ padding:3px 10px;border-radius:5px;font-family:var(--mono);font-size:9px;font-weight:700;letter-spacing:1px;}
325
+ .mclose{width:30px;height:30px;border-radius:var(--r-sm);border:1px solid var(--b1);
326
+ background:transparent;color:var(--t3);cursor:pointer;font-size:14px;
327
+ display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;}
328
+ .mclose:hover{border-color:var(--red);color:var(--red);}
329
+ .modal-tabs{display:flex;gap:0;border-bottom:1px solid var(--b1);}
330
+ .mtab{padding:11px 20px;font-size:11px;font-weight:600;cursor:pointer;border:none;
331
+ background:transparent;color:var(--t3);border-bottom:2px solid transparent;
332
+ transition:all .2s;font-family:var(--body);}
333
+ .mtab:hover{color:var(--t1);}
334
+ .mtab.act{color:var(--blue);border-bottom-color:var(--blue);}
335
+ .mtab-panel{padding:20px 24px;display:none;}
336
+ .mtab-panel.act{display:block;}
337
+ .modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
338
+ .m-card{background:rgba(30,120,255,0.03);border:1px solid var(--b1);
339
+ border-radius:var(--r-md);padding:14px 16px;}
340
+ .m-card-title{font-family:var(--mono);font-size:8px;font-weight:600;letter-spacing:2px;
341
+ text-transform:uppercase;color:var(--t3);margin-bottom:12px;
342
+ display:flex;align-items:center;gap:6px;}
343
+ .mc-icon{font-size:11px;}
344
+ .m-stat{font-family:var(--display);font-size:28px;font-weight:700;color:var(--t1);}
345
+ .m-stat-unit{font-size:14px;font-weight:400;color:var(--t3);}
346
+ .m-stat-sub{font-size:10px;color:var(--t4);font-family:var(--mono);margin-top:4px;}
347
+ .nominal{display:flex;flex-direction:column;align-items:center;justify-content:center;
348
+ height:70px;gap:6px;}
349
+ .nominal-ico{font-size:22px;}
350
+ .nominal-txt{font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:2px;color:var(--green);}
351
+ .incident-txt{font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:1px;color:var(--red);}
352
+
353
+ /* history table */
354
+ .h-tbl{width:100%;border-collapse:collapse;}
355
+ .h-tbl th{font-family:var(--mono);font-size:8px;letter-spacing:2px;text-transform:uppercase;
356
+ color:var(--t4);padding:6px 10px;text-align:left;border-bottom:1px solid var(--b1);}
357
+ .h-tbl td{padding:8px 10px;font-size:11px;border-bottom:1px solid rgba(30,120,255,0.04);}
358
+ .h-tbl td:last-child{font-family:var(--mono);font-size:10px;}
359
+
360
+ /* ─── TOAST ─── */
361
+ #toasts{position:fixed;bottom:20px;right:20px;z-index:999999;
362
+ display:flex;flex-direction:column;gap:8px;align-items:flex-end;}
363
+ .toast{padding:12px 16px;border-radius:var(--r-md);
364
+ background:#0F172A;border:1px solid #334155;
365
+ box-shadow:0 12px 36px rgba(0,0,0,.6);
366
+ display:flex;align-items:center;gap:10px;
367
+ font-size:12px;min-width:280px;max-width:440px;
368
+ animation:toast-in .3s var(--ease);cursor:pointer;color:#FFFFFF;}
369
+ .toast.exit{animation:toast-out .3s var(--ease) forwards;}
370
+ @keyframes toast-in{from{opacity:0;transform:translateX(20px);}to{opacity:1;transform:none;}}
371
+ @keyframes toast-out{from{opacity:1;transform:none;}to{opacity:0;transform:translateX(20px);}}
372
+ .toast-ico{font-size:16px;flex-shrink:0;}
373
+ .toast-body{flex:1;}
374
+ .toast-title{font-weight:700;font-size:12px;color:#FFFFFF;}
375
+ .toast-msg{font-size:11px;color:#94A3B8;margin-top:2px;line-height:1.45;word-break:break-word;}
376
+ .toast.err{border-color:rgba(255,58,92,.6);background:#1E1018;}
377
+ .toast.ok{border-color:rgba(0,232,135,.6);background:#091A14;}
378
+ .toast.info{border-color:#334155;background:#0F172A;}
379
+
380
+ /* ─── LOADING OVERLAY ─── */
381
+ #loader{position:fixed;inset:0;z-index:9999;background:var(--bg0);
382
+ display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;
383
+ transition:opacity .5s;}
384
+ #loader.hide{opacity:0;pointer-events:none;}
385
+ .loader-logo{font-family:var(--display);font-size:32px;font-weight:800;letter-spacing:4px;color:var(--t1);}
386
+ .loader-bar{width:200px;height:2px;background:rgba(30,120,255,0.15);border-radius:1px;overflow:hidden;}
387
+ .loader-fill{height:100%;background:linear-gradient(90deg,var(--blue),var(--cyan));
388
+ border-radius:1px;animation:load-bar 1.4s var(--ease) forwards;}
389
+ @keyframes load-bar{from{width:0;}to{width:100%;}}
390
+ .loader-txt{font-family:var(--mono);font-size:10px;color:var(--t4);letter-spacing:2px;}
391
+
392
+ /* ─── ANIM ─── */
393
+ .fade-up{animation:fu .5s var(--ease) both;}
394
+ @keyframes fu{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:none;}}
395
+ .fade-up:nth-child(1){animation-delay:.06s;}
396
+ .fade-up:nth-child(2){animation-delay:.12s;}
397
+ .fade-up:nth-child(3){animation-delay:.18s;}
398
+ .fade-up:nth-child(4){animation-delay:.24s;}
399
+
400
+ /* ─── ADDITIONS FOR OPERATIONS AND TEST LAB ─── */
401
+ .ops-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
402
+ .ops-grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
403
+ .input-group { margin-bottom: 12px; }
404
+ .input-group label { display: block; font-family: var(--mono); font-size: 9px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; color: var(--t3); margin-bottom: 6px; }
405
+ .pro-input { width: 100%; background: rgba(30,120,255,0.04); border: 1px solid var(--b1); border-radius: var(--r-sm); padding: 10px 14px; color: var(--t1); font-size: 13px; font-family: var(--body); outline: none; transition: border .2s; }
406
+ .pro-input:focus { border-color: var(--b2); }
407
+ .pro-input::placeholder { color: var(--t4); }
408
+ .btn-primary { width: 100%; padding: 10px; background: rgba(30,120,255,0.12); border: 1px solid var(--b2); border-radius: var(--r-sm); color: var(--blue); font-size: 12px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: 0.2s; }
409
+ .btn-primary:hover { background: rgba(30,120,255,0.2); }
410
+ .btn-danger-outline { width: 100%; padding: 8px; background: rgba(255,58,92,0.05); border: 1px solid rgba(255,58,92,0.2); border-radius: var(--r-sm); color: var(--red); font-size: 11px; font-weight: 600; cursor: pointer; transition: 0.2s; margin-top:10px; }
411
+ .btn-danger-outline:hover { background: rgba(255,58,92,0.1); }
412
+ .manage-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; background: rgba(30,120,255,0.02); border: 1px solid var(--b1); border-radius: var(--r-sm); margin-bottom: 6px; }
413
+ .manage-item-info b { font-size: 12px; color: var(--t1); }
414
+ .manage-item-info span { font-size: 10px; color: var(--t4); font-family: var(--mono); display: block; margin-top:2px; }
415
+ .btn-remove { padding: 4px 10px; background: rgba(255,58,92,0.08); border: 1px solid rgba(255,58,92,0.15); border-radius: 4px; color: var(--red); font-size: 10px; font-weight: 600; cursor: pointer; transition: 0.2s; font-family:var(--mono); text-transform:uppercase; letter-spacing:1px; }
416
+ .btn-remove:hover { background: var(--red); color: #fff; }
417
+
418
+ /* ─── AUTH OVERLAY ─── */
419
+ .auth-overlay { position: fixed; inset: 0; background: rgba(2,6,14,0.95); display: flex; justify-content: center; align-items: center; z-index: 99999; backdrop-filter: blur(10px); }
420
+ .auth-box { width: 400px; max-width: 90%; background: var(--surface); border: 1px solid var(--b2); border-radius: var(--r-xl); padding: 32px; text-align: center; box-shadow: 0 20px 60px rgba(30,120,255,0.1); }
421
+ .auth-box .s-logo { margin: 0 auto 20px; }
422
+ .auth-box h3 { font-family: var(--display); font-size: 22px; font-weight: 700; margin-bottom: 8px; }
423
+ .auth-box p { font-size: 12px; color: var(--t3); margin-bottom: 24px; }
424
+
425
+ /* ─── TOOLTIPS (HIGH CONTRAST & LEGIBILITY) ─── */
426
+ .tip {
427
+ display: inline-flex; align-items: center; justify-content: center;
428
+ width: 15px; height: 15px; border-radius: 50%;
429
+ background: var(--cyan-glow); color: var(--cyan);
430
+ font-size: 10px; font-weight: 800; cursor: help;
431
+ position: relative; margin-left: 6px; border: 1px solid var(--b2);
432
+ font-family: var(--mono); vertical-align: middle;
433
+ }
434
+ .tip:hover::after {
435
+ content: attr(data-tip);
436
+ position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
437
+ background: #0F172A; color: #FFFFFF;
438
+ padding: 8px 12px; border-radius: var(--r-sm); font-size: 11px; font-weight: 500;
439
+ font-family: var(--body);
440
+ white-space: normal; z-index: 10000; border: 1px solid #334155;
441
+ box-shadow: 0 12px 32px rgba(0,0,0,0.6); pointer-events: none;
442
+ width: max-content; max-width: 280px; line-height: 1.45; text-transform: none; letter-spacing: normal; text-align: left;
443
+ }
444
+
445
+ /* ─── TEST LAB DIAGNOSTIC SUITE ─── */
446
+ .testlab-grid { display: grid; grid-template-columns: 380px 1fr; gap: 16px; width: 100%; }
447
+ .console-box {
448
+ background: #030712; border: 1px solid var(--b2); border-radius: var(--r-md);
449
+ padding: 14px 16px; font-family: var(--mono); font-size: 11px;
450
+ min-height: 240px; max-height: 320px; overflow-y: auto;
451
+ box-shadow: inset 0 2px 10px rgba(0,0,0,0.8);
452
+ }
453
+ .console-line { line-height: 1.6; margin-bottom: 4px; word-break: break-all; }
454
+ .console-line.text-dim { color: #64748B; }
455
+ .console-line.text-ok { color: #10B981; font-weight: 600; }
456
+ .console-line.text-warn { color: #F59E0B; }
457
+ .console-line.text-err { color: #EF4444; font-weight: 700; }
458
+ .console-line.text-cyan { color: #00D4FF; }
459
+ .testlab-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
460
+ .t-kpi { background: var(--surface); border: 1px solid var(--b1); border-radius: var(--r-md); padding: 12px 14px; backdrop-filter: blur(12px); }
461
+ .t-kpi-label { font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: 1.5px; color: var(--t3); margin-bottom: 4px; }
462
+ .t-kpi-val { font-family: var(--display); font-size: 20px; font-weight: 700; color: var(--t1); }
463
+
templates/dashboard_v5.html ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>StatusPilot — Operations Center</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
10
+ <link rel="stylesheet" href="/static/style_v5.css">
11
+ <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
12
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
13
+ </head>
14
+ <body>
15
+
16
+ <!-- ===== AUTH OVERLAY ===== -->
17
+ <div id="master-auth-overlay" class="auth-overlay">
18
+ <div class="auth-box card" style="padding:2rem;">
19
+ <div class="sidebar-logo" style="justify-content:center; margin-bottom:1.2rem; padding:0;">
20
+ <div class="icon"><i class="fas fa-shield-halved"></i><span class="dot"></span></div>
21
+ <span>STATUSPILOT</span>
22
+ </div>
23
+ <h3 style="font-weight:800; font-size:1rem; margin-bottom:6px;">Restricted Access</h3>
24
+ <p style="color:var(--text-secondary); font-size:0.78rem; margin-bottom:1.2rem;">Authenticate with your Master Key</p>
25
+ <input type="password" id="master-key-input" class="pro-input" placeholder="••••••••••••" style="text-align:center; letter-spacing:3px; margin-bottom:10px;">
26
+ <button id="master-login-btn" class="btn-primary"><i class="fas fa-lock-open"></i> Unlock</button>
27
+ </div>
28
+ </div>
29
+
30
+ <!-- ===== LICENSE OVERLAY ===== -->
31
+ <div id="license-overlay" class="auth-overlay" style="display:none;">
32
+ <div class="auth-box card" style="padding:2rem;">
33
+ <div class="sidebar-logo" style="justify-content:center; margin-bottom:1.2rem; padding:0;">
34
+ <div class="icon"><i class="fas fa-gem" style="color:#a78bfa;"></i></div>
35
+ <span style="color:#a78bfa;">STATUSPILOT PRO</span>
36
+ </div>
37
+ <h3 style="font-weight:800; font-size:1rem; margin-bottom:6px;">Enterprise Activation</h3>
38
+ <p style="color:var(--text-secondary); font-size:0.78rem; margin-bottom:1.2rem;">Enter your license credentials</p>
39
+ <input type="email" id="lic-email" class="pro-input" placeholder="Purchase Email" style="margin-bottom:8px;">
40
+ <input type="text" id="lic-key" class="pro-input" placeholder="License Key (SG-XXXX-XXXX)" style="text-transform:uppercase; margin-bottom:10px;">
41
+ <button id="activate-btn" class="btn-primary" style="background:linear-gradient(135deg,#a78bfa,#8b5cf6);">Activate</button>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- ===== ANALYTICS MODAL ===== -->
46
+ <div id="matrix-modal" class="modal-overlay">
47
+ <div class="modal-box">
48
+ <div class="modal-header">
49
+ <div>
50
+ <h2 id="modal-title">Service Analytics</h2>
51
+ <p id="modal-subtitle">Historical performance data</p>
52
+ </div>
53
+ <button class="modal-close" id="modal-close-btn">&times;</button>
54
+ </div>
55
+ <div class="modal-grid">
56
+ <div class="modal-card">
57
+ <h4 style="color:var(--accent);"><i class="fas fa-check-circle"></i> Availability <span class="tip" data-tip="Shows UP/DOWN status over time. 100 = UP, 0 = DOWN.">?</span></h4>
58
+ <canvas id="uptimeChart"></canvas>
59
+ </div>
60
+ <div class="modal-card">
61
+ <h4 style="color:var(--primary);"><i class="fas fa-bolt"></i> Latency (ms) <span class="tip" data-tip="Response time in milliseconds. Lower = faster. Above 500ms = slow.">?</span></h4>
62
+ <canvas id="latencyChart"></canvas>
63
+ </div>
64
+ <div class="modal-card">
65
+ <h4 style="color:var(--warning);"><i class="fas fa-chart-pie"></i> Error Profile <span class="tip" data-tip="Ratio of successful vs failed checks. Green = OK, Red = Issues.">?</span></h4>
66
+ <div style="height:130px; display:flex; justify-content:center;"><canvas id="errorChart"></canvas></div>
67
+ </div>
68
+ <div class="modal-card">
69
+ <h4 style="color:var(--danger);"><i class="fas fa-exclamation-triangle"></i> Incidents <span class="tip" data-tip="Log of outages with timestamps and error messages.">?</span></h4>
70
+ <div id="incident-log" style="max-height:130px; overflow-y:auto;"></div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ </div>
75
+
76
+ <!-- ===== MAIN APP ===== -->
77
+ <div class="app" id="main-app" style="filter:blur(15px); opacity:0; pointer-events:none;">
78
+
79
+ <!-- Sidebar -->
80
+ <aside class="sidebar">
81
+ <div class="sidebar-logo">
82
+ <div class="icon"><i class="fas fa-shield-halved"></i><span class="dot"></span></div>
83
+ <span>STATUSPILOT</span>
84
+ </div>
85
+ <nav class="sidebar-nav">
86
+ <a href="#" class="active" id="nav-dashboard"><i class="fas fa-gauge-high"></i> Dashboard</a>
87
+ <a href="#" id="nav-test"><i class="fas fa-vial"></i> Test Lab</a>
88
+ <a href="#" id="nav-config"><i class="fas fa-sliders"></i> Operations</a>
89
+ </nav>
90
+ <div class="sidebar-footer">
91
+ <div class="sidebar-status">
92
+ <div class="label">System Status</div>
93
+ <div class="value"><span class="pulse"></span> All Systems Online</div>
94
+ </div>
95
+ </div>
96
+ </aside>
97
+
98
+ <!-- Main -->
99
+ <div class="main">
100
+ <!-- Top Bar -->
101
+ <div class="topbar">
102
+ <div class="topbar-left">
103
+ <h1>Operations Center</h1>
104
+ <div class="live-dot"></div>
105
+ <span class="clock" id="live-clock">--:--:--</span>
106
+ </div>
107
+ <div class="topbar-right">
108
+ <div class="btn-outline" style="padding:5px 10px;">
109
+ <i class="fas fa-globe" style="color:var(--primary); font-size:0.7rem;"></i>
110
+ <select id="location-filter" style="background:transparent; border:none; color:var(--text-secondary); font-size:0.72rem; font-weight:600; outline:none; cursor:pointer;"></select>
111
+ </div>
112
+ <button id="download-excel-btn" class="btn-outline"><i class="fas fa-file-export"></i> Export</button>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="content">
117
+
118
+ <!-- ===== DASHBOARD TAB ===== -->
119
+ <div id="section-dashboard" class="content-section fade-in">
120
+
121
+ <!-- Latency Matrix -->
122
+ <div class="card latency-section" style="margin-bottom:1rem;">
123
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.8rem;">
124
+ <div class="card-title" style="margin-bottom:0;">
125
+ <i class="fas fa-bolt" style="color:var(--primary);"></i>
126
+ Global Latency Matrix
127
+ <span class="tip" data-tip="Real-time response time comparison across all services. Bars show latency in milliseconds — shorter is faster.">?</span>
128
+ </div>
129
+ <div class="live-badge"><span class="pulse"></span> LIVE</div>
130
+ </div>
131
+ <div id="latency-matrix-bars"></div>
132
+ </div>
133
+
134
+ <!-- KPI Stats -->
135
+ <div class="stats-grid">
136
+ <div class="card">
137
+ <div class="card-title"><div class="dot" style="background:var(--primary);"></div> Active Monitors <span class="tip" data-tip="Total services, websites, and SSL certs being tracked.">?</span></div>
138
+ <div class="stat-value" id="stat-monitors">0</div>
139
+ </div>
140
+ <div class="card">
141
+ <div class="card-title"><div class="dot" style="background:var(--accent);"></div> Global Uptime <span class="tip" data-tip="Average availability across all monitors. 100% = zero downtime.">?</span></div>
142
+ <div class="stat-value" style="color:var(--accent);" id="stat-uptime">0%</div>
143
+ </div>
144
+ <div class="card">
145
+ <div class="card-title"><div class="dot" style="background:var(--danger);"></div> Active Incidents <span class="tip" data-tip="Services currently DOWN. 0 = all systems operational.">?</span></div>
146
+ <div class="stat-value" style="color:var(--danger);" id="stat-incidents">0</div>
147
+ </div>
148
+ <div class="card">
149
+ <div class="card-title"><div class="dot" style="background:var(--primary);"></div> Avg. Latency <span class="tip" data-tip="Average response time in ms. Below 200ms = excellent.">?</span></div>
150
+ <div class="stat-value" id="stat-latency">0ms</div>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- Search -->
155
+ <div class="search-wrap">
156
+ <i class="fas fa-search"></i>
157
+ <input type="text" id="search-services" class="search-bar" placeholder="Search services..." style="padding-left:2rem;">
158
+ </div>
159
+
160
+ <!-- Service List -->
161
+ <div class="svc-list" id="status-grid">
162
+ <div style="text-align:center; padding:2rem; color:var(--text-dim);">
163
+ <i class="fas fa-circle-notch fa-spin"></i>
164
+ <p style="margin-top:0.6rem; font-size:0.8rem; font-weight:600;">Connecting to Infrastructure...</p>
165
+ </div>
166
+ </div>
167
+ </div>
168
+
169
+ <!-- ===== TEST LAB TAB ===== -->
170
+ <div id="section-test" class="content-section fade-in" style="display:none;">
171
+ <div class="card" style="max-width:600px;">
172
+ <div class="card-title"><i class="fas fa-vial" style="color:var(--primary);"></i> Manual Diagnostics</div>
173
+ <div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px;">
174
+ <div class="input-group">
175
+ <label>Target <span class="tip" data-tip="Select which service to test.">?</span></label>
176
+ <select id="test-tool-select" class="pro-input"></select>
177
+ </div>
178
+ <div class="input-group">
179
+ <label>Node <span class="tip" data-tip="Test server location.">?</span></label>
180
+ <select id="test-location-select" class="pro-input">
181
+ <option>Global Anycast</option><option>US East</option><option>EU Central</option>
182
+ </select>
183
+ </div>
184
+ </div>
185
+ <button id="run-test-btn" class="btn-primary"><i class="fas fa-satellite-dish"></i> Execute Trace</button>
186
+ <div id="test-results" style="margin-top:1rem;"></div>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- ===== OPERATIONS TAB ===== -->
191
+ <div id="section-config" class="content-section fade-in" style="display:none;">
192
+
193
+ <!-- M365 Monitor -->
194
+ <div class="card" style="margin-bottom:1rem; border-color:rgba(14,165,233,0.2);">
195
+ <div class="card-title"><i class="fab fa-microsoft" style="color:var(--primary);"></i> M365 Email Monitoring <span class="tip" data-tip="Monitors Microsoft 365 mail flow by sending a test email and verifying delivery. Uses App Password (not your main password). Credentials stored only on your server.">?</span></div>
196
+ <div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin-bottom:10px;">
197
+ <div class="input-group"><label>Sender <span class="tip" data-tip="M365 email that sends test pulses.">?</span></label><input type="email" id="m365-sender" class="pro-input" placeholder="alerts@company.com"></div>
198
+ <div class="input-group"><label>App Password <span class="tip" data-tip="Generate from Microsoft account security settings. NOT your main password. Can be revoked anytime.">?</span></label><input type="password" id="m365-pass" class="pro-input" placeholder="••••••••"></div>
199
+ <div class="input-group"><label>Recipient <span class="tip" data-tip="Email that receives test pulses. Can be same as sender.">?</span></label><input type="email" id="m365-recipient" class="pro-input" placeholder="verify@company.com"></div>
200
+ </div>
201
+ <button id="activate-m365-btn" class="btn-primary"><i class="fas fa-paper-plane"></i> Activate Mail Flow Monitor</button>
202
+ </div>
203
+
204
+ <div class="ops-grid">
205
+ <!-- Infrastructure List -->
206
+ <div class="card">
207
+ <div class="card-title"><i class="fas fa-server" style="color:var(--accent);"></i> Active Infrastructure <span class="tip" data-tip="All monitored services. Click REMOVE to decommission.">?</span></div>
208
+ <div id="manage-svc-list" style="max-height:300px; overflow-y:auto;"></div>
209
+ <button id="purge-stale-btn" class="btn-danger-outline" style="margin-top:8px;"><i class="fas fa-broom"></i> Purge Stale Data</button>
210
+ </div>
211
+ <!-- Add New -->
212
+ <div class="card">
213
+ <div class="card-title"><i class="fas fa-plus-circle" style="color:var(--warning);"></i> New Node <span class="tip" data-tip="Add a website, API, or SSL cert to monitor.">?</span></div>
214
+ <div class="input-group"><label>Name</label><input type="text" id="new-svc-name" class="pro-input" placeholder="Service Name"></div>
215
+ <div class="input-group"><label>Location</label><input type="text" id="new-svc-loc" class="pro-input" placeholder="Global"></div>
216
+ <div class="input-group"><label>URL</label><input type="url" id="new-svc-url" class="pro-input" placeholder="https://example.com"></div>
217
+ <div class="input-group"><label>Type</label><select id="new-svc-type" class="pro-input"><option value="http_code">🌐 Website</option><option value="ssl">🔒 SSL</option><option value="json_status">⚙️ API</option></select></div>
218
+ <button id="add-svc-btn" class="btn-primary" style="background:linear-gradient(135deg,var(--warning),#d97706);"><i class="fas fa-bolt"></i> Activate</button>
219
+ </div>
220
+ </div>
221
+
222
+ <!-- Alert Config -->
223
+ <div class="card" style="margin-top:1rem;">
224
+ <div class="card-title"><i class="fas fa-bell"></i> Alert Configuration <span class="tip" data-tip="Configure how you get notified when services go DOWN or come back UP. SMTP = cloud email, Outlook = local desktop.">?</span></div>
225
+ <div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px;">
226
+ <div class="input-group"><label>Mode <span class="tip" data-tip="SMTP = Cloud relay. Outlook = Local bot.">?</span></label><select id="alert-mode" class="pro-input"><option value="SMTP">SMTP (Cloud)</option><option value="Outlook">Local Outlook</option></select></div>
227
+ <div class="input-group"><label>Interval <span class="tip" data-tip="Check frequency in seconds. 60 = once per minute.">?</span></label><input type="number" id="check-interval" class="pro-input" placeholder="60"></div>
228
+ <div class="input-group"><label>SMTP User <span class="tip" data-tip="Email address used to send alerts.">?</span></label><input type="email" id="smtp-user" class="pro-input" placeholder="alerts@co.com"></div>
229
+ </div>
230
+ <div class="input-group" style="margin-top:6px;"><label>SMTP Password <span class="tip" data-tip="App password for the SMTP sender.">?</span></label><input type="password" id="smtp-pass" class="pro-input" placeholder="••••••••"></div>
231
+ <button id="save-settings-btn" class="btn-primary" style="margin-top:8px; background:linear-gradient(135deg,#6366f1,#8b5cf6);"><i class="fas fa-save"></i> Save Configuration</button>
232
+ </div>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Footer -->
237
+ <div style="text-align:center; padding:1rem; font-size:0.6rem; color:var(--text-dim);">
238
+ STATUSPILOT Operations Center • <span id="footer-year"></span> • Real-time monitoring dashboard
239
+ </div>
240
+ </div>
241
+ </div>
242
+
243
+ <script>document.getElementById('footer-year').textContent = new Date().getFullYear();</script>
244
+ <script src="/static/script_v5.js"></script>
245
+ </body>
246
+ </html>
templates/dashboard_v6.html ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>StatusPilot — Operations Center</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=Barlow+Condensed:wght@300;400;500;600;700;800&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
8
+ <link rel="stylesheet" href="/static/style_v6.css?v=5.1.8">
9
+ <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
11
+ </head>
12
+ <body>
13
+
14
+ <!-- AUTH OVERLAYS -->
15
+ <div id="master-auth-overlay" class="auth-overlay" style="display:none;">
16
+ <div class="auth-box">
17
+ <div class="s-logo">SP</div>
18
+ <h3>Restricted Access</h3>
19
+ <p>Authenticate with your Master Key</p>
20
+ <input type="password" id="master-key-input" class="pro-input" placeholder="••••••••••••" style="text-align:center; letter-spacing:3px; margin-bottom:16px;">
21
+ <button id="master-login-btn" class="btn-primary">Unlock</button>
22
+ </div>
23
+ </div>
24
+
25
+ <div id="license-overlay" class="auth-overlay" style="display:none;">
26
+ <div class="auth-box">
27
+ <div class="s-logo" style="background:linear-gradient(135deg,#9B6DFF,#5a3bd8);">PRO</div>
28
+ <h3>Enterprise Activation</h3>
29
+ <p>Enter your license credentials</p>
30
+ <input type="email" id="lic-email" class="pro-input" placeholder="Purchase Email" style="margin-bottom:12px;">
31
+ <input type="text" id="lic-key" class="pro-input" placeholder="License Key (SG-XXXX-XXXX)" style="text-transform:uppercase; margin-bottom:16px;">
32
+ <button id="activate-btn" class="btn-primary" style="background:rgba(155,109,255,0.15); border-color:var(--violet); color:var(--violet);">Activate</button>
33
+ </div>
34
+ </div>
35
+
36
+ <!-- LOADER -->
37
+ <div id="loader">
38
+ <div class="loader-logo">STATUSPILOT</div>
39
+ <div class="loader-bar"><div class="loader-fill"></div></div>
40
+ <div class="loader-txt">CONNECTING TO INFRASTRUCTURE...</div>
41
+ </div>
42
+
43
+ <!-- BG -->
44
+ <div id="bg-grid"></div>
45
+ <div id="bg-glow"></div>
46
+
47
+ <!-- SIDEBAR -->
48
+ <nav class="sidebar">
49
+ <div class="s-logo">SP</div>
50
+ <button class="s-btn act" title="Dashboard" onclick="switchMainTab('dashboard', this)">
51
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
52
+ </button>
53
+ <button class="s-btn" title="Test Lab" onclick="switchMainTab('testlab', this)">
54
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3v11l-4 5h14l-4-5V3"/><path d="M6 3h12"/></svg>
55
+ </button>
56
+ <button class="s-btn" title="Operations" onclick="switchMainTab('config', this)">
57
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
58
+ </button>
59
+ <div class="s-spacer"></div>
60
+ <div class="s-avatar" title="System Status: Online" style="background:var(--green-glow); border-color:var(--green); color:var(--green);">OK</div>
61
+ </nav>
62
+
63
+ <!-- APP -->
64
+ <div class="app" id="main-app" style="filter:blur(15px); opacity:0; pointer-events:none;">
65
+ <!-- ALERT BANNER -->
66
+ <div class="alert-banner" id="alert-banner">
67
+ <div class="alert-banner-dot"></div>
68
+ <strong>ACTIVE INCIDENTS</strong>
69
+ <div class="alert-banner-sep"></div>
70
+ <span class="alert-banner-text" id="alert-banner-text">Loading incidents...</span>
71
+ </div>
72
+
73
+ <!-- HEADER -->
74
+ <header class="header">
75
+ <div class="h-left">
76
+ <div>
77
+ <div class="h-title" style="display:flex; align-items:center; gap:12px;">
78
+ <img src="data:image/png;base64,{{ logo_b64 }}" height="38" alt="PARLE TECH" style="height:38px; width:auto; vertical-align:middle;" />
79
+ <span style="opacity:0.3; font-weight:300;">|</span>
80
+ <span>Operations Center</span>
81
+ </div>
82
+ <div class="h-sub">STATUSPILOT PRO · ENTERPRISE NOC</div>
83
+ </div>
84
+ <div class="live-chip"><span class="live-chip-dot"></span>LIVE</div>
85
+ <button class="theme-btn" id="theme-btn" onclick="cycleTheme()" title="Switch Dashboard Theme (Dark Pro / Light Mode / Cyber Pulse)">
86
+ <span id="theme-icon">🌙</span> <span id="theme-label">Dark Pro</span>
87
+ </button>
88
+ </div>
89
+ <div class="h-right">
90
+ <div style="display:flex; align-items:center; gap:6px;">
91
+ <div class="clock-box" id="clock">--:--:-- UTC</div>
92
+ <select id="tzSel" onchange="" title="Select Timezone" style="background:rgba(255,255,255,0.07); border:1px solid rgba(255,255,255,0.12); color:var(--t2); font-size:10px; font-family:var(--mono); padding:4px 6px; border-radius:6px; cursor:pointer; outline:none; letter-spacing:0.5px;">
93
+ <option value="UTC" data-label="UTC" selected>🌐 UTC</option>
94
+ <option value="America/New_York" data-label="EST">🇺🇸 EST (New York)</option>
95
+ <option value="America/Chicago" data-label="CST">🇺🇸 CST (Chicago)</option>
96
+ <option value="America/Denver" data-label="MST">🇺🇸 MST (Denver)</option>
97
+ <option value="America/Los_Angeles" data-label="PST">🇺🇸 PST (Los Angeles)</option>
98
+ <option value="Europe/London" data-label="GMT">🇬🇧 GMT (London)</option>
99
+ <option value="Europe/Paris" data-label="CET">🇪🇺 CET (Paris)</option>
100
+ <option value="Asia/Dubai" data-label="GST">🇦🇪 GST (Dubai)</option>
101
+ <option value="Asia/Kolkata" data-label="IST">🇮🇳 IST (India)</option>
102
+ <option value="Asia/Singapore" data-label="SGT">🇸🇬 SGT (Singapore)</option>
103
+ <option value="Asia/Tokyo" data-label="JST">🇯🇵 JST (Tokyo)</option>
104
+ <option value="Australia/Sydney" data-label="AEST">🇦🇺 AEST (Sydney)</option>
105
+ </select>
106
+ </div>
107
+ <select class="region-sel" id="regionSel" onchange="filterRegion()">
108
+ <option value="all">🌐 All Regions</option>
109
+ </select>
110
+ <button class="hbtn hbtn-ghost" onclick="exportCSV()">
111
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
112
+ Export CSV
113
+ </button>
114
+ <button class="hbtn hbtn-prim" onclick="switchMainTab('testlab', document.querySelectorAll('.s-btn')[1])">
115
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
116
+ Diagnostics
117
+ </button>
118
+ </div>
119
+ </header>
120
+
121
+ <!-- DASHBOARD TAB -->
122
+ <main class="main-tab act" id="tab-dashboard">
123
+ <!-- KPIs -->
124
+ <div class="kpi-row">
125
+ <div class="kpi fade-up" style="--ac:var(--blue)">
126
+ <div class="kpi-accent-line"></div>
127
+ <div class="kpi-icon">📡</div>
128
+ <div class="kpi-label">Active Monitors <span class="tip" data-tip="Total number of nodes, sites, and APIs currently being tracked by StatusPilot.">?</span></div>
129
+ <div class="kpi-val" style="--vc:var(--blue)" id="kpi-mon">0</div>
130
+ <div class="kpi-delta neutral">↔ Stable Tracking</div>
131
+ <div class="kpi-bar"><div class="kpi-bar-fill" style="width:100%"></div></div>
132
+ </div>
133
+ <div class="kpi fade-up" style="--ac:var(--green)">
134
+ <div class="kpi-accent-line"></div>
135
+ <div class="kpi-icon">📶</div>
136
+ <div class="kpi-label">Global Uptime <span class="tip" data-tip="Average availability across all active monitors. 100% means zero downtime.">?</span></div>
137
+ <div class="kpi-val" style="--vc:var(--green)" id="kpi-up">100%</div>
138
+ <div class="kpi-delta up" id="kpi-up-delta">↑ 100%</div>
139
+ <div class="kpi-bar"><div class="kpi-bar-fill" id="kpi-up-bar" style="width:100%;background:var(--green);opacity:.5"></div></div>
140
+ </div>
141
+ <div class="kpi fade-up" id="kpi-inc-card" style="--ac:var(--green)">
142
+ <div class="kpi-accent-line"></div>
143
+ <div class="kpi-icon">🚨</div>
144
+ <div class="kpi-label">Active Incidents <span class="tip" data-tip="Number of services currently offline or failing checks. 0 means all systems operational.">?</span></div>
145
+ <div class="kpi-val" style="--vc:var(--green)" id="kpi-inc">0</div>
146
+ <div class="kpi-delta up" id="kpi-inc-desc">All systems operational</div>
147
+ <div class="kpi-bar"><div class="kpi-bar-fill" id="kpi-inc-bar" style="width:100%;background:var(--green);opacity:.5"></div></div>
148
+ </div>
149
+ <div class="kpi fade-up" style="--ac:var(--amber)">
150
+ <div class="kpi-accent-line"></div>
151
+ <div class="kpi-icon">⚡</div>
152
+ <div class="kpi-label">Avg. Latency <span class="tip" data-tip="Real-time average response time in milliseconds across all infrastructure.">?</span></div>
153
+ <div class="kpi-val" style="--vc:var(--amber)" id="kpi-lat">0ms</div>
154
+ <div class="kpi-delta neutral" id="kpi-lat-desc">Live Average</div>
155
+ <div class="kpi-bar"><div class="kpi-bar-fill" id="kpi-lat-bar" style="width:50%;background:var(--amber);opacity:.5"></div></div>
156
+ </div>
157
+ </div>
158
+
159
+ <!-- ROW 2 -->
160
+ <div class="row2">
161
+ <div class="panel">
162
+ <div class="ph">
163
+ <div class="ph-title"><span class="ph-icon">⚡</span>Latency Trend (Live) <span class="tip" data-tip="Continuous 60-minute moving average of global response times compared against SLA threshold.">?</span></div>
164
+ <div class="ph-badge live">● LIVE</div>
165
+ </div>
166
+ <div class="chart-wrap" style="padding:16px 16px 8px">
167
+ <canvas id="trendChart" height="110"></canvas>
168
+ <div class="chart-xaxis" id="trendXAxis"></div>
169
+ </div>
170
+ </div>
171
+
172
+ <div class="panel">
173
+ <div class="ph">
174
+ <div class="ph-title"><span class="ph-icon">📅</span>Uptime Heatmap <span class="tip" data-tip="Historical 90-day availability matrix. Dark green is 100% uptime, red indicates outages.">?</span></div>
175
+ <div style="font-family:var(--mono);font-size:9px;color:var(--t4)">Per service · history</div>
176
+ </div>
177
+ <div class="pb">
178
+ <div class="heatmap" id="heatmap"></div>
179
+ <div class="hm-legend" style="margin-top:10px">
180
+ <span>Less</span>
181
+ <div class="hm-leg-cell" style="background:rgba(0,232,135,0.15)"></div>
182
+ <div class="hm-leg-cell" style="background:rgba(0,232,135,0.4)"></div>
183
+ <div class="hm-leg-cell" style="background:rgba(0,232,135,0.75)"></div>
184
+ <div class="hm-leg-cell" style="background:var(--green)"></div>
185
+ <span>More</span>
186
+ <div style="flex:1"></div>
187
+ <div class="hm-leg-cell" style="background:rgba(255,58,92,0.7)"></div>
188
+ <span style="color:var(--red)">Down</span>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <div class="panel">
194
+ <div class="ph">
195
+ <div class="ph-title"><span style="color:var(--red)">▲</span>&nbsp;Incidents <span class="tip" data-tip="Live feed of critical infrastructure failures and warnings.">?</span></div>
196
+ <div class="ph-badge warn" id="inc-badge">0 OPEN</div>
197
+ </div>
198
+ <div class="pb" style="padding-top:12px">
199
+ <div class="inc-list" id="incList"></div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <!-- ROW 3 -->
205
+ <div class="row3">
206
+ <div class="panel">
207
+ <div class="ph">
208
+ <div class="ph-title">⊞&nbsp;&nbsp;Infrastructure Services <span class="tip" data-tip="Detailed registry of all monitored nodes including 30-day sparklines, current status, and live latency. Click any row for deep diagnostics.">?</span></div>
209
+ <div style="font-family:var(--mono);font-size:9px;color:var(--t4)" id="svcCount">0 MONITORED</div>
210
+ </div>
211
+ <div class="pb">
212
+ <div class="svc-toolbar">
213
+ <div class="svc-search">
214
+ <span class="svc-search-ico">⌕</span>
215
+ <input type="text" id="searchBox" placeholder="Search services, URLs, tags..." oninput="filterTable()">
216
+ </div>
217
+ <button class="filter-chip sel" id="fc-all" onclick="setFilter('all',this)">All</button>
218
+ <button class="filter-chip" id="fc-online" onclick="setFilter('online',this)">Online</button>
219
+ <button class="filter-chip red" id="fc-down" onclick="setFilter('down',this)">Issues</button>
220
+ </div>
221
+ <table class="svc-tbl">
222
+ <thead>
223
+ <tr>
224
+ <th onclick="sortTable('name')">Service</th>
225
+ <th onclick="sortTable('scope')">Scope</th>
226
+ <th>Timeline</th>
227
+ <th>Status</th>
228
+ <th onclick="sortTable('latency')" style="text-align:right">Latency ↓</th>
229
+ </tr>
230
+ </thead>
231
+ <tbody id="svcBody"></tbody>
232
+ </table>
233
+ </div>
234
+ </div>
235
+
236
+ <div style="display:flex;flex-direction:column;gap:16px;">
237
+ <div class="panel">
238
+ <div class="ph"><div class="ph-title"><span class="ph-icon">⚡</span>Global Latency <span class="tip" data-tip="Top 8 slowest endpoints by current response time (ms).">?</span></div></div>
239
+ <div class="pb" id="latMatrix"></div>
240
+ </div>
241
+ <div class="panel">
242
+ <div class="ph"><div class="ph-title">🌐&nbsp;&nbsp;Node Map <span class="tip" data-tip="Geospatial visualization of infrastructure health across regions.">?</span></div></div>
243
+ <div class="world-wrap" id="worldMap"></div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </main>
248
+
249
+ <!-- TEST LAB TAB -->
250
+ <main class="main-tab" id="tab-testlab">
251
+ <div class="testlab-grid">
252
+ <!-- Left: Diagnostic Probe Controls -->
253
+ <div class="panel">
254
+ <div class="ph">
255
+ <div class="ph-title"><span class="ph-icon" style="color:var(--cyan)">⚡</span> Manual Diagnostics Probe <span class="tip" data-tip="Execute forced on-demand traces to specific endpoints to verify routing and response.">?</span></div>
256
+ <div class="ph-badge live">● PROBE READY</div>
257
+ </div>
258
+ <div class="pb">
259
+ <div class="input-group">
260
+ <label>Target Infrastructure Endpoint <span class="tip" data-tip="Select which managed node to run the manual trace against.">?</span></label>
261
+ <select id="test-tool-select" class="pro-input"></select>
262
+ </div>
263
+ <div class="input-group">
264
+ <label>Origin Probe Location <span class="tip" data-tip="Choose the geographic origin of the diagnostic trace.">?</span></label>
265
+ <select id="test-location-select" class="pro-input">
266
+ <option value="Global Anycast">🌐 Global Anycast Network</option>
267
+ <option value="US East">🇺🇸 US East (N. Virginia)</option>
268
+ <option value="US West">🇺🇸 US West (California)</option>
269
+ <option value="EU Central">🇪🇺 EU Central (Frankfurt)</option>
270
+ <option value="UK London">🇬🇧 UK (London)</option>
271
+ <option value="Asia Pacific">🌏 Asia Pacific (Tokyo)</option>
272
+ </select>
273
+ </div>
274
+ <div class="input-group">
275
+ <label>Diagnostic Protocol <span class="tip" data-tip="Select analysis protocol layer.">?</span></label>
276
+ <select id="test-proto-select" class="pro-input">
277
+ <option value="HTTP_TLS">🌐 HTTP/2 + TLS 1.3 Full Stack Trace</option>
278
+ <option value="SSL_CERT">🔒 SSL/TLS Certificate Audit</option>
279
+ <option value="JSON_PAYLOAD">⚙️ REST API Payload Health Check</option>
280
+ </select>
281
+ </div>
282
+ <button id="run-test-btn" class="btn-primary" style="margin-top:16px; padding:12px; font-size:13px; background:rgba(0,212,255,0.12); border-color:var(--cyan); color:var(--cyan);">
283
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
284
+ Execute Diagnostic Trace
285
+ </button>
286
+ </div>
287
+ </div>
288
+
289
+ <!-- Right: Real-time Telemetry Terminal Console -->
290
+ <div style="display:flex; flex-direction:column; gap:16px;">
291
+ <div class="panel">
292
+ <div class="ph">
293
+ <div class="ph-title"><span class="ph-icon" style="color:var(--cyan)">🖥️</span> Live Diagnostic Telemetry Console <span class="tip" data-tip="Real-time protocol traceroute and latency metrics stream.">?</span></div>
294
+ <div class="ph-badge live" id="console-badge">STANDBY</div>
295
+ </div>
296
+ <div class="pb" style="padding:14px;">
297
+ <div class="console-box" id="console-output">
298
+ <div class="console-line text-dim">[SYSTEM] StatusPilot Diagnostic Engine v5.1 Ready.</div>
299
+ <div class="console-line text-dim">[SYSTEM] Select an infrastructure target and click "Execute Diagnostic Trace" to initiate probe...</div>
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+ <div class="testlab-kpis">
305
+ <div class="t-kpi">
306
+ <div class="t-kpi-label">DNS RESOLVE</div>
307
+ <div class="t-kpi-val" id="tk-dns">-- ms</div>
308
+ </div>
309
+ <div class="t-kpi">
310
+ <div class="t-kpi-label">TCP HANDSHAKE</div>
311
+ <div class="t-kpi-val" id="tk-tcp">-- ms</div>
312
+ </div>
313
+ <div class="t-kpi">
314
+ <div class="t-kpi-label">TTFB (LATENCY)</div>
315
+ <div class="t-kpi-val" id="tk-ttfb">-- ms</div>
316
+ </div>
317
+ <div class="t-kpi">
318
+ <div class="t-kpi-label">PROBE RESULT</div>
319
+ <div class="t-kpi-val" style="color:var(--green)" id="tk-status">READY</div>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </main>
325
+
326
+ <!-- OPERATIONS TAB -->
327
+ <main class="main-tab" id="tab-config">
328
+ <!-- M365 Panel -->
329
+ <div class="panel" style="margin-bottom:20px; border-color:var(--b2);">
330
+ <div class="ph"><div class="ph-title"><span class="ph-icon" style="color:var(--cyan);">✉️</span> M365 Email Monitoring <span class="tip" data-tip="Monitors Microsoft 365 mail flow by sending a test email and verifying delivery. Uses App Password (not your main password).">?</span></div></div>
331
+ <div class="pb">
332
+ <div class="ops-grid-3">
333
+ <div class="input-group"><label>Sender Email</label><input type="email" id="m365-sender" class="pro-input" placeholder="alerts@company.com"></div>
334
+ <div class="input-group"><label>App Password</label><input type="password" id="m365-pass" class="pro-input" placeholder="••••••••"></div>
335
+ <div class="input-group"><label>Recipient Email</label><input type="email" id="m365-recipient" class="pro-input" placeholder="verify@company.com"></div>
336
+ </div>
337
+ <button id="activate-m365-btn" class="btn-primary" style="background:rgba(0,212,255,0.1); border-color:var(--cyan); color:var(--cyan);">Activate Mail Flow Monitor</button>
338
+ </div>
339
+ </div>
340
+
341
+ <div class="ops-grid">
342
+ <!-- Active Infrastructure -->
343
+ <div class="panel">
344
+ <div class="ph"><div class="ph-title"><span class="ph-icon">📋</span> Active Infrastructure <span class="tip" data-tip="All monitored services. Click REMOVE to decommission a node permanently.">?</span></div></div>
345
+ <div class="pb">
346
+ <div id="manage-svc-list" style="max-height: 250px; overflow-y: auto; margin-bottom:10px;"></div>
347
+ <button id="purge-stale-btn" class="btn-danger-outline">Purge Stale Data</button>
348
+ </div>
349
+ </div>
350
+
351
+ <!-- Add Node -->
352
+ <div class="panel">
353
+ <div class="ph"><div class="ph-title"><span class="ph-icon" style="color:var(--green)">➕</span> Add New Node <span class="tip" data-tip="Provision a new website, API, or SSL certificate into the monitoring engine.">?</span></div></div>
354
+ <div class="pb">
355
+ <div class="input-group"><label>Service Name</label><input type="text" id="new-svc-name" class="pro-input" placeholder="e.g. Acme API"></div>
356
+ <div class="input-group"><label>Location / Scope</label><input type="text" id="new-svc-loc" class="pro-input" placeholder="e.g. US East"></div>
357
+ <div class="input-group"><label>Endpoint URL</label><input type="url" id="new-svc-url" class="pro-input" placeholder="https://..."></div>
358
+ <div class="input-group">
359
+ <label>Check Type</label>
360
+ <select id="new-svc-type" class="pro-input">
361
+ <option value="http_code">🌐 Website (HTTP)</option>
362
+ <option value="ssl">🔒 SSL Certificate</option>
363
+ <option value="json_status">⚙️ API (JSON)</option>
364
+ </select>
365
+ </div>
366
+ <button id="add-svc-btn" class="btn-primary" style="background:rgba(0,232,135,0.1); border-color:var(--green); color:var(--green);">Provision Node</button>
367
+ </div>
368
+ </div>
369
+ </div>
370
+
371
+ <!-- Alert Config -->
372
+ <div class="panel" style="margin-top:20px;">
373
+ <div class="ph"><div class="ph-title"><span class="ph-icon" style="color:var(--amber)">🔔</span> Alert Configuration <span class="tip" data-tip="Configure how you receive notifications when incidents occur or resolve.">?</span></div></div>
374
+ <div class="pb">
375
+ <div class="ops-grid-3">
376
+ <div class="input-group"><label>Alert Mode</label><select id="alert-mode" class="pro-input"><option value="SMTP">SMTP (Cloud)</option><option value="Outlook">Local Outlook</option></select></div>
377
+ <div class="input-group"><label>Interval (sec)</label><input type="number" id="check-interval" class="pro-input" placeholder="60"></div>
378
+ <div class="input-group"><label>SMTP User</label><input type="email" id="smtp-user" class="pro-input" placeholder="alerts@co.com"></div>
379
+ </div>
380
+ <div class="input-group" style="margin-top:6px;"><label>SMTP Password</label><input type="password" id="smtp-pass" class="pro-input" placeholder="••••••••"></div>
381
+ <div class="input-group" style="margin-top:6px;"><label>HTTPS Webhook Gateway URL (Google Apps Script / Custom Bypass)</label><input type="text" id="email-webhook-url" class="pro-input" placeholder="https://script.google.com/macros/s/.../exec"></div>
382
+ <div class="input-group" style="margin-top:6px;"><label>Alert Recipient Emails (comma separated)</label><input type="text" id="alert-recipients" class="pro-input" placeholder="admin@co.com, team@co.com"></div>
383
+ <div style="display:flex; gap:10px; margin-top:10px;">
384
+ <button id="save-settings-btn" class="btn-primary" style="flex:1; background:rgba(255,170,0,0.1); border-color:var(--amber); color:var(--amber);">Save Configuration</button>
385
+ <button id="test-alert-btn" class="btn-primary" style="flex:1; background:rgba(0,212,255,0.1); border-color:var(--cyan); color:var(--cyan);">Send Test Alert Email</button>
386
+ </div>
387
+ </div>
388
+ </div>
389
+ </main>
390
+ </div><!-- /app -->
391
+
392
+ <!-- MODAL -->
393
+ <div class="modal-back" id="modal" onclick="bgClose(event)">
394
+ <div class="modal" id="modalBox">
395
+ <div class="modal-hd">
396
+ <div class="modal-hd-info">
397
+ <div class="modal-title" id="mTitle">Service</div>
398
+ <div class="modal-meta">
399
+ <div class="modal-url" id="mUrl">https://...</div>
400
+ <div class="modal-status-chip online pill" id="mStatusChip">
401
+ <span class="pill-dot"></span><span id="mStatusTxt">ONLINE</span>
402
+ </div>
403
+ </div>
404
+ </div>
405
+ <button class="mclose" onclick="closeModal()">✕</button>
406
+ </div>
407
+ <div class="modal-tabs">
408
+ <button class="mtab act" onclick="switchModalTab('overview',this)">Overview</button>
409
+ <button class="mtab" onclick="switchModalTab('history',this)">History</button>
410
+ </div>
411
+ <div class="mtab-panel act" id="mtab-overview">
412
+ <div class="modal-grid">
413
+ <div class="m-card">
414
+ <div class="m-card-title"><span class="mc-icon" style="color:var(--green)">◎</span>Availability</div>
415
+ <canvas id="mAvailChart" height="72" width="280"></canvas>
416
+ </div>
417
+ <div class="m-card">
418
+ <div class="m-card-title"><span class="mc-icon" style="color:var(--cyan)">⚡</span>Latency (ms)</div>
419
+ <canvas id="mLatChart" height="72" width="280"></canvas>
420
+ </div>
421
+ <div class="m-card">
422
+ <div class="m-card-title"><span class="mc-icon" style="color:var(--amber)">◔</span>Error Profile</div>
423
+ <div style="display:flex;align-items:center;gap:16px;padding-top:4px;">
424
+ <canvas id="mDonut" width="72" height="72"></canvas>
425
+ <div style="display:flex;flex-direction:column;gap:8px;">
426
+ <div style="display:flex;align-items:center;gap:8px;font-size:11px;"><div style="width:10px;height:10px;border-radius:2px;background:var(--green)"></div><span id="mOkPct">—</span> Operational</div>
427
+ <div style="display:flex;align-items:center;gap:8px;font-size:11px;"><div style="width:10px;height:10px;border-radius:2px;background:var(--red)"></div><span id="mErrPct">—</span> Errors</div>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ <div class="m-card">
432
+ <div class="m-card-title"><span class="mc-icon" style="color:var(--red)">▲</span>Status Indicator</div>
433
+ <div id="mIncidentBox"></div>
434
+ </div>
435
+ </div>
436
+ </div>
437
+ <div class="mtab-panel" id="mtab-history">
438
+ <div style="margin-bottom:14px;font-family:var(--mono);font-size:9px;color:var(--t4);letter-spacing:1px;">LAST 10 CHECK RESULTS</div>
439
+ <table class="h-tbl" id="histTable">
440
+ <thead><tr><th>Timestamp</th><th>Status</th><th>Latency</th><th>Message</th></tr></thead>
441
+ <tbody id="histBody"></tbody>
442
+ </table>
443
+ </div>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- TOASTS -->
448
+ <div id="toasts"></div>
449
+
450
+ <script src="/static/script_v6.js?v=5.1.8"></script>
451
+ </body>
452
+ </html>
update_logo_b64.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64, io
2
+ from PIL import Image
3
+
4
+ src = r'C:\Users\Suresh\OneDrive - Parle Tech\Desktop\Services Alert\Cloud_Version\static\parle_tech_logo.png'
5
+ img = Image.open(src).convert('RGBA')
6
+ print('SIZE:', img.size)
7
+ buf = io.BytesIO()
8
+ img.save(buf, format='PNG')
9
+ b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
10
+ print('B64_LEN:', len(b64))
11
+
12
+ target = r'c:\Users\Suresh\OneDrive - Parle Tech\Desktop\Services Alert\Cloud_Version\app.py'
13
+ lines = open(target, 'r', encoding='utf-8').readlines()
14
+ new_lines = []
15
+ updated = False
16
+ for l in lines:
17
+ if l.startswith('PARLE_TECH_LOGO_B64 ='):
18
+ new_lines.append('PARLE_TECH_LOGO_B64 = "' + b64 + '"\n')
19
+ updated = True
20
+ else:
21
+ new_lines.append(l)
22
+ open(target, 'w', encoding='utf-8').writelines(new_lines)
23
+ print('Updated B64:', updated)