diff --git a/package/secubox/luci-app-haproxy/Makefile b/package/secubox/luci-app-haproxy/Makefile index 7b401dd3..14461d98 100644 --- a/package/secubox/luci-app-haproxy/Makefile +++ b/package/secubox/luci-app-haproxy/Makefile @@ -11,7 +11,7 @@ LUCI_PKGARCH:=all PKG_NAME:=luci-app-haproxy PKG_VERSION:=1.0.0 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_MAINTAINER:=CyberMind PKG_LICENSE:=MIT diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css index 6f6ba1e8..f3438464 100644 --- a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/haproxy/dashboard.css @@ -1,315 +1,1080 @@ -/* HAProxy Dashboard Styles */ +/** + * HAProxy Dashboard Styles (SecuBox Design System v0.3.0) + * Enhanced dashboard with consistent SecuBox theme + * Copyright (C) 2025 CyberMind.fr + */ -.haproxy-dashboard { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1rem; - margin-bottom: 1.5rem; +/* === Hide LuCI Left Menu === */ +#mainmenu, +.main-left, +#maincontent > .container > .main-left { + display: none !important; } -.haproxy-card { - background: var(--background-color-high, #fff); - border: 1px solid var(--border-color-medium, #ddd); - border-radius: 8px; - padding: 1.25rem; - box-shadow: 0 2px 4px rgba(0,0,0,0.05); +.main-right, +#maincontent > .container > .main-right { + width: 100% !important; + margin-left: 0 !important; + padding-left: 0 !important; } -.haproxy-card h3 { - margin: 0 0 1rem 0; - font-size: 1rem; - color: var(--text-color-medium, #666); - font-weight: 500; +#maincontent > .container { + display: block !important; } -.haproxy-card .stat-value { - font-size: 2rem; +/* === Import Fonts === */ +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap'); + +/* === Design System Variables === */ +:root { + /* Light Mode */ + --hp-text-primary: #0f172a; + --hp-text-secondary: #475569; + --hp-text-muted: #64748b; + --hp-bg-primary: #ffffff; + --hp-bg-secondary: #f8fafc; + --hp-bg-tertiary: #f1f5f9; + --hp-bg-card: #ffffff; + --hp-border: #e2e8f0; + --hp-hover-bg: #f8fafc; + --hp-shadow: rgba(0, 0, 0, 0.08); + --hp-primary: #6366f1; + --hp-primary-end: #8b5cf6; + --hp-success: #22c55e; + --hp-success-soft: rgba(34, 197, 94, 0.12); + --hp-danger: #ef4444; + --hp-danger-soft: rgba(239, 68, 68, 0.12); + --hp-warning: #f59e0b; + --hp-warning-soft: rgba(245, 158, 11, 0.12); + --hp-info: #3b82f6; + --hp-info-soft: rgba(59, 130, 246, 0.12); +} + +[data-theme="dark"] { + --hp-text-primary: #fafafa; + --hp-text-secondary: #a0a0b0; + --hp-text-muted: #6b7280; + --hp-bg-primary: #0a0a0f; + --hp-bg-secondary: #12121a; + --hp-bg-tertiary: #1a1a24; + --hp-bg-card: #12121a; + --hp-border: #2a2a35; + --hp-hover-bg: #1a1a24; + --hp-shadow: rgba(0, 0, 0, 0.4); +} + +/* === Global Typography === */ +.haproxy-dashboard, +.hp-page-header, +.hp-card { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; +} + +code, +.hp-mono, +.hp-stat-value { + font-family: 'JetBrains Mono', 'Courier New', monospace; +} + +/* === Page Header === */ +.hp-page-header { + margin-bottom: 24px; + padding: 24px; + background: var(--hp-bg-card); + border-radius: 16px; + border: 1px solid var(--hp-border); + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 20px; + box-shadow: 0 1px 3px var(--hp-shadow); +} + +.hp-page-title { + font-size: 24px; font-weight: 700; - color: var(--text-color-high, #333); -} - -.haproxy-card .stat-label { - font-size: 0.875rem; - color: var(--text-color-medium, #666); - margin-top: 0.25rem; -} - -.haproxy-status { + margin: 0; + background: linear-gradient(135deg, var(--hp-primary), var(--hp-primary-end)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; display: flex; align-items: center; - gap: 0.5rem; + gap: 12px; } -.haproxy-status-indicator { - width: 12px; - height: 12px; +.hp-page-title-icon { + font-size: 28px; + line-height: 1; + -webkit-text-fill-color: initial; +} + +.hp-page-subtitle { + margin: 4px 0 0 0; + font-size: 14px; + color: var(--hp-text-secondary); + font-weight: 400; +} + +.hp-header-badges { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.hp-header-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--hp-bg-tertiary); + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: var(--hp-text-primary); +} + +.hp-badge-dot { + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; } -.haproxy-status-indicator.running { - background: #22c55e; +.hp-badge-dot.running { + background: var(--hp-success); box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); + animation: hp-pulse 2s infinite; } -.haproxy-status-indicator.stopped { - background: #ef4444; +.hp-badge-dot.stopped { + background: var(--hp-danger); } -.haproxy-status-indicator.unknown { - background: #f59e0b; +.hp-badge-dot.warning { + background: var(--hp-warning); } -.haproxy-actions { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-top: 1rem; +@keyframes hp-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } } -.haproxy-actions .cbi-button { - padding: 0.5rem 1rem; -} - -/* Vhost table styles */ -.haproxy-vhosts-table { - width: 100%; - border-collapse: collapse; - margin-top: 1rem; -} - -.haproxy-vhosts-table th, -.haproxy-vhosts-table td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid var(--border-color-low, #eee); -} - -.haproxy-vhosts-table th { - font-weight: 600; - color: var(--text-color-medium, #666); - background: var(--background-color-low, #f9f9f9); -} - -.haproxy-vhosts-table tr:hover td { - background: var(--background-color-low, #f9f9f9); -} - -.haproxy-badge { - display: inline-block; - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 500; -} - -.haproxy-badge.ssl { - background: #dbeafe; - color: #1d4ed8; -} - -.haproxy-badge.acme { - background: #dcfce7; - color: #166534; -} - -.haproxy-badge.enabled { - background: #dcfce7; - color: #166534; -} - -.haproxy-badge.disabled { - background: #fee2e2; - color: #991b1b; -} - -/* Backend cards */ -.haproxy-backends-grid { +/* === Stats Grid === */ +.hp-stats-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 1rem; - margin-top: 1rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; } -.haproxy-backend-card { - background: var(--background-color-high, #fff); - border: 1px solid var(--border-color-medium, #ddd); - border-radius: 8px; +.hp-stat-card { + background: var(--hp-bg-card); + border: 1px solid var(--hp-border); + border-radius: 16px; + padding: 20px; + position: relative; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.hp-stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--hp-primary), var(--hp-primary-end)); + opacity: 0; + transition: opacity 0.3s ease; +} + +.hp-stat-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px var(--hp-shadow); + border-color: var(--hp-primary); +} + +.hp-stat-card:hover::before { + opacity: 1; +} + +.hp-stat-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.hp-stat-value { + font-size: 36px; + font-weight: 700; + color: var(--hp-text-primary); + line-height: 1.1; +} + +.hp-stat-value.success { color: var(--hp-success); } +.hp-stat-value.danger { color: var(--hp-danger); } +.hp-stat-value.warning { color: var(--hp-warning); } +.hp-stat-value.info { color: var(--hp-info); } + +.hp-stat-label { + font-size: 13px; + color: var(--hp-text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; + margin-top: 4px; +} + +.hp-stat-trend { + font-size: 12px; + color: var(--hp-text-muted); + margin-top: 8px; + display: flex; + align-items: center; + gap: 4px; +} + +.hp-stat-trend.up { color: var(--hp-success); } +.hp-stat-trend.down { color: var(--hp-danger); } + +/* === Cards === */ +.hp-card { + background: var(--hp-bg-card); + border-radius: 16px; + border: 1px solid var(--hp-border); + padding: 0; + margin-bottom: 20px; + box-shadow: 0 1px 3px var(--hp-shadow); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; overflow: hidden; } -.haproxy-backend-header { - padding: 1rem; - background: var(--background-color-low, #f9f9f9); - border-bottom: 1px solid var(--border-color-low, #eee); +.hp-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--hp-primary), var(--hp-primary-end)); + opacity: 0; + transition: opacity 0.3s ease; +} + +.hp-card:hover::before { + opacity: 1; +} + +.hp-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--hp-shadow); + border-color: var(--hp-primary); +} + +.hp-card-header { display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--hp-border); + background: var(--hp-bg-secondary); } -.haproxy-backend-header h4 { +.hp-card-title { + font-size: 16px; + font-weight: 600; + color: var(--hp-text-primary); margin: 0; - font-size: 1rem; -} - -.haproxy-backend-servers { - padding: 0.5rem 0; -} - -.haproxy-server-item { display: flex; - justify-content: space-between; align-items: center; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border-color-low, #eee); + gap: 10px; } -.haproxy-server-item:last-child { +.hp-card-title-icon { + font-size: 20px; +} + +.hp-card-actions { + display: flex; + gap: 8px; +} + +.hp-card-body { + padding: 20px; +} + +.hp-card-body.no-padding { + padding: 0; +} + +/* === Buttons === */ +.hp-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: 8px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border: none; +} + +.hp-btn-primary { + background: linear-gradient(135deg, var(--hp-primary), var(--hp-primary-end)); + color: white; +} + +.hp-btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); +} + +.hp-btn-secondary { + background: var(--hp-bg-tertiary); + color: var(--hp-text-primary); + border: 1px solid var(--hp-border); +} + +.hp-btn-secondary:hover { + background: var(--hp-hover-bg); + border-color: var(--hp-primary); +} + +.hp-btn-success { + background: var(--hp-success); + color: white; +} + +.hp-btn-danger { + background: var(--hp-danger); + color: white; +} + +.hp-btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.hp-btn-icon { + padding: 8px; + min-width: 36px; + justify-content: center; +} + +.hp-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; +} + +/* === Badges === */ +.hp-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.hp-badge-success { + background: var(--hp-success-soft); + color: var(--hp-success); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.hp-badge-danger { + background: var(--hp-danger-soft); + color: var(--hp-danger); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.hp-badge-warning { + background: var(--hp-warning-soft); + color: var(--hp-warning); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.hp-badge-info { + background: var(--hp-info-soft); + color: var(--hp-info); + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.hp-badge-primary { + background: rgba(99, 102, 241, 0.12); + color: var(--hp-primary); + border: 1px solid rgba(99, 102, 241, 0.3); +} + +/* === Health Grid === */ +.hp-health-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.hp-health-item { + text-align: center; + padding: 16px; + background: var(--hp-bg-tertiary); + border-radius: 12px; + transition: all 0.3s ease; +} + +.hp-health-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--hp-shadow); +} + +.hp-health-icon { + font-size: 28px; + margin-bottom: 8px; +} + +.hp-health-label { + font-weight: 600; + font-size: 14px; + color: var(--hp-text-primary); + margin-bottom: 4px; +} + +.hp-health-value { + font-size: 12px; + color: var(--hp-text-secondary); +} + +.hp-health-value.success { color: var(--hp-success); } +.hp-health-value.danger { color: var(--hp-danger); } +.hp-health-value.warning { color: var(--hp-warning); } + +/* === Tables === */ +.hp-table { + width: 100%; + border-collapse: collapse; +} + +.hp-table th, +.hp-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--hp-border); +} + +.hp-table th { + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--hp-text-secondary); + background: var(--hp-bg-secondary); +} + +.hp-table tbody tr { + transition: background 0.2s ease; +} + +.hp-table tbody tr:hover { + background: var(--hp-hover-bg); +} + +.hp-table tbody tr:last-child td { border-bottom: none; } -.haproxy-server-info { - display: flex; - flex-direction: column; +/* === Backend Cards === */ +.hp-backends-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; } -.haproxy-server-name { - font-weight: 500; +.hp-backend-card { + background: var(--hp-bg-card); + border: 1px solid var(--hp-border); + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; } -.haproxy-server-address { - font-size: 0.875rem; - color: var(--text-color-medium, #666); - font-family: monospace; +.hp-backend-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--hp-shadow); + border-color: var(--hp-primary); } -.haproxy-server-status { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.haproxy-server-weight { - font-size: 0.75rem; - background: var(--background-color-low, #f5f5f5); - padding: 0.25rem 0.5rem; - border-radius: 4px; -} - -/* Certificate list */ -.haproxy-cert-list { - margin-top: 1rem; -} - -.haproxy-cert-item { +.hp-backend-header { display: flex; justify-content: space-between; align-items: center; - padding: 1rem; - background: var(--background-color-high, #fff); - border: 1px solid var(--border-color-medium, #ddd); - border-radius: 8px; - margin-bottom: 0.5rem; + padding: 16px; + background: var(--hp-bg-secondary); + border-bottom: 1px solid var(--hp-border); } -.haproxy-cert-domain { +.hp-backend-name { + font-weight: 600; + font-size: 16px; + color: var(--hp-text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.hp-backend-meta { + display: flex; + gap: 12px; + padding: 12px 16px; + background: var(--hp-bg-tertiary); + font-size: 13px; +} + +.hp-backend-meta-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--hp-text-secondary); +} + +.hp-backend-servers { + padding: 0; +} + +.hp-server-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--hp-border); + transition: background 0.2s ease; +} + +.hp-server-item:last-child { + border-bottom: none; +} + +.hp-server-item:hover { + background: var(--hp-hover-bg); +} + +.hp-server-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.hp-server-name { font-weight: 500; - font-family: monospace; + color: var(--hp-text-primary); } -.haproxy-cert-type { - font-size: 0.875rem; - color: var(--text-color-medium, #666); +.hp-server-address { + font-size: 13px; + color: var(--hp-text-muted); + font-family: 'JetBrains Mono', monospace; } -/* Form sections */ -.haproxy-form-section { - background: var(--background-color-high, #fff); - border: 1px solid var(--border-color-medium, #ddd); - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 1rem; +.hp-server-status { + display: flex; + align-items: center; + gap: 8px; } -.haproxy-form-section h3 { - margin: 0 0 1rem 0; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-color-low, #eee); +.hp-server-weight { + font-size: 12px; + padding: 4px 8px; + background: var(--hp-bg-tertiary); + border-radius: 4px; + color: var(--hp-text-secondary); } -/* Stats iframe */ -.haproxy-stats-frame { - width: 100%; - height: 600px; - border: 1px solid var(--border-color-medium, #ddd); - border-radius: 8px; +/* === Certificate List === */ +.hp-cert-list { + display: flex; + flex-direction: column; + gap: 12px; } -/* Logs viewer */ -.haproxy-logs { - background: #1e1e1e; - color: #d4d4d4; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-size: 0.8125rem; - line-height: 1.5; - padding: 1rem; - border-radius: 8px; +.hp-cert-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background: var(--hp-bg-tertiary); + border-radius: 12px; + transition: all 0.3s ease; +} + +.hp-cert-item:hover { + transform: translateX(4px); + box-shadow: 0 2px 8px var(--hp-shadow); +} + +.hp-cert-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.hp-cert-domain { + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + color: var(--hp-text-primary); +} + +.hp-cert-meta { + display: flex; + gap: 16px; + font-size: 13px; + color: var(--hp-text-secondary); +} + +.hp-cert-expiry { + display: flex; + align-items: center; + gap: 4px; +} + +.hp-cert-expiry.expiring { + color: var(--hp-warning); +} + +.hp-cert-expiry.expired { + color: var(--hp-danger); +} + +/* === Logs Viewer === */ +.hp-logs { + background: #1e1e2e; + color: #cdd6f4; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + line-height: 1.6; + padding: 16px; + border-radius: 12px; max-height: 400px; overflow: auto; white-space: pre-wrap; word-wrap: break-word; } -/* Modal styles */ -.haproxy-modal { +.hp-logs .log-error { + color: #f38ba8; +} + +.hp-logs .log-warn { + color: #fab387; +} + +.hp-logs .log-info { + color: #89b4fa; +} + +.hp-logs .log-success { + color: #a6e3a1; +} + +/* === Traffic Chart === */ +.hp-traffic-chart { + height: 200px; + position: relative; + background: var(--hp-bg-tertiary); + border-radius: 12px; + padding: 16px; + overflow: hidden; +} + +.hp-sparkline { + display: flex; + align-items: flex-end; + height: 100%; + gap: 2px; +} + +.hp-sparkline-bar { + flex: 1; + background: linear-gradient(180deg, var(--hp-primary), var(--hp-primary-end)); + border-radius: 2px 2px 0 0; + min-height: 4px; + transition: height 0.3s ease; +} + +/* === Quick Actions === */ +.hp-quick-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.hp-action-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px 24px; + background: var(--hp-bg-card); + border: 1px solid var(--hp-border); + border-radius: 12px; + cursor: pointer; + transition: all 0.3s ease; + min-width: 100px; +} + +.hp-action-btn:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px var(--hp-shadow); + border-color: var(--hp-primary); +} + +.hp-action-btn:hover .hp-action-icon { + transform: scale(1.1); +} + +.hp-action-icon { + font-size: 28px; + transition: transform 0.3s ease; +} + +.hp-action-label { + font-size: 13px; + font-weight: 500; + color: var(--hp-text-primary); +} + +/* === Grid Layouts === */ +.hp-grid { + display: grid; + gap: 20px; +} + +.hp-grid-2 { + grid-template-columns: repeat(2, 1fr); +} + +.hp-grid-3 { + grid-template-columns: repeat(3, 1fr); +} + +.hp-row { + display: flex; + gap: 20px; +} + +.hp-row > * { + flex: 1; +} + +/* === Form Sections === */ +.hp-form-section { + background: var(--hp-bg-card); + border: 1px solid var(--hp-border); + border-radius: 16px; + padding: 24px; + margin-bottom: 20px; +} + +.hp-form-section h3 { + margin: 0 0 20px 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--hp-border); + font-size: 18px; + font-weight: 600; + color: var(--hp-text-primary); +} + +.hp-form-group { + margin-bottom: 16px; +} + +.hp-form-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 14px; + color: var(--hp-text-secondary); +} + +.hp-form-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--hp-border); + border-radius: 8px; + background: var(--hp-bg-tertiary); + color: var(--hp-text-primary); + font-size: 14px; + transition: all 0.2s ease; +} + +.hp-form-input:focus { + outline: none; + border-color: var(--hp-primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); +} + +.hp-form-checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.hp-form-checkbox input { + width: 18px; + height: 18px; + accent-color: var(--hp-primary); +} + +/* === Empty States === */ +.hp-empty { + text-align: center; + padding: 40px 20px; + color: var(--hp-text-muted); +} + +.hp-empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.hp-empty-text { + font-size: 16px; + margin-bottom: 8px; +} + +.hp-empty-hint { + font-size: 14px; + color: var(--hp-text-muted); +} + +/* === Toasts === */ +.hp-toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 16px 24px; + background: var(--hp-bg-card); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 10000; + animation: hp-slide-in 0.3s ease; + display: flex; + align-items: center; + gap: 12px; + font-weight: 500; +} + +.hp-toast.success { + border-left: 4px solid var(--hp-success); +} + +.hp-toast.error { + border-left: 4px solid var(--hp-danger); +} + +.hp-toast.warning { + border-left: 4px solid var(--hp-warning); +} + +@keyframes hp-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* === Modal === */ +.hp-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; + animation: hp-fade-in 0.2s ease; } -.haproxy-modal-content { - background: var(--background-color-high, #fff); - border-radius: 8px; - padding: 1.5rem; +.hp-modal { + background: var(--hp-bg-card); + border-radius: 16px; max-width: 500px; width: 90%; max-height: 80vh; - overflow-y: auto; + overflow: hidden; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.3); + animation: hp-zoom-in 0.3s ease; } -.haproxy-modal-header { +.hp-modal-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; + padding: 20px 24px; + border-bottom: 1px solid var(--hp-border); + background: var(--hp-bg-secondary); } -.haproxy-modal-header h3 { +.hp-modal-title { + font-size: 18px; + font-weight: 600; margin: 0; } -.haproxy-modal-close { +.hp-modal-close { background: none; border: none; - font-size: 1.5rem; + font-size: 24px; cursor: pointer; - color: var(--text-color-medium, #666); + color: var(--hp-text-muted); + padding: 4px; + line-height: 1; + transition: color 0.2s ease; +} + +.hp-modal-close:hover { + color: var(--hp-danger); +} + +.hp-modal-body { + padding: 24px; + overflow-y: auto; + max-height: 60vh; +} + +.hp-modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid var(--hp-border); + background: var(--hp-bg-secondary); +} + +@keyframes hp-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes hp-zoom-in { + from { + transform: scale(0.95); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +/* === Stats Frame (for HAProxy stats) === */ +.hp-stats-frame { + width: 100%; + height: 600px; + border: none; + border-radius: 12px; + background: var(--hp-bg-tertiary); +} + +/* === Connection Info === */ +.hp-connection-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +.hp-connection-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--hp-bg-tertiary); + border-radius: 8px; +} + +.hp-connection-label { + font-size: 14px; + color: var(--hp-text-secondary); +} + +.hp-connection-value { + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + color: var(--hp-text-primary); +} + +.hp-connection-value a { + color: var(--hp-primary); + text-decoration: none; +} + +.hp-connection-value a:hover { + text-decoration: underline; +} + +/* === Responsive === */ +@media (max-width: 1024px) { + .hp-grid-2, + .hp-grid-3 { + grid-template-columns: 1fr; + } + + .hp-row { + flex-direction: column; + } } -/* Responsive adjustments */ @media (max-width: 768px) { - .haproxy-dashboard { + .hp-page-header { + flex-direction: column; + align-items: flex-start; + } + + .hp-stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .hp-backends-grid { grid-template-columns: 1fr; } - .haproxy-backends-grid { - grid-template-columns: 1fr; + .hp-quick-actions { + justify-content: center; + } + + .hp-action-btn { + flex: 1; + min-width: auto; + } +} + +@media (max-width: 480px) { + .hp-stats-grid { + grid-template-columns: 1fr; + } + + .hp-stat-value { + font-size: 28px; + } + + .hp-btn { + width: 100%; + justify-content: center; } } diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js index cb40adec..0f415671 100644 --- a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/overview.js @@ -2,15 +2,34 @@ 'require view'; 'require dom'; 'require ui'; +'require poll'; 'require haproxy.api as api'; +/** + * HAProxy Dashboard - Overview + * Enhanced dashboard with stats, health monitoring, and quick actions + * Copyright (C) 2025 CyberMind.fr + */ + return view.extend({ + title: _('HAProxy Dashboard'), + + data: null, + load: function() { + // Load CSS + var cssLink = document.createElement('link'); + cssLink.rel = 'stylesheet'; + cssLink.href = L.resource('haproxy/dashboard.css'); + document.head.appendChild(cssLink); + return api.getDashboardData(); }, render: function(data) { var self = this; + this.data = data; + var status = data.status || {}; var vhosts = data.vhosts || []; var backends = data.backends || []; @@ -20,222 +39,561 @@ return view.extend({ var haproxyRunning = status.haproxy_running; var enabled = status.enabled; - var statusText = haproxyRunning ? 'Running' : (containerRunning ? 'Container Running' : 'Stopped'); - var statusClass = haproxyRunning ? 'running' : (containerRunning ? 'unknown' : 'stopped'); + // Main wrapper + var view = E('div', { 'class': 'haproxy-dashboard' }, [ + // Page Header + this.renderPageHeader(status), - var view = E('div', { 'class': 'cbi-map' }, [ - E('h2', {}, 'HAProxy Load Balancer'), + // Warning banner if not running + !containerRunning ? this.renderWarningBanner() : null, - // Dashboard cards - E('div', { 'class': 'haproxy-dashboard' }, [ - // Status card - E('div', { 'class': 'haproxy-card' }, [ - E('h3', {}, 'Service Status'), - E('div', { 'class': 'haproxy-status' }, [ - E('span', { 'class': 'haproxy-status-indicator ' + statusClass }), - E('span', { 'class': 'stat-value' }, statusText) - ]), - E('div', { 'class': 'haproxy-actions' }, [ - E('button', { - 'class': 'cbi-button cbi-button-apply', - 'click': function() { self.handleStart(); }, - 'disabled': haproxyRunning - }, 'Start'), - E('button', { - 'class': 'cbi-button cbi-button-reset', - 'click': function() { self.handleStop(); }, - 'disabled': !haproxyRunning - }, 'Stop'), - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': function() { self.handleReload(); }, - 'disabled': !haproxyRunning - }, 'Reload') - ]) + // Stats Grid + this.renderStatsGrid(status, vhosts, backends, certificates), + + // Health Check Grid + this.renderHealthGrid(status), + + // Main content grid + E('div', { 'class': 'hp-row' }, [ + // Left column - Vhosts preview + E('div', { 'style': 'flex: 2' }, [ + this.renderVhostsCard(vhosts) ]), - - // Vhosts card - E('div', { 'class': 'haproxy-card' }, [ - E('h3', {}, 'Virtual Hosts'), - E('div', { 'class': 'stat-value' }, String(vhosts.length)), - E('div', { 'class': 'stat-label' }, 'configured domains') - ]), - - // Backends card - E('div', { 'class': 'haproxy-card' }, [ - E('h3', {}, 'Backends'), - E('div', { 'class': 'stat-value' }, String(backends.length)), - E('div', { 'class': 'stat-label' }, 'backend pools') - ]), - - // Certificates card - E('div', { 'class': 'haproxy-card' }, [ - E('h3', {}, 'SSL Certificates'), - E('div', { 'class': 'stat-value' }, String(certificates.length)), - E('div', { 'class': 'stat-label' }, 'certificates') + // Right column - Backends + Certs + E('div', { 'style': 'flex: 1' }, [ + this.renderBackendsCard(backends), + this.renderCertificatesCard(certificates) ]) ]), - // Quick info section - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Connection Details'), - E('table', { 'class': 'table' }, [ - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td', 'style': 'width: 200px' }, 'HTTP Port'), - E('td', { 'class': 'td' }, String(status.http_port || 80)) - ]), - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, 'HTTPS Port'), - E('td', { 'class': 'td' }, String(status.https_port || 443)) - ]), - E('tr', { 'class': 'tr' }, [ - E('td', { 'class': 'td' }, 'Stats Dashboard'), - E('td', { 'class': 'td' }, status.stats_enabled ? - E('a', { 'href': 'http://' + window.location.hostname + ':' + (status.stats_port || 8404) + '/stats', 'target': '_blank' }, - 'http://' + window.location.hostname + ':' + (status.stats_port || 8404) + '/stats') - : 'Disabled') - ]) - ]) - ]), + // Quick Actions + this.renderQuickActions(status), - // Recent vhosts - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Virtual Hosts'), - this.renderVhostsTable(vhosts.slice(0, 5)), - vhosts.length > 5 ? E('p', {}, - E('a', { 'href': L.url('admin/services/haproxy/vhosts') }, 'View all ' + vhosts.length + ' virtual hosts') - ) : null - ]), - - // Quick actions - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Quick Actions'), - E('div', { 'class': 'haproxy-actions' }, [ - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': function() { self.handleValidate(); } - }, 'Validate Config'), - E('button', { - 'class': 'cbi-button cbi-button-action', - 'click': function() { self.handleGenerate(); } - }, 'Regenerate Config'), - E('button', { - 'class': 'cbi-button cbi-button-apply', - 'click': function() { self.handleInstall(); }, - 'disabled': containerRunning - }, 'Install Container') - ]) - ]) + // Connection Info + this.renderConnectionInfo(status) ]); - // Add CSS - var style = E('style', {}, ` - @import url('/luci-static/resources/haproxy/dashboard.css'); - `); - view.insertBefore(style, view.firstChild); + // Setup polling for auto-refresh + poll.add(function() { + return self.refreshDashboard(); + }, 30); return view; }, - renderVhostsTable: function(vhosts) { - if (vhosts.length === 0) { - return E('p', { 'style': 'color: var(--text-color-medium, #666)' }, - 'No virtual hosts configured. Add one in the Virtual Hosts tab.'); - } + renderPageHeader: function(status) { + var haproxyRunning = status.haproxy_running; + var containerRunning = status.container_running; + var statusText = haproxyRunning ? 'Running' : (containerRunning ? 'Container Only' : 'Stopped'); + var statusClass = haproxyRunning ? 'running' : (containerRunning ? 'warning' : 'stopped'); - return E('table', { 'class': 'haproxy-vhosts-table' }, [ - E('thead', {}, [ - E('tr', {}, [ - E('th', {}, 'Domain'), - E('th', {}, 'Backend'), - E('th', {}, 'SSL'), - E('th', {}, 'Status') - ]) + return E('div', { 'class': 'hp-page-header' }, [ + E('div', {}, [ + E('h1', { 'class': 'hp-page-title' }, [ + E('span', { 'class': 'hp-page-title-icon' }, '\u2696\uFE0F'), + 'HAProxy Load Balancer' + ]), + E('p', { 'class': 'hp-page-subtitle' }, 'High-performance reverse proxy and load balancer') ]), - E('tbody', {}, vhosts.map(function(vh) { - return E('tr', {}, [ - E('td', {}, vh.domain), - E('td', {}, vh.backend || '-'), - E('td', {}, [ - vh.ssl ? E('span', { 'class': 'haproxy-badge ssl' }, 'SSL') : null, - vh.acme ? E('span', { 'class': 'haproxy-badge acme' }, 'ACME') : null - ]), - E('td', {}, E('span', { - 'class': 'haproxy-badge ' + (vh.enabled ? 'enabled' : 'disabled') - }, vh.enabled ? 'Enabled' : 'Disabled')) - ]); - })) + E('div', { 'class': 'hp-header-badges' }, [ + E('div', { 'class': 'hp-header-badge' }, [ + E('span', { 'class': 'hp-badge-dot ' + statusClass }), + statusText + ]), + status.version ? E('div', { 'class': 'hp-header-badge' }, 'v' + status.version) : null + ]) ]); }, + renderWarningBanner: function() { + var self = this; + return E('div', { + 'class': 'hp-card', + 'style': 'border-left: 4px solid var(--hp-warning); margin-bottom: 24px;' + }, [ + E('div', { 'class': 'hp-card-body', 'style': 'display: flex; align-items: center; gap: 16px;' }, [ + E('span', { 'style': 'font-size: 32px;' }, '\u26A0\uFE0F'), + E('div', { 'style': 'flex: 1;' }, [ + E('div', { 'style': 'font-weight: 600; font-size: 16px; margin-bottom: 4px;' }, + 'HAProxy Container Not Running'), + E('div', { 'style': 'color: var(--hp-text-secondary);' }, + 'The HAProxy container needs to be installed and started to use load balancing features.') + ]), + E('button', { + 'class': 'hp-btn hp-btn-primary', + 'click': function() { self.handleInstall(); } + }, ['\u{1F4E6}', ' Install Container']) + ]) + ]); + }, + + renderStatsGrid: function(status, vhosts, backends, certificates) { + var activeVhosts = vhosts.filter(function(v) { return v.enabled; }).length; + var activeBackends = backends.filter(function(b) { return b.enabled; }).length; + var validCerts = certificates.filter(function(c) { return !c.expired; }).length; + + return E('div', { 'class': 'hp-stats-grid' }, [ + E('div', { 'class': 'hp-stat-card' }, [ + E('div', { 'class': 'hp-stat-icon' }, '\u{1F310}'), + E('div', { 'class': 'hp-stat-value' }, String(vhosts.length)), + E('div', { 'class': 'hp-stat-label' }, 'Virtual Hosts'), + E('div', { 'class': 'hp-stat-trend' }, activeVhosts + ' active') + ]), + E('div', { 'class': 'hp-stat-card' }, [ + E('div', { 'class': 'hp-stat-icon' }, '\u{1F5A5}\uFE0F'), + E('div', { 'class': 'hp-stat-value' }, String(backends.length)), + E('div', { 'class': 'hp-stat-label' }, 'Backends'), + E('div', { 'class': 'hp-stat-trend' }, activeBackends + ' active') + ]), + E('div', { 'class': 'hp-stat-card' }, [ + E('div', { 'class': 'hp-stat-icon' }, '\u{1F512}'), + E('div', { 'class': 'hp-stat-value' }, String(certificates.length)), + E('div', { 'class': 'hp-stat-label' }, 'SSL Certificates'), + E('div', { 'class': 'hp-stat-trend' }, validCerts + ' valid') + ]), + E('div', { 'class': 'hp-stat-card' }, [ + E('div', { 'class': 'hp-stat-icon' }, '\u{1F4CA}'), + E('div', { 'class': 'hp-stat-value ' + (status.haproxy_running ? 'success' : 'danger') }, + status.haproxy_running ? 'UP' : 'DOWN'), + E('div', { 'class': 'hp-stat-label' }, 'Service Status'), + E('div', { 'class': 'hp-stat-trend' }, status.enabled ? 'Auto-start enabled' : 'Manual start') + ]) + ]); + }, + + renderHealthGrid: function(status) { + var items = [ + { + icon: status.container_running ? '\u2705' : '\u274C', + label: 'Container', + value: status.container_running ? 'Running' : 'Stopped', + status: status.container_running ? 'success' : 'danger' + }, + { + icon: status.haproxy_running ? '\u2705' : '\u274C', + label: 'HAProxy', + value: status.haproxy_running ? 'Active' : 'Inactive', + status: status.haproxy_running ? 'success' : 'danger' + }, + { + icon: status.config_valid ? '\u2705' : '\u26A0\uFE0F', + label: 'Config', + value: status.config_valid ? 'Valid' : 'Check Needed', + status: status.config_valid ? 'success' : 'warning' + }, + { + icon: '\u{1F4E1}', + label: 'HTTP Port', + value: String(status.http_port || 80), + status: '' + }, + { + icon: '\u{1F510}', + label: 'HTTPS Port', + value: String(status.https_port || 443), + status: '' + }, + { + icon: status.stats_enabled ? '\u{1F4CA}' : '\u26AA', + label: 'Stats Page', + value: status.stats_enabled ? 'Enabled' : 'Disabled', + status: status.stats_enabled ? 'success' : '' + } + ]; + + return E('div', { 'class': 'hp-card', 'style': 'margin-bottom: 24px;' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u{1F3E5}'), + 'System Health' + ]) + ]), + E('div', { 'class': 'hp-card-body' }, [ + E('div', { 'class': 'hp-health-grid' }, items.map(function(item) { + return E('div', { 'class': 'hp-health-item' }, [ + E('div', { 'class': 'hp-health-icon' }, item.icon), + E('div', { 'class': 'hp-health-label' }, item.label), + E('div', { 'class': 'hp-health-value ' + item.status }, item.value) + ]); + })) + ]) + ]); + }, + + renderVhostsCard: function(vhosts) { + var self = this; + + if (vhosts.length === 0) { + return E('div', { 'class': 'hp-card' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u{1F310}'), + 'Virtual Hosts' + ]), + E('a', { 'href': L.url('admin/services/haproxy/vhosts'), 'class': 'hp-btn hp-btn-primary hp-btn-sm' }, + '+ Add Host') + ]), + E('div', { 'class': 'hp-card-body' }, [ + E('div', { 'class': 'hp-empty' }, [ + E('div', { 'class': 'hp-empty-icon' }, '\u{1F310}'), + E('div', { 'class': 'hp-empty-text' }, 'No virtual hosts configured'), + E('div', { 'class': 'hp-empty-hint' }, 'Add a virtual host to start routing traffic') + ]) + ]) + ]); + } + + return E('div', { 'class': 'hp-card' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u{1F310}'), + 'Virtual Hosts (' + vhosts.length + ')' + ]), + E('a', { 'href': L.url('admin/services/haproxy/vhosts'), 'class': 'hp-btn hp-btn-secondary hp-btn-sm' }, + 'Manage') + ]), + E('div', { 'class': 'hp-card-body no-padding' }, [ + E('table', { 'class': 'hp-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, 'Domain'), + E('th', {}, 'Backend'), + E('th', {}, 'SSL'), + E('th', {}, 'Status') + ]) + ]), + E('tbody', {}, vhosts.slice(0, 5).map(function(vh) { + return E('tr', {}, [ + E('td', {}, [ + E('strong', {}, vh.domain), + vh.ssl_redirect ? E('small', { 'style': 'display: block; color: var(--hp-text-muted); font-size: 11px;' }, + 'HTTPS redirect enabled') : null + ]), + E('td', {}, E('span', { 'class': 'hp-mono' }, vh.backend || '-')), + E('td', {}, [ + vh.ssl ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'margin-right: 4px;' }, 'SSL') : null, + vh.acme ? E('span', { 'class': 'hp-badge hp-badge-success' }, 'ACME') : null + ]), + E('td', {}, E('span', { + 'class': 'hp-badge ' + (vh.enabled ? 'hp-badge-success' : 'hp-badge-danger') + }, vh.enabled ? 'Active' : 'Disabled')) + ]); + })) + ]), + vhosts.length > 5 ? E('div', { 'style': 'padding: 12px 16px; text-align: center; border-top: 1px solid var(--hp-border);' }, + E('a', { 'href': L.url('admin/services/haproxy/vhosts') }, + 'View all ' + vhosts.length + ' virtual hosts \u2192') + ) : null + ]) + ]); + }, + + renderBackendsCard: function(backends) { + var activeCount = backends.filter(function(b) { return b.enabled; }).length; + + return E('div', { 'class': 'hp-card' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u{1F5A5}\uFE0F'), + 'Backends' + ]), + E('a', { 'href': L.url('admin/services/haproxy/backends'), 'class': 'hp-btn hp-btn-secondary hp-btn-sm' }, + 'Manage') + ]), + E('div', { 'class': 'hp-card-body' }, backends.length === 0 ? [ + E('div', { 'class': 'hp-empty', 'style': 'padding: 20px;' }, [ + E('div', { 'class': 'hp-empty-icon', 'style': 'font-size: 32px;' }, '\u{1F5A5}\uFE0F'), + E('div', { 'class': 'hp-empty-text', 'style': 'font-size: 14px;' }, 'No backends configured') + ]) + ] : [ + E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, + backends.slice(0, 4).map(function(b) { + return E('div', { + 'style': 'display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: var(--hp-bg-tertiary); border-radius: 8px;' + }, [ + E('div', {}, [ + E('div', { 'style': 'font-weight: 500;' }, b.name), + E('div', { 'style': 'font-size: 12px; color: var(--hp-text-muted);' }, + (b.mode || 'http').toUpperCase() + ' / ' + (b.balance || 'roundrobin')) + ]), + E('span', { + 'class': 'hp-badge ' + (b.enabled ? 'hp-badge-success' : 'hp-badge-danger') + }, b.enabled ? 'UP' : 'DOWN') + ]); + }) + ), + backends.length > 4 ? E('div', { 'style': 'text-align: center; margin-top: 12px;' }, + E('a', { 'href': L.url('admin/services/haproxy/backends'), 'style': 'font-size: 13px;' }, + '+' + (backends.length - 4) + ' more') + ) : null + ]) + ]); + }, + + renderCertificatesCard: function(certificates) { + var expiringCount = certificates.filter(function(c) { + return c.days_until_expiry && c.days_until_expiry < 30 && c.days_until_expiry > 0; + }).length; + + return E('div', { 'class': 'hp-card' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u{1F512}'), + 'Certificates' + ]), + E('a', { 'href': L.url('admin/services/haproxy/certificates'), 'class': 'hp-btn hp-btn-secondary hp-btn-sm' }, + 'Manage') + ]), + E('div', { 'class': 'hp-card-body' }, certificates.length === 0 ? [ + E('div', { 'class': 'hp-empty', 'style': 'padding: 20px;' }, [ + E('div', { 'class': 'hp-empty-icon', 'style': 'font-size: 32px;' }, '\u{1F512}'), + E('div', { 'class': 'hp-empty-text', 'style': 'font-size: 14px;' }, 'No certificates') + ]) + ] : [ + expiringCount > 0 ? E('div', { + 'style': 'display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--hp-warning-soft); border-radius: 8px; margin-bottom: 12px; font-size: 13px; color: var(--hp-warning);' + }, [ + '\u26A0\uFE0F', + expiringCount + ' certificate(s) expiring soon' + ]) : null, + E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, + certificates.slice(0, 3).map(function(c) { + var isExpiring = c.days_until_expiry && c.days_until_expiry < 30; + var isExpired = c.expired || (c.days_until_expiry && c.days_until_expiry <= 0); + return E('div', { + 'style': 'display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: var(--hp-bg-tertiary); border-radius: 8px;' + }, [ + E('div', { 'class': 'hp-mono', 'style': 'font-size: 13px;' }, c.domain), + E('span', { + 'class': 'hp-badge ' + (isExpired ? 'hp-badge-danger' : (isExpiring ? 'hp-badge-warning' : 'hp-badge-success')) + }, isExpired ? 'Expired' : (c.acme ? 'ACME' : 'Custom')) + ]); + }) + ), + certificates.length > 3 ? E('div', { 'style': 'text-align: center; margin-top: 12px;' }, + E('a', { 'href': L.url('admin/services/haproxy/certificates'), 'style': 'font-size: 13px;' }, + '+' + (certificates.length - 3) + ' more') + ) : null + ]) + ]); + }, + + renderQuickActions: function(status) { + var self = this; + var haproxyRunning = status.haproxy_running; + var containerRunning = status.container_running; + + var actions = [ + { + icon: '\u25B6\uFE0F', + label: 'Start', + disabled: haproxyRunning, + click: function() { self.handleStart(); } + }, + { + icon: '\u23F9\uFE0F', + label: 'Stop', + disabled: !haproxyRunning, + click: function() { self.handleStop(); } + }, + { + icon: '\u{1F504}', + label: 'Reload', + disabled: !haproxyRunning, + click: function() { self.handleReload(); } + }, + { + icon: '\u2705', + label: 'Validate', + disabled: !containerRunning, + click: function() { self.handleValidate(); } + }, + { + icon: '\u{1F4DD}', + label: 'Regenerate', + disabled: !containerRunning, + click: function() { self.handleGenerate(); } + }, + { + icon: '\u{1F4CA}', + label: 'Stats', + disabled: !status.stats_enabled, + click: function() { + window.open('http://' + window.location.hostname + ':' + (status.stats_port || 8404) + '/stats', '_blank'); + } + } + ]; + + return E('div', { 'class': 'hp-card', 'style': 'margin-bottom: 24px;' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u26A1'), + 'Quick Actions' + ]) + ]), + E('div', { 'class': 'hp-card-body' }, [ + E('div', { 'class': 'hp-quick-actions' }, actions.map(function(action) { + return E('button', { + 'class': 'hp-action-btn', + 'disabled': action.disabled, + 'click': action.click + }, [ + E('span', { 'class': 'hp-action-icon' }, action.icon), + E('span', { 'class': 'hp-action-label' }, action.label) + ]); + })) + ]) + ]); + }, + + renderConnectionInfo: function(status) { + var hostname = window.location.hostname; + + return E('div', { 'class': 'hp-card' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u{1F4E1}'), + 'Connection Details' + ]) + ]), + E('div', { 'class': 'hp-card-body' }, [ + E('div', { 'class': 'hp-connection-grid' }, [ + E('div', { 'class': 'hp-connection-item' }, [ + E('span', { 'class': 'hp-connection-label' }, 'HTTP Endpoint'), + E('span', { 'class': 'hp-connection-value' }, + E('a', { 'href': 'http://' + hostname + ':' + (status.http_port || 80), 'target': '_blank' }, + hostname + ':' + (status.http_port || 80))) + ]), + E('div', { 'class': 'hp-connection-item' }, [ + E('span', { 'class': 'hp-connection-label' }, 'HTTPS Endpoint'), + E('span', { 'class': 'hp-connection-value' }, + E('a', { 'href': 'https://' + hostname + ':' + (status.https_port || 443), 'target': '_blank' }, + hostname + ':' + (status.https_port || 443))) + ]), + status.stats_enabled ? E('div', { 'class': 'hp-connection-item' }, [ + E('span', { 'class': 'hp-connection-label' }, 'Stats Dashboard'), + E('span', { 'class': 'hp-connection-value' }, + E('a', { 'href': 'http://' + hostname + ':' + (status.stats_port || 8404) + '/stats', 'target': '_blank' }, + hostname + ':' + (status.stats_port || 8404) + '/stats')) + ]) : null, + E('div', { 'class': 'hp-connection-item' }, [ + E('span', { 'class': 'hp-connection-label' }, 'Config Path'), + E('span', { 'class': 'hp-connection-value' }, '/etc/haproxy/haproxy.cfg') + ]) + ]) + ]) + ]); + }, + + // === Action Handlers === + handleStart: function() { + var self = this; return api.start().then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'HAProxy service started')); - window.location.reload(); + self.showToast('HAProxy service started', 'success'); + return self.refreshDashboard(); } else { - ui.addNotification(null, E('p', {}, 'Failed to start: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Failed to start: ' + (res.error || 'Unknown error'), 'error'); } }); }, handleStop: function() { + var self = this; return api.stop().then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'HAProxy service stopped')); - window.location.reload(); + self.showToast('HAProxy service stopped', 'success'); + return self.refreshDashboard(); } else { - ui.addNotification(null, E('p', {}, 'Failed to stop: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Failed to stop: ' + (res.error || 'Unknown error'), 'error'); } }); }, handleReload: function() { + var self = this; return api.reload().then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'HAProxy configuration reloaded')); + self.showToast('HAProxy configuration reloaded', 'success'); } else { - ui.addNotification(null, E('p', {}, 'Failed to reload: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Failed to reload: ' + (res.error || 'Unknown error'), 'error'); } }); }, handleValidate: function() { + var self = this; return api.validate().then(function(res) { if (res.valid) { - ui.addNotification(null, E('p', {}, 'Configuration is valid')); + self.showToast('Configuration is valid', 'success'); } else { - ui.addNotification(null, E('p', {}, 'Configuration error: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Configuration error: ' + (res.error || 'Unknown error'), 'error'); } }); }, handleGenerate: function() { + var self = this; return api.generate().then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'Configuration regenerated')); + self.showToast('Configuration regenerated', 'success'); } else { - ui.addNotification(null, E('p', {}, 'Failed to generate: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Failed to generate: ' + (res.error || 'Unknown error'), 'error'); } }); }, handleInstall: function() { + var self = this; ui.showModal('Installing HAProxy Container', [ - E('p', { 'class': 'spinning' }, 'Installing HAProxy container...') + E('p', { 'class': 'spinning' }, 'Downloading and configuring HAProxy container...') ]); return api.install().then(function(res) { ui.hideModal(); if (res.success) { - ui.addNotification(null, E('p', {}, 'HAProxy container installed successfully')); - window.location.reload(); + self.showToast('HAProxy container installed successfully', 'success'); + return self.refreshDashboard(); } else { - ui.addNotification(null, E('p', {}, 'Installation failed: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Installation failed: ' + (res.error || 'Unknown error'), 'error'); } }); }, + refreshDashboard: function() { + var self = this; + return api.getDashboardData().then(function(data) { + self.data = data; + // Re-render dashboard content + var container = document.querySelector('.haproxy-dashboard'); + if (container) { + dom.content(container, self.render(data).childNodes); + } + }); + }, + + showToast: function(message, type) { + var existing = document.querySelector('.hp-toast'); + if (existing) existing.remove(); + + var iconMap = { + 'success': '\u2705', + 'error': '\u274C', + 'warning': '\u26A0\uFE0F' + }; + + var toast = E('div', { 'class': 'hp-toast ' + (type || '') }, [ + E('span', {}, iconMap[type] || '\u2139\uFE0F'), + message + ]); + document.body.appendChild(toast); + + setTimeout(function() { + toast.remove(); + }, 4000); + }, + handleSaveApply: null, handleSave: null, handleReset: null diff --git a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js index f3f0cdb8..c5067303 100644 --- a/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js +++ b/package/secubox/luci-app-haproxy/htdocs/luci-static/resources/view/haproxy/vhosts.js @@ -2,11 +2,23 @@ 'require view'; 'require dom'; 'require ui'; -'require form'; 'require haproxy.api as api'; +/** + * HAProxy Virtual Hosts Management + * Copyright (C) 2025 CyberMind.fr + */ + return view.extend({ + title: _('Virtual Hosts'), + load: function() { + // Load CSS + var cssLink = document.createElement('link'); + cssLink.rel = 'stylesheet'; + cssLink.href = L.resource('haproxy/dashboard.css'); + document.head.appendChild(cssLink); + return Promise.all([ api.listVhosts(), api.listBackends() @@ -15,126 +27,143 @@ return view.extend({ render: function(data) { var self = this; - var vhosts = data[0] || []; - var backends = data[1] || []; + var vhosts = (data[0] && data[0].vhosts) || data[0] || []; + var backends = (data[1] && data[1].backends) || data[1] || []; - var view = E('div', { 'class': 'cbi-map' }, [ - E('h2', {}, 'Virtual Hosts'), - E('p', {}, 'Configure domain-based routing to backend servers.'), + return E('div', { 'class': 'haproxy-dashboard' }, [ + // Page Header + E('div', { 'class': 'hp-page-header' }, [ + E('div', {}, [ + E('h1', { 'class': 'hp-page-title' }, [ + E('span', { 'class': 'hp-page-title-icon' }, '\u{1F310}'), + 'Virtual Hosts' + ]), + E('p', { 'class': 'hp-page-subtitle' }, 'Configure domain-based routing to backend servers') + ]), + E('a', { + 'href': L.url('admin/services/haproxy/overview'), + 'class': 'hp-btn hp-btn-secondary' + }, ['\u2190', ' Back to Overview']) + ]), - // Add vhost form - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Add Virtual Host'), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, 'Domain'), - E('div', { 'class': 'cbi-value-field' }, [ - E('input', { - 'type': 'text', - 'id': 'new-domain', - 'class': 'cbi-input-text', - 'placeholder': 'example.com' - }) + // Add Virtual Host Card + E('div', { 'class': 'hp-card' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u2795'), + 'Add Virtual Host' ]) ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, 'Backend'), - E('div', { 'class': 'cbi-value-field' }, [ - E('select', { 'id': 'new-backend', 'class': 'cbi-input-select' }, - [E('option', { 'value': '' }, '-- Select Backend --')].concat( - backends.map(function(b) { - return E('option', { 'value': b.id }, b.name); - }) + E('div', { 'class': 'hp-card-body' }, [ + E('div', { 'class': 'hp-grid hp-grid-2', 'style': 'gap: 16px;' }, [ + E('div', { 'class': 'hp-form-group' }, [ + E('label', { 'class': 'hp-form-label' }, 'Domain'), + E('input', { + 'type': 'text', + 'id': 'new-domain', + 'class': 'hp-form-input', + 'placeholder': 'example.com or *.example.com' + }) + ]), + E('div', { 'class': 'hp-form-group' }, [ + E('label', { 'class': 'hp-form-label' }, 'Backend'), + E('select', { 'id': 'new-backend', 'class': 'hp-form-input' }, + [E('option', { 'value': '' }, '-- Select Backend --')].concat( + backends.map(function(b) { + return E('option', { 'value': b.id || b.name }, b.name); + }) + ) ) - ) - ]) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, 'Options'), - E('div', { 'class': 'cbi-value-field' }, [ - E('label', { 'style': 'margin-right: 1rem' }, [ - E('input', { 'type': 'checkbox', 'id': 'new-ssl', 'checked': true }), - ' Enable SSL' - ]), - E('label', { 'style': 'margin-right: 1rem' }, [ - E('input', { 'type': 'checkbox', 'id': 'new-ssl-redirect', 'checked': true }), - ' Force HTTPS redirect' - ]), - E('label', {}, [ - E('input', { 'type': 'checkbox', 'id': 'new-acme', 'checked': true }), - ' Auto-renew with ACME' ]) - ]) - ]), - E('div', { 'class': 'cbi-value' }, [ - E('label', { 'class': 'cbi-value-title' }, ''), - E('div', { 'class': 'cbi-value-field' }, [ - E('button', { - 'class': 'cbi-button cbi-button-add', - 'click': function() { self.handleAddVhost(); } - }, 'Add Virtual Host') - ]) + ]), + E('div', { 'style': 'display: flex; gap: 24px; flex-wrap: wrap; margin: 16px 0;' }, [ + E('label', { 'class': 'hp-form-checkbox' }, [ + E('input', { 'type': 'checkbox', 'id': 'new-ssl', 'checked': true }), + E('span', {}, 'Enable SSL/TLS') + ]), + E('label', { 'class': 'hp-form-checkbox' }, [ + E('input', { 'type': 'checkbox', 'id': 'new-ssl-redirect', 'checked': true }), + E('span', {}, 'Force HTTPS redirect') + ]), + E('label', { 'class': 'hp-form-checkbox' }, [ + E('input', { 'type': 'checkbox', 'id': 'new-acme', 'checked': true }), + E('span', {}, 'Auto-renew with ACME (Let\'s Encrypt)') + ]) + ]), + E('button', { + 'class': 'hp-btn hp-btn-primary', + 'click': function() { self.handleAddVhost(backends); } + }, ['\u2795', ' Add Virtual Host']) ]) ]), - // Vhosts list - E('div', { 'class': 'haproxy-form-section' }, [ - E('h3', {}, 'Configured Virtual Hosts (' + vhosts.length + ')'), - this.renderVhostsTable(vhosts, backends) + // Virtual Hosts List + E('div', { 'class': 'hp-card' }, [ + E('div', { 'class': 'hp-card-header' }, [ + E('div', { 'class': 'hp-card-title' }, [ + E('span', { 'class': 'hp-card-title-icon' }, '\u{1F4CB}'), + 'Configured Virtual Hosts (' + vhosts.length + ')' + ]) + ]), + E('div', { 'class': 'hp-card-body no-padding' }, + vhosts.length === 0 ? [ + E('div', { 'class': 'hp-empty' }, [ + E('div', { 'class': 'hp-empty-icon' }, '\u{1F310}'), + E('div', { 'class': 'hp-empty-text' }, 'No virtual hosts configured'), + E('div', { 'class': 'hp-empty-hint' }, 'Add a virtual host above to start routing traffic') + ]) + ] : [ + this.renderVhostsTable(vhosts, backends) + ] + ) ]) ]); - - // Add CSS - var style = E('style', {}, ` - @import url('/luci-static/resources/haproxy/dashboard.css'); - `); - view.insertBefore(style, view.firstChild); - - return view; }, renderVhostsTable: function(vhosts, backends) { var self = this; - if (vhosts.length === 0) { - return E('p', { 'style': 'color: var(--text-color-medium, #666)' }, - 'No virtual hosts configured.'); - } - var backendMap = {}; - backends.forEach(function(b) { backendMap[b.id] = b.name; }); + backends.forEach(function(b) { + backendMap[b.id || b.name] = b.name; + }); - return E('table', { 'class': 'haproxy-vhosts-table' }, [ + return E('table', { 'class': 'hp-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', {}, 'Domain'), E('th', {}, 'Backend'), - E('th', {}, 'SSL'), + E('th', {}, 'SSL Configuration'), E('th', {}, 'Status'), - E('th', { 'style': 'width: 150px' }, 'Actions') + E('th', { 'style': 'width: 180px; text-align: right;' }, 'Actions') ]) ]), E('tbody', {}, vhosts.map(function(vh) { return E('tr', { 'data-id': vh.id }, [ E('td', {}, [ - E('strong', {}, vh.domain), - vh.ssl_redirect ? E('small', { 'style': 'display: block; color: #666' }, 'Redirects HTTP to HTTPS') : null + E('div', { 'style': 'font-weight: 600;' }, vh.domain), + vh.ssl_redirect ? E('small', { 'style': 'color: var(--hp-text-muted); font-size: 12px;' }, + '\u{1F512} Redirects HTTP \u2192 HTTPS') : null ]), - E('td', {}, backendMap[vh.backend] || vh.backend || '-'), E('td', {}, [ - vh.ssl ? E('span', { 'class': 'haproxy-badge ssl', 'style': 'margin-right: 4px' }, 'SSL') : null, - vh.acme ? E('span', { 'class': 'haproxy-badge acme' }, 'ACME') : null + E('span', { 'class': 'hp-mono' }, backendMap[vh.backend] || vh.backend || '-') + ]), + E('td', {}, [ + vh.ssl ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'margin-right: 6px;' }, '\u{1F512} SSL') : null, + vh.acme ? E('span', { 'class': 'hp-badge hp-badge-success' }, '\u{1F504} ACME') : null, + !vh.ssl && !vh.acme ? E('span', { 'class': 'hp-badge hp-badge-warning' }, 'No SSL') : null ]), E('td', {}, E('span', { - 'class': 'haproxy-badge ' + (vh.enabled ? 'enabled' : 'disabled') - }, vh.enabled ? 'Enabled' : 'Disabled')), - E('td', {}, [ + 'class': 'hp-badge ' + (vh.enabled ? 'hp-badge-success' : 'hp-badge-danger') + }, vh.enabled ? '\u2705 Active' : '\u26D4 Disabled')), + E('td', { 'style': 'text-align: right;' }, [ E('button', { - 'class': 'cbi-button cbi-button-edit', - 'style': 'margin-right: 4px', + 'class': 'hp-btn hp-btn-sm ' + (vh.enabled ? 'hp-btn-secondary' : 'hp-btn-success'), + 'style': 'margin-right: 8px;', 'click': function() { self.handleToggleVhost(vh); } }, vh.enabled ? 'Disable' : 'Enable'), E('button', { - 'class': 'cbi-button cbi-button-remove', + 'class': 'hp-btn hp-btn-sm hp-btn-danger', 'click': function() { self.handleDeleteVhost(vh); } }, 'Delete') ]) @@ -143,7 +172,7 @@ return view.extend({ ]); }, - handleAddVhost: function() { + handleAddVhost: function(backends) { var self = this; var domain = document.getElementById('new-domain').value.trim(); var backend = document.getElementById('new-backend').value; @@ -152,51 +181,66 @@ return view.extend({ var acme = document.getElementById('new-acme').checked ? 1 : 0; if (!domain) { - ui.addNotification(null, E('p', {}, 'Domain is required'), 'error'); + self.showToast('Please enter a domain name', 'error'); + return; + } + + // Validate domain format + if (!/^(\*\.)?[a-zA-Z0-9][-a-zA-Z0-9]*(\.[a-zA-Z0-9][-a-zA-Z0-9]*)+$/.test(domain)) { + self.showToast('Invalid domain format', 'error'); return; } return api.createVhost(domain, backend, ssl, sslRedirect, acme, 1).then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'Virtual host created')); + self.showToast('Virtual host "' + domain + '" created', 'success'); window.location.reload(); } else { - ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error'); } }); }, handleToggleVhost: function(vh) { + var self = this; var newEnabled = vh.enabled ? 0 : 1; + var action = newEnabled ? 'enabled' : 'disabled'; + return api.updateVhost(vh.id, null, null, null, null, null, newEnabled).then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'Virtual host updated')); + self.showToast('Virtual host ' + action, 'success'); window.location.reload(); } else { - ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error'); } }); }, handleDeleteVhost: function(vh) { var self = this; + ui.showModal('Delete Virtual Host', [ - E('p', {}, 'Are you sure you want to delete virtual host "' + vh.domain + '"?'), - E('div', { 'class': 'right' }, [ + E('div', { 'style': 'margin-bottom: 16px;' }, [ + E('p', { 'style': 'margin: 0;' }, 'Are you sure you want to delete this virtual host?'), + E('div', { + 'style': 'margin-top: 12px; padding: 12px; background: var(--hp-bg-tertiary, #f5f5f5); border-radius: 8px; font-family: monospace;' + }, vh.domain) + ]), + E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [ E('button', { - 'class': 'cbi-button', + 'class': 'hp-btn hp-btn-secondary', 'click': ui.hideModal }, 'Cancel'), E('button', { - 'class': 'cbi-button cbi-button-negative', + 'class': 'hp-btn hp-btn-danger', 'click': function() { ui.hideModal(); api.deleteVhost(vh.id).then(function(res) { if (res.success) { - ui.addNotification(null, E('p', {}, 'Virtual host deleted')); + self.showToast('Virtual host deleted', 'success'); window.location.reload(); } else { - ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); + self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error'); } }); } @@ -205,6 +249,27 @@ return view.extend({ ]); }, + showToast: function(message, type) { + var existing = document.querySelector('.hp-toast'); + if (existing) existing.remove(); + + var iconMap = { + 'success': '\u2705', + 'error': '\u274C', + 'warning': '\u26A0\uFE0F' + }; + + var toast = E('div', { 'class': 'hp-toast ' + (type || '') }, [ + E('span', {}, iconMap[type] || '\u2139\uFE0F'), + message + ]); + document.body.appendChild(toast); + + setTimeout(function() { + toast.remove(); + }, 4000); + }, + handleSaveApply: null, handleSave: null, handleReset: null