feat: UI improvements and WMT sync workflow overhaul
This commit is contained in:
+66
-40
@@ -127,11 +127,25 @@ def get_device_config(mac_address):
|
||||
@wmt_api_bp.route('/config/update_request', methods=['POST'])
|
||||
def submit_update_request():
|
||||
"""
|
||||
WMT client sends current device info. Three outcomes:
|
||||
WMT client sends its current running config so the server can validate it.
|
||||
Three outcomes:
|
||||
|
||||
1. Device not registered → create pending WMTUpdateRequest for admin approval.
|
||||
2. Device registered, info UNCHANGED → update last_seen only; no request created.
|
||||
3. Device registered, info CHANGED → create/refresh a pending WMTUpdateRequest.
|
||||
1. Device known AND config matches server record
|
||||
→ record config_synced_at = now, update last_seen
|
||||
→ respond {"status": "ok", "in_sync": true}
|
||||
Client does nothing.
|
||||
|
||||
2. Device known BUT config differs from server record
|
||||
→ the server is authoritative; client must pull fresh config
|
||||
→ respond {"status": "sync_required", "in_sync": false}
|
||||
Client should call GET /api/wmt/config/<mac> to obtain correct values.
|
||||
|
||||
3. Device unknown (new client – not registered by MAC or hostname)
|
||||
→ create WMTUpdateRequest so admin can approve and assign settings
|
||||
→ respond {"status": "pending_approval"}
|
||||
Client waits; it will get real config once admin approves.
|
||||
|
||||
card_presence is always applied directly – no approval required.
|
||||
|
||||
Expected JSON:
|
||||
{
|
||||
@@ -139,8 +153,8 @@ def submit_update_request():
|
||||
"device_name": "Masa-01",
|
||||
"hostname": "rpi-masa01",
|
||||
"device_ip": "192.168.1.100",
|
||||
"card_presence": "enable", // optional – applied directly, no approval
|
||||
"client_config_mtime": "2026-04-22T09:30:00" // optional
|
||||
"card_presence": "enable",
|
||||
"client_config_mtime": "2026-04-22T09:30:00"
|
||||
}
|
||||
"""
|
||||
if not request.is_json:
|
||||
@@ -157,36 +171,15 @@ def submit_update_request():
|
||||
card_presence = data.get('card_presence')
|
||||
|
||||
def _eq(a, b):
|
||||
"""Compare two values treating None and '' as equivalent."""
|
||||
return (a or '') == (b or '')
|
||||
|
||||
try:
|
||||
with get_db().get_session() as session:
|
||||
device = session.query(Device).filter_by(mac_address=mac).first()
|
||||
|
||||
# Always update last_seen and card_presence (no approval needed)
|
||||
if device:
|
||||
device.last_seen = datetime.utcnow()
|
||||
if card_presence in ('enable', 'disable'):
|
||||
device.card_presence = card_presence
|
||||
|
||||
# --- Determine whether the proposed info differs from the server record ---
|
||||
if device:
|
||||
info_changed = not (
|
||||
_eq(proposed_name, device.nume_masa) and
|
||||
_eq(proposed_hostname, device.hostname) and
|
||||
_eq(proposed_ip, device.device_ip)
|
||||
)
|
||||
else:
|
||||
# Unknown device – always needs admin attention
|
||||
info_changed = True
|
||||
|
||||
if not info_changed:
|
||||
# Heartbeat only – nothing for admin to review
|
||||
logger.debug(f'WMT heartbeat from {mac} – info unchanged, last_seen updated')
|
||||
return jsonify({'status': 'no_change', 'message': 'Device info matches server record'}), 200
|
||||
|
||||
# --- Info changed (or device unknown): avoid duplicate pending requests ---
|
||||
# ── Outcome 3: unknown device ─────────────────────────────
|
||||
if not device:
|
||||
# Check if a pending request with the same data already exists
|
||||
existing = (
|
||||
session.query(WMTUpdateRequest)
|
||||
.filter_by(mac_address=mac, status='pending')
|
||||
@@ -198,15 +191,12 @@ def submit_update_request():
|
||||
_eq(existing.proposed_hostname, proposed_hostname) and
|
||||
_eq(existing.proposed_device_ip, proposed_ip)
|
||||
):
|
||||
# Identical pending request already exists – just refresh its timestamp
|
||||
existing.submitted_at = datetime.utcnow()
|
||||
logger.debug(f'WMT duplicate pending request from {mac} – timestamp refreshed')
|
||||
return jsonify({'status': 'pending', 'message': 'Update request already pending admin review'}), 200
|
||||
|
||||
# Create a new update request
|
||||
logger.debug(f'WMT unknown device {mac} – refreshed existing pending request')
|
||||
else:
|
||||
req = WMTUpdateRequest(
|
||||
mac_address=mac,
|
||||
device_id=device.id if device else None,
|
||||
device_id=None,
|
||||
proposed_device_name=proposed_name or None,
|
||||
proposed_hostname=proposed_hostname or None,
|
||||
proposed_device_ip=proposed_ip or None,
|
||||
@@ -215,12 +205,48 @@ def submit_update_request():
|
||||
status='pending',
|
||||
)
|
||||
session.add(req)
|
||||
logger.info(f'WMT pending approval request created for unknown device {mac}')
|
||||
|
||||
return jsonify({
|
||||
'status': 'pending_approval',
|
||||
'message': 'Device not registered. Awaiting admin approval.',
|
||||
}), 202
|
||||
|
||||
# Device is known from here on ─────────────────────────────
|
||||
device.last_seen = datetime.utcnow()
|
||||
if card_presence in ('enable', 'disable'):
|
||||
device.card_presence = card_presence
|
||||
|
||||
# ── Outcome 1: config matches server record ────────────────
|
||||
config_in_sync = (
|
||||
_eq(proposed_name, device.nume_masa) and
|
||||
_eq(proposed_hostname, device.hostname) and
|
||||
_eq(proposed_ip, device.device_ip)
|
||||
)
|
||||
|
||||
if config_in_sync:
|
||||
device.config_synced_at = datetime.utcnow()
|
||||
logger.debug(f'WMT config OK for {mac} – synced_at updated')
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'in_sync': True,
|
||||
'message': 'Config matches server record.',
|
||||
}), 200
|
||||
|
||||
# ── Outcome 2: config differs – client must pull from server ─
|
||||
logger.info(
|
||||
f'WMT config mismatch for {mac}: '
|
||||
f'client has name={proposed_name!r} host={proposed_hostname!r} ip={proposed_ip!r} '
|
||||
f'but server has name={device.nume_masa!r} host={device.hostname!r} ip={device.device_ip!r}'
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'sync_required',
|
||||
'in_sync': False,
|
||||
'message': 'Config differs from server record. Pull updated config.',
|
||||
}), 200
|
||||
|
||||
reason = 'new device' if not device else 'info changed'
|
||||
logger.info(f'WMT update request created for {mac} ({reason})')
|
||||
return jsonify({'status': 'received', 'message': 'Update request queued for admin review'}), 201
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving WMT update request from {mac}: {e}')
|
||||
logger.error(f'Error processing WMT config check from {mac}: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ class Device(Base):
|
||||
|
||||
# WMT (Workstation Management Terminal) integration fields
|
||||
mac_address = Column(String(17), unique=True, nullable=True, index=True)
|
||||
config_updated_at = Column(DateTime)
|
||||
config_updated_at = Column(DateTime) # set by admin when pushing new config
|
||||
config_synced_at = Column(DateTime) # set by server when client confirms in-sync
|
||||
info_reviewed_at = Column(DateTime, default=lambda: datetime(1970, 1, 1))
|
||||
card_presence = Column(String(10), default='enable')
|
||||
|
||||
|
||||
+11
-3
@@ -34,7 +34,7 @@ def devices():
|
||||
with get_db().get_session() as session:
|
||||
devices = session.query(Device).order_by(Device.last_seen.desc()).all()
|
||||
|
||||
# Get log count per device
|
||||
# Log count per device
|
||||
device_log_counts = {}
|
||||
for device in devices:
|
||||
log_count = session.query(LogEntry).filter_by(device_id=device.id).count()
|
||||
@@ -42,14 +42,22 @@ def devices():
|
||||
|
||||
pending_count = session.query(WMTUpdateRequest).filter_by(status='pending').count()
|
||||
|
||||
# Pending approval requests mapped by mac_address for per-device badge
|
||||
pending_requests = session.query(WMTUpdateRequest).filter_by(status='pending').all()
|
||||
pending_by_mac = {}
|
||||
for req in pending_requests:
|
||||
pending_by_mac[req.mac_address] = pending_by_mac.get(req.mac_address, 0) + 1
|
||||
|
||||
return render_template('device_management.html',
|
||||
devices=devices,
|
||||
device_log_counts=device_log_counts,
|
||||
pending_count=pending_count)
|
||||
pending_count=pending_count,
|
||||
pending_by_mac=pending_by_mac)
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading devices: {e}")
|
||||
flash(f'Error loading devices: {e}', 'error')
|
||||
return render_template('device_management.html', devices=[], device_log_counts={}, pending_count=0)
|
||||
return render_template('device_management.html', devices=[], device_log_counts={},
|
||||
pending_count=0, pending_by_mac={})
|
||||
|
||||
@main_bp.route('/device/<int:device_id>')
|
||||
def device_detail(device_id):
|
||||
|
||||
@@ -57,6 +57,22 @@ class DatabaseSchemaUpdater:
|
||||
except Exception as e:
|
||||
print(f" ❌ Error adding {column_name}: {e}")
|
||||
|
||||
def add_config_synced_at_column(self):
|
||||
"""Add config_synced_at column to devices table (WMT config sync tracking)"""
|
||||
print("📊 Updating devices table schema (config_synced_at)...")
|
||||
with self.engine.connect() as conn:
|
||||
try:
|
||||
result = conn.execute(text("PRAGMA table_info(devices)"))
|
||||
existing = [row[1] for row in result.fetchall()]
|
||||
if 'config_synced_at' not in existing:
|
||||
conn.execute(text("ALTER TABLE devices ADD COLUMN config_synced_at DATETIME"))
|
||||
conn.commit()
|
||||
print(" ✅ Added column: config_synced_at")
|
||||
else:
|
||||
print(" ⏭️ Column config_synced_at already exists")
|
||||
except Exception as e:
|
||||
print(f" ❌ Error adding config_synced_at: {e}")
|
||||
|
||||
def create_tables(self):
|
||||
"""Create all tables using SQLAlchemy metadata"""
|
||||
print("🏗️ Creating all database tables...")
|
||||
@@ -109,6 +125,7 @@ if __name__ == "__main__":
|
||||
|
||||
# Update schema
|
||||
updater.update_playbook_executions_schema()
|
||||
updater.add_config_synced_at_column()
|
||||
|
||||
# Verify
|
||||
if updater.verify_schema():
|
||||
|
||||
@@ -38,6 +38,26 @@
|
||||
padding: 1rem;
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
body.dark-mode .playbook-actions {
|
||||
background: #2a2a2a;
|
||||
border-bottom-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .playbook-item.selected {
|
||||
border-color: #64b5f6;
|
||||
background-color: #1a2a3a;
|
||||
}
|
||||
|
||||
body.dark-mode #welcomeMessage h4,
|
||||
body.dark-mode #welcomeMessage p {
|
||||
color: #888888 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .code-editor-area {
|
||||
border-color: #444444;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
+722
-124
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Server Monitoring Dashboard{% endblock %}</title>
|
||||
<!-- Apply dark mode before render to avoid flash -->
|
||||
<script>if(localStorage.getItem('darkMode')==='enabled'){document.documentElement.style.backgroundColor='#121212';}</script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
@@ -52,56 +54,124 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Accordion group header ── */
|
||||
.nav-group {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 11px 15px;
|
||||
color: #ecf0f1;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
user-select: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-group-header:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.nav-group-header.open {
|
||||
background-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.nav-group-header i.group-icon {
|
||||
width: 20px;
|
||||
margin-right: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.nav-group-header .group-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-group-header .chevron {
|
||||
font-size: 11px;
|
||||
transition: transform 0.25s ease;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-group-header.open .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Badge on group header (e.g. pending count) */
|
||||
.nav-group-header .group-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
margin-right: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Collapsible children ── */
|
||||
.nav-group-children {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
transition: max-height 0.3s ease;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.nav-group-children.open {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
/* ── Child nav links ── */
|
||||
.sidebar .nav-item {
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
color: #ecf0f1;
|
||||
padding: 9px 15px;
|
||||
color: #dce6f0;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
border-radius: 7px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
transform: translateX(5px);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
background-color: rgba(255,255,255,0.22);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
width: 20px;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.sidebar .nav-section {
|
||||
margin: 30px 15px 10px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #bdc3c7;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
/* ── Admin standalone link ── */
|
||||
.sidebar .nav-link.admin-link {
|
||||
color: #ff6b6b;
|
||||
margin-top: 8px;
|
||||
color: #ff8a80;
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
padding-top: 14px;
|
||||
padding-top: 13px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.sidebar .nav-link.admin-link:hover {
|
||||
background-color: rgba(220,53,69,0.25);
|
||||
color: #ff6b6b;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.sidebar .nav-link.admin-link.active {
|
||||
background-color: rgba(220,53,69,0.35);
|
||||
@@ -218,6 +288,505 @@
|
||||
border: none;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Dark Mode ── */
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Override Bootstrap 5 CSS variables globally in dark mode */
|
||||
body.dark-mode {
|
||||
--bs-body-bg: #121212;
|
||||
--bs-body-color: #e0e0e0;
|
||||
--bs-card-bg: #2a2a2a;
|
||||
--bs-card-color: #e0e0e0;
|
||||
--bs-card-border-color: #444444;
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-color: #e0e0e0;
|
||||
--bs-table-border-color: #444444;
|
||||
--bs-table-striped-bg: #232323;
|
||||
--bs-table-striped-color: #e0e0e0;
|
||||
--bs-table-hover-bg: #303030;
|
||||
--bs-table-hover-color: #e0e0e0;
|
||||
--bs-table-active-bg: #353535;
|
||||
--bs-border-color: #444444;
|
||||
--bs-border-color-translucent: rgba(255,255,255,0.1);
|
||||
--bs-link-color: #64b5f6;
|
||||
--bs-link-hover-color: #90caf9;
|
||||
--bs-nav-tabs-link-active-bg: #2a2a2a;
|
||||
--bs-nav-tabs-link-active-color: #e0e0e0;
|
||||
--bs-nav-tabs-link-active-border-color: #444444 #444444 #2a2a2a;
|
||||
--bs-nav-link-color: #aaaaaa;
|
||||
--bs-nav-link-hover-color: #e0e0e0;
|
||||
--bs-list-group-bg: #2a2a2a;
|
||||
--bs-list-group-color: #e0e0e0;
|
||||
--bs-list-group-border-color: #444444;
|
||||
--bs-list-group-action-color: #e0e0e0;
|
||||
--bs-list-group-action-hover-bg: #333333;
|
||||
--bs-list-group-action-active-bg: #333333;
|
||||
--bs-input-bg: #2a2a2a;
|
||||
--bs-input-color: #e0e0e0;
|
||||
--bs-input-border-color: #555555;
|
||||
--bs-form-select-bg: #2a2a2a;
|
||||
}
|
||||
|
||||
body.dark-mode .content-header {
|
||||
background-color: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
body.dark-mode .content-header h1 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .content-header .breadcrumb-item a {
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
body.dark-mode .content-header .breadcrumb-item.active {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
body.dark-mode .content-body {
|
||||
background-color: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
body.dark-mode .card {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #e0e0e0 !important;
|
||||
border-color: #444444 !important;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
body.dark-mode .card-body {
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .card-header {
|
||||
background-color: #333333 !important;
|
||||
border-bottom-color: #444444 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .card-footer {
|
||||
background-color: #2e2e2e !important;
|
||||
border-top-color: #444444 !important;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
/* Tables — override Bootstrap table-light/table-dark utility */
|
||||
body.dark-mode .table {
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-color: #e0e0e0;
|
||||
--bs-table-border-color: #444444;
|
||||
--bs-table-hover-bg: #303030;
|
||||
--bs-table-hover-color: #e0e0e0;
|
||||
--bs-table-striped-bg: #232323;
|
||||
--bs-table-striped-color: #e0e0e0;
|
||||
color: #e0e0e0;
|
||||
border-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .table > :not(caption) > * > * {
|
||||
background-color: transparent;
|
||||
color: #e0e0e0;
|
||||
border-bottom-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .table thead,
|
||||
body.dark-mode .table-light,
|
||||
body.dark-mode .table thead.table-light,
|
||||
body.dark-mode .table thead > tr > th {
|
||||
--bs-table-bg: #1a1a1a;
|
||||
--bs-table-color: #b0b0b0;
|
||||
--bs-table-border-color: #444444;
|
||||
background-color: #1a1a1a !important;
|
||||
color: #b0b0b0 !important;
|
||||
border-color: #444444 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table-hover > tbody > tr:hover > * {
|
||||
background-color: #303030 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .table-container {
|
||||
background-color: #1e1e1e;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* List groups */
|
||||
body.dark-mode .list-group-item {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #e0e0e0 !important;
|
||||
border-color: #444444 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .list-group-item:hover {
|
||||
background-color: #333333 !important;
|
||||
}
|
||||
|
||||
/* Nav tabs */
|
||||
body.dark-mode .nav-tabs {
|
||||
border-bottom-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .nav-tabs .nav-link {
|
||||
color: #aaaaaa;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
body.dark-mode .nav-tabs .nav-link:hover {
|
||||
color: #e0e0e0;
|
||||
border-color: #555555 #555555 transparent;
|
||||
}
|
||||
|
||||
body.dark-mode .nav-tabs .nav-link.active {
|
||||
background-color: #2a2a2a !important;
|
||||
color: #e0e0e0 !important;
|
||||
border-color: #444444 #444444 #2a2a2a !important;
|
||||
}
|
||||
|
||||
/* Nav pills */
|
||||
body.dark-mode .nav-pills .nav-link {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
body.dark-mode .nav-pills .nav-link.active {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
body.dark-mode .form-control,
|
||||
body.dark-mode .form-select {
|
||||
background-color: #2a2a2a !important;
|
||||
border-color: #555555 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus,
|
||||
body.dark-mode .form-select:focus {
|
||||
background-color: #333333 !important;
|
||||
border-color: #64b5f6 !important;
|
||||
color: #e0e0e0 !important;
|
||||
box-shadow: 0 0 0 0.2rem rgba(100,181,246,0.25);
|
||||
}
|
||||
|
||||
body.dark-mode .form-control::placeholder {
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
/* File input — native "Choose file" button */
|
||||
body.dark-mode input[type="file"].form-control::file-selector-button {
|
||||
background-color: #444444;
|
||||
color: #e0e0e0;
|
||||
border: 0;
|
||||
border-right: 1px solid #555555;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
body.dark-mode input[type="file"].form-control::file-selector-button:hover {
|
||||
background-color: #555555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* "No file chosen" text area colour */
|
||||
body.dark-mode input[type="file"].form-control {
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
/* Helper text below inputs */
|
||||
body.dark-mode .form-text {
|
||||
color: #888888 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .form-label,
|
||||
body.dark-mode label {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
body.dark-mode .form-check-label {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
body.dark-mode .input-group-text {
|
||||
background-color: #333333 !important;
|
||||
border-color: #555555 !important;
|
||||
color: #cccccc !important;
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
body.dark-mode .modal-content {
|
||||
background-color: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
border-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-header {
|
||||
border-bottom-color: #444444;
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-footer {
|
||||
border-top-color: #444444;
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
/* Dropdowns */
|
||||
body.dark-mode .dropdown-menu {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .dropdown-item {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .dropdown-item:hover,
|
||||
body.dark-mode .dropdown-item:focus {
|
||||
background-color: #333333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .dropdown-divider {
|
||||
border-top-color: #444444;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
body.dark-mode .badge.bg-secondary {
|
||||
background-color: #555555 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .badge.bg-light {
|
||||
background-color: #444444 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
body.dark-mode .alert-info {
|
||||
background-color: #0d3250;
|
||||
border-color: #1565c0;
|
||||
color: #90caf9;
|
||||
}
|
||||
|
||||
body.dark-mode .alert-success {
|
||||
background-color: #0d3b2a;
|
||||
border-color: #1b5e20;
|
||||
color: #a5d6a7;
|
||||
}
|
||||
|
||||
body.dark-mode .alert-warning {
|
||||
background-color: #3e2d00;
|
||||
border-color: #f57f17;
|
||||
color: #ffe082;
|
||||
}
|
||||
|
||||
body.dark-mode .alert-danger {
|
||||
background-color: #3b0d0d;
|
||||
border-color: #b71c1c;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
body.dark-mode .btn-outline-secondary {
|
||||
color: #aaaaaa;
|
||||
border-color: #666666;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-outline-secondary:hover {
|
||||
background-color: #444444;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-outline-primary {
|
||||
color: #64b5f6;
|
||||
border-color: #64b5f6;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-outline-primary:hover {
|
||||
background-color: #1565c0;
|
||||
border-color: #1565c0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-outline-danger {
|
||||
color: #ef9a9a;
|
||||
border-color: #ef9a9a;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-outline-danger:hover {
|
||||
background-color: #b71c1c;
|
||||
border-color: #b71c1c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-outline-warning {
|
||||
color: #ffe082;
|
||||
border-color: #f57f17;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-outline-warning:hover {
|
||||
background-color: #e65100;
|
||||
border-color: #e65100;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Misc */
|
||||
body.dark-mode hr {
|
||||
border-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .text-muted {
|
||||
color: #888888 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .text-dark {
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .bg-light {
|
||||
background-color: #2a2a2a !important;
|
||||
}
|
||||
|
||||
body.dark-mode .bg-white {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
body.dark-mode .border {
|
||||
border-color: #444444 !important;
|
||||
}
|
||||
|
||||
body.dark-mode pre,
|
||||
body.dark-mode code {
|
||||
background-color: #1a1a1a;
|
||||
color: #80cbc4;
|
||||
border-color: #444444;
|
||||
}
|
||||
|
||||
body.dark-mode a {
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
body.dark-mode a:hover {
|
||||
color: #90caf9;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
body.dark-mode .page-link {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #444444;
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
body.dark-mode .page-link:hover {
|
||||
background-color: #333333;
|
||||
border-color: #555555;
|
||||
color: #90caf9;
|
||||
}
|
||||
|
||||
body.dark-mode .page-item.active .page-link {
|
||||
background-color: #3498db;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
body.dark-mode .page-item.disabled .page-link {
|
||||
background-color: #1e1e1e;
|
||||
border-color: #444444;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
/* Progress bars */
|
||||
body.dark-mode .progress {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
/* Tabs content / tab-pane */
|
||||
body.dark-mode .tab-content {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Dark mode toggle button */
|
||||
.dark-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% - 20px);
|
||||
margin: 4px 10px 0;
|
||||
padding: 10px 15px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
color: #ecf0f1;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark-mode-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark-mode-btn i {
|
||||
width: 20px;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dark-mode-btn .toggle-track {
|
||||
margin-left: auto;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.dark-mode-btn .toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
body.dark-mode .dark-mode-btn .toggle-track {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
body.dark-mode .dark-mode-btn .toggle-track::after {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Dark mode — accordion sidebar */
|
||||
body.dark-mode .nav-group-header {
|
||||
color: #dce6f0;
|
||||
}
|
||||
|
||||
body.dark-mode .nav-group-header:hover {
|
||||
background-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
body.dark-mode .nav-group-header.open {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
@@ -235,123 +804,130 @@
|
||||
</div>
|
||||
|
||||
<ul class="nav-menu">
|
||||
<div class="nav-section">WMT</div>
|
||||
|
||||
<!-- ── WMT group ── -->
|
||||
<li class="nav-group" id="group-wmt">
|
||||
<div class="nav-group-header {% if request.endpoint and request.endpoint.startswith('wmt_web') %}open{% endif %}"
|
||||
onclick="toggleGroup('group-wmt')">
|
||||
<i class="fas fa-tablet-alt group-icon"></i>
|
||||
<span class="group-label">WMT</span>
|
||||
{% if pending_wmt_count > 0 %}<span class="group-badge">{{ pending_wmt_count }}</span>{% endif %}
|
||||
<i class="fas fa-chevron-right chevron"></i>
|
||||
</div>
|
||||
<ul class="nav-group-children {% if request.endpoint and request.endpoint.startswith('wmt_web') %}open{% endif %}" style="list-style:none;padding-left:10px;margin:0;">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('wmt_web.index') }}" class="nav-link {% if request.endpoint == 'wmt_web.index' %}active{% endif %}">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
WMT Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('wmt_web.settings') }}" class="nav-link {% if request.endpoint == 'wmt_web.settings' %}active{% endif %}">
|
||||
<i class="fas fa-cog"></i>
|
||||
WMT Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('wmt_web.update_requests') }}" class="nav-link {% if request.endpoint == 'wmt_web.update_requests' %}active{% endif %}">
|
||||
<i class="fas fa-inbox"></i>
|
||||
Update Requests
|
||||
{% if pending_wmt_count > 0 %}<span class="badge bg-danger ms-1">{{ pending_wmt_count }}</span>{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('wmt_web.releases') }}" class="nav-link {% if request.endpoint == 'wmt_web.releases' %}active{% endif %}">
|
||||
<i class="fas fa-box-open"></i>
|
||||
Client Releases
|
||||
<i class="fas fa-tachometer-alt"></i>Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('wmt_web.devices') }}" class="nav-link {% if request.endpoint in ['wmt_web.devices','wmt_web.device_new','wmt_web.device_edit'] %}active{% endif %}">
|
||||
<i class="fas fa-desktop"></i>
|
||||
Devices
|
||||
<i class="fas fa-desktop"></i>Devices
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.devices') }}" class="nav-link {% if request.endpoint in ['main.devices','main.device_edit','main.device_detail'] %}active{% endif %}">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
Devices Monitoring
|
||||
<a href="{{ url_for('wmt_web.update_requests') }}" class="nav-link {% if request.endpoint == 'wmt_web.update_requests' %}active{% endif %}">
|
||||
<i class="fas fa-inbox"></i>Update Requests
|
||||
{% if pending_wmt_count > 0 %}<span class="badge bg-danger ms-auto">{{ pending_wmt_count }}</span>{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('wmt_web.releases') }}" class="nav-link {% if request.endpoint == 'wmt_web.releases' %}active{% endif %}">
|
||||
<i class="fas fa-box-open"></i>Client Releases
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('wmt_web.settings') }}" class="nav-link {% if request.endpoint == 'wmt_web.settings' %}active{% endif %}">
|
||||
<i class="fas fa-cog"></i>Settings
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<div class="nav-section">Monitoring</div>
|
||||
<!-- ── Live View group ── -->
|
||||
{% set lv_active = request.endpoint in ['main.devices','main.device_edit','main.device_detail','main.logs','main.templates','main.stats'] %}
|
||||
<li class="nav-group" id="group-liveview">
|
||||
<div class="nav-group-header {% if lv_active %}open{% endif %}"
|
||||
onclick="toggleGroup('group-liveview')">
|
||||
<i class="fas fa-heartbeat group-icon"></i>
|
||||
<span class="group-label">Live View</span>
|
||||
<i class="fas fa-chevron-right chevron"></i>
|
||||
</div>
|
||||
<ul class="nav-group-children {% if lv_active %}open{% endif %}" style="list-style:none;padding-left:10px;margin:0;">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
|
||||
<i class="fas fa-list-alt"></i>
|
||||
Logs
|
||||
<a href="{{ url_for('main.devices') }}" class="nav-link {% if request.endpoint in ['main.devices','main.device_edit','main.device_detail'] %}active{% endif %}">
|
||||
<i class="fas fa-satellite-dish"></i>Device Health
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.templates') }}" class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
Templates
|
||||
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
|
||||
<i class="fas fa-list-alt"></i>Logs
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.stats') }}" class="nav-link {% if request.endpoint == 'main.stats' %}active{% endif %}">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
Statistics
|
||||
<i class="fas fa-chart-bar"></i>Statistics
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.templates') }}" class="nav-link {% if request.endpoint == 'main.templates' %}active{% endif %}">
|
||||
<i class="fas fa-file-alt"></i>Templates
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<div class="nav-section">Automation</div>
|
||||
<!-- ── Automation group ── -->
|
||||
{% set auto_active = request.endpoint and request.endpoint.startswith('ansible_web') %}
|
||||
<li class="nav-group" id="group-automation">
|
||||
<div class="nav-group-header {% if auto_active %}open{% endif %}"
|
||||
onclick="toggleGroup('group-automation')">
|
||||
<i class="fas fa-robot group-icon"></i>
|
||||
<span class="group-label">Automation</span>
|
||||
<i class="fas fa-chevron-right chevron"></i>
|
||||
</div>
|
||||
<ul class="nav-group-children {% if auto_active %}open{% endif %}" style="list-style:none;padding-left:10px;margin:0;">
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('ansible_web.devices') }}" class="nav-link {% if request.endpoint == 'ansible_web.devices' %}active{% endif %}">
|
||||
<i class="fas fa-network-wired"></i>
|
||||
Remote Devices
|
||||
<i class="fas fa-network-wired"></i>Remote Devices
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('ansible_web.playbooks') }}" class="nav-link {% if request.endpoint == 'ansible_web.playbooks' %}active{% endif %}">
|
||||
<i class="fas fa-play"></i>
|
||||
Playbooks
|
||||
<i class="fas fa-book-open"></i>Playbooks
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('ansible_web.execute') }}" class="nav-link {% if request.endpoint == 'ansible_web.execute' %}active{% endif %}">
|
||||
<i class="fas fa-terminal"></i>
|
||||
Execute
|
||||
<i class="fas fa-terminal"></i>Execute
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('ansible_web.ssh_setup') }}" class="nav-link {% if request.endpoint == 'ansible_web.ssh_setup' %}active{% endif %}">
|
||||
<i class="fas fa-key"></i>
|
||||
SSH Setup
|
||||
<i class="fas fa-key"></i>SSH Setup
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('ansible_web.failure_reports') }}" class="nav-link {% if request.endpoint == 'ansible_web.failure_reports' %}active{% endif %}">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Failure Reports
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<div class="nav-section">Server</div>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.logs') }}" class="nav-link {% if request.endpoint == 'main.logs' %}active{% endif %}">
|
||||
<i class="fas fa-stream"></i>
|
||||
Live Logs
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="nav-link" onclick="confirmAction('Refresh Data', 'Are you sure you want to refresh all data?')">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Refresh Data
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="nav-link" onclick="showInfo()">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
System Info
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('main.admin') }}" class="nav-link admin-link {% if request.endpoint == 'main.admin' %}active{% endif %}">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
Admin
|
||||
<i class="fas fa-exclamation-triangle"></i>Failure Reports
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- ── Admin standalone ── -->
|
||||
<li class="nav-item" style="padding: 0 0px;">
|
||||
<a href="{{ url_for('main.admin') }}" class="nav-link admin-link {% if request.endpoint == 'main.admin' %}active{% endif %}">
|
||||
<i class="fas fa-shield-alt"></i>Admin
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<button class="dark-mode-btn" id="darkModeToggle" onclick="toggleDarkMode()" title="Toggle dark / light mode">
|
||||
<i class="fas fa-moon" id="darkModeIcon"></i>
|
||||
<span id="darkModeLabel">Dark Mode</span>
|
||||
<span class="toggle-track"></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
@@ -399,17 +975,46 @@
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// ── Accordion sidebar ──────────────────────────────────────────
|
||||
function toggleGroup(groupId) {
|
||||
const group = document.getElementById(groupId);
|
||||
const header = group.querySelector('.nav-group-header');
|
||||
const children = group.querySelector('.nav-group-children');
|
||||
const isOpen = header.classList.contains('open');
|
||||
|
||||
header.classList.toggle('open', !isOpen);
|
||||
children.classList.toggle('open', !isOpen);
|
||||
|
||||
// Persist state
|
||||
const state = JSON.parse(localStorage.getItem('sidebarState') || '{}');
|
||||
state[groupId] = !isOpen;
|
||||
localStorage.setItem('sidebarState', JSON.stringify(state));
|
||||
}
|
||||
|
||||
function restoreSidebarState() {
|
||||
const state = JSON.parse(localStorage.getItem('sidebarState') || '{}');
|
||||
document.querySelectorAll('.nav-group').forEach(function(group) {
|
||||
const id = group.id;
|
||||
// If server already rendered it open (active page), leave it
|
||||
const header = group.querySelector('.nav-group-header');
|
||||
const children = group.querySelector('.nav-group-children');
|
||||
if (header.classList.contains('open')) return; // already active
|
||||
if (state[id] === true) {
|
||||
header.classList.add('open');
|
||||
children.classList.add('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile sidebar toggle
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
document.getElementById('sidebar').classList.toggle('mobile-open');
|
||||
}
|
||||
|
||||
// Close sidebar when clicking outside on mobile
|
||||
document.addEventListener('click', function(event) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggle = document.querySelector('.mobile-menu-toggle');
|
||||
|
||||
if (window.innerWidth <= 768 &&
|
||||
!sidebar.contains(event.target) &&
|
||||
!toggle.contains(event.target)) {
|
||||
@@ -417,37 +1022,30 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Utility functions
|
||||
function confirmAction(title, message) {
|
||||
if (confirm(message)) {
|
||||
// Add refresh logic here
|
||||
window.location.reload();
|
||||
}
|
||||
// ── Dark mode ─────────────────────────────────────────────────
|
||||
function toggleDarkMode() {
|
||||
const isDark = document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', isDark ? 'enabled' : 'disabled');
|
||||
updateDarkModeButton(isDark);
|
||||
}
|
||||
|
||||
function showInfo() {
|
||||
alert('Server Monitoring System v2.0\nDeveloped for enhanced device monitoring\nFeatures: Device management, Log monitoring, Ansible automation');
|
||||
function updateDarkModeButton(isDark) {
|
||||
const icon = document.getElementById('darkModeIcon');
|
||||
const label = document.getElementById('darkModeLabel');
|
||||
if (!icon || !label) return;
|
||||
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
||||
label.textContent = isDark ? 'Light Mode' : 'Dark Mode';
|
||||
}
|
||||
|
||||
// Auto-refresh functionality (optional)
|
||||
let autoRefreshInterval;
|
||||
|
||||
function startAutoRefresh(seconds = 30) {
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
window.location.reload();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize based on page
|
||||
// ── Init ──────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add any page-specific initialization here
|
||||
console.log('Server Monitoring Dashboard loaded');
|
||||
// Restore dark mode
|
||||
if (localStorage.getItem('darkMode') === 'enabled') {
|
||||
document.body.classList.add('dark-mode');
|
||||
updateDarkModeButton(true);
|
||||
}
|
||||
// Restore sidebar accordion state
|
||||
restoreSidebarState();
|
||||
});
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Devices – {{ app_name }}{% endblock %}
|
||||
{% block page_title %}Devices{% endblock %}
|
||||
{% block page_title %}Device Health{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.mac-badge { font-size: 0.75rem; letter-spacing: 0.03em; }
|
||||
.sync-ok { color: #2ecc71; }
|
||||
.sync-old { color: #e74c3c; }
|
||||
.tbl-sm td, .tbl-sm th { padding: 0.45rem 0.6rem; font-size: 0.88rem; vertical-align: middle; }
|
||||
|
||||
/* Sync status pills */
|
||||
.sync-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sync-ok { background: #d4edda; color: #155724; }
|
||||
.sync-required { background: #fff3cd; color: #856404; }
|
||||
.sync-pending { background: #cce5ff; color: #004085; }
|
||||
.sync-never { background: #e2e3e5; color: #495057; }
|
||||
|
||||
body.dark-mode .sync-ok { background: #1a3a25; color: #6fcf97; }
|
||||
body.dark-mode .sync-required { background: #3a2e00; color: #f0c040; }
|
||||
body.dark-mode .sync-pending { background: #003060; color: #7ec8e3; }
|
||||
body.dark-mode .sync-never { background: #2a2a2a; color: #999; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -60,7 +79,7 @@
|
||||
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}" class="card text-center h-100 text-decoration-none">
|
||||
<div class="card-body py-3">
|
||||
<h4 class="mb-0 {% if pending_count > 0 %}text-danger{% else %}text-secondary{% endif %}">{{ pending_count }}</h4>
|
||||
<small class="text-muted">Pending Requests</small>
|
||||
<small class="text-muted">Pending Approval</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -88,7 +107,7 @@
|
||||
<th>Work Place</th>
|
||||
<th>Hostname</th>
|
||||
<th>IP</th>
|
||||
<th>MAC Address</th>
|
||||
<th>MAC / Type</th>
|
||||
<th>Status</th>
|
||||
<th>Logs</th>
|
||||
<th>Last Seen</th>
|
||||
@@ -98,20 +117,35 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
{% set is_wmt = device.mac_address is not none and device.mac_address != '' %}
|
||||
{% set has_pending = is_wmt and (pending_by_mac.get(device.mac_address, 0) > 0) %}
|
||||
<tr class="device-row"
|
||||
data-search="{{ device.hostname|lower }} {{ device.device_ip }} {{ (device.nume_masa or '')|lower }} {{ (device.mac_address or '')|lower }}">
|
||||
|
||||
<!-- Work Place -->
|
||||
<td>
|
||||
<strong>{{ device.nume_masa or '—' }}</strong>
|
||||
{% if is_wmt %}
|
||||
<br><small class="text-muted"><i class="fas fa-tablet-alt me-1"></i>WMT</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Hostname -->
|
||||
<td>{{ device.hostname }}</td>
|
||||
|
||||
<!-- IP -->
|
||||
<td><code>{{ device.device_ip }}</code></td>
|
||||
|
||||
<!-- MAC / Type -->
|
||||
<td>
|
||||
{% if device.mac_address %}
|
||||
{% if is_wmt %}
|
||||
<code class="mac-badge">{{ device.mac_address }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td>
|
||||
{% if device.status == 'active' %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
@@ -121,24 +155,55 @@
|
||||
<span class="badge bg-danger">Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Logs -->
|
||||
<td>{{ device_log_counts.get(device.id, 0) }}</td>
|
||||
|
||||
<!-- Last Seen -->
|
||||
<td class="text-muted">
|
||||
{% if device.last_seen %}
|
||||
{{ device.last_seen | local_dt }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Config Sync status -->
|
||||
<td>
|
||||
{% if device.mac_address and device.config_updated_at %}
|
||||
<span class="sync-ok" title="{{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') }}">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
{{ device.config_updated_at | local_dt('%m-%d %H:%M') }}
|
||||
</span>
|
||||
{% elif device.mac_address %}
|
||||
<span class="sync-old"><i class="fas fa-clock"></i> Never</span>
|
||||
{% else %}
|
||||
{% if not is_wmt %}
|
||||
<span class="text-muted">—</span>
|
||||
|
||||
{% elif has_pending %}
|
||||
{# Unknown device waiting for admin approval #}
|
||||
<a href="{{ url_for('wmt_web.update_requests', status='pending') }}"
|
||||
class="sync-pill sync-pending text-decoration-none"
|
||||
title="Admin approval required for this device">
|
||||
<i class="fas fa-user-clock"></i> Pending Approval
|
||||
</a>
|
||||
|
||||
{% elif device.config_synced_at %}
|
||||
{# Device checked in and server confirmed configs match #}
|
||||
<span class="sync-pill sync-ok"
|
||||
title="Last confirmed in-sync: {{ device.config_synced_at | local_dt('%Y-%m-%d %H:%M:%S') }}">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
OK · {{ device.config_synced_at | local_dt('%m-%d %H:%M') }}
|
||||
</span>
|
||||
|
||||
{% elif device.config_updated_at %}
|
||||
{# Admin pushed config but client hasn't confirmed sync yet #}
|
||||
<span class="sync-pill sync-required"
|
||||
title="Config updated {{ device.config_updated_at | local_dt('%Y-%m-%d %H:%M:%S') }} – awaiting client check-in">
|
||||
<i class="fas fa-arrow-circle-down"></i> Awaiting client
|
||||
</span>
|
||||
|
||||
{% else %}
|
||||
{# WMT device registered but never checked in #}
|
||||
<span class="sync-pill sync-never"
|
||||
title="Client has not checked in yet">
|
||||
<i class="fas fa-question-circle"></i> Never synced
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{{ url_for('main.device_detail', device_id=device.id) }}"
|
||||
class="btn btn-sm btn-outline-primary py-0" title="View Details">
|
||||
@@ -175,6 +240,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync legend -->
|
||||
<div class="d-flex flex-wrap gap-3 mt-3 ms-1">
|
||||
<small class="text-muted"><strong>Config Sync legend:</strong></small>
|
||||
<small><span class="sync-pill sync-ok"><i class="fas fa-check-circle"></i> OK</span> — client confirmed configs match</small>
|
||||
<small><span class="sync-pill sync-required"><i class="fas fa-arrow-circle-down"></i> Awaiting client</span> — server pushed new config, waiting for client check-in</small>
|
||||
<small><span class="sync-pill sync-pending"><i class="fas fa-user-clock"></i> Pending Approval</span> — new/unknown device waiting for admin</small>
|
||||
<small><span class="sync-pill sync-never"><i class="fas fa-question-circle"></i> Never synced</span> — registered but hasn't checked in</small>
|
||||
</div>
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<div class="modal fade" id="addDeviceModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@@ -276,3 +350,4 @@ async function submitAddDevice(event) {
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -61,6 +61,20 @@
|
||||
margin: 10px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Dark mode overrides for this page */
|
||||
body.dark-mode .filter-container {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444444;
|
||||
}
|
||||
|
||||
body.dark-mode .log-entry:hover {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
body.dark-mode .log-meta {
|
||||
color: #888888;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user