Adding the Initial WIP
This commit is contained in:
commit
326479c338
25 files changed
+6629
No files matched your search
@@ -0,0 +1,355 @@
|
||||
/* Configuration page styles */
|
||||
body {
|
||||
background: #10151b;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.header nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header nav a {
|
||||
text-decoration: none;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header nav a:hover {
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
/* UPS list styles */
|
||||
.ups-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ups-item {
|
||||
background: #1d2530;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.ups-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ups-item-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.ups-item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ups-item-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ups-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ups-detail-label {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ups-detail-value {
|
||||
font-weight: 500;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.ups-status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ups-status.online {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.ups-status.offline {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.ups-status.testing {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Modal styles */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #1d2530;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content.small {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: #2d3748;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.25);
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: #1d2530;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
max-width: 300px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.toast.warning {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-left: 4px solid #17a2b8;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ups-item-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ups-item-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
margin: 2% auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #eef;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: #10151b;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.header nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-link {
|
||||
color: #6c757d;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.config-link:hover {
|
||||
background-color: #2d3748;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1d2530;
|
||||
padding: 1.5rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
min-width: 320px;
|
||||
min-height: 260px;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
position: relative;
|
||||
resize: both; /* fallback native resize for browsers that support */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.highline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #243140;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.5px;
|
||||
color: #cde;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge[data-badge="runtime"] { background:#2c3e50; }
|
||||
.badge[data-badge="watts"] { background:#1f4f61; }
|
||||
.badge[data-badge="headroom"] { background:#3b3f2a; }
|
||||
.badge[data-badge="energy"] { background:#28324a; }
|
||||
|
||||
/* Battery Gauge */
|
||||
.gauge { width: 120px; height: 70px; position: relative; }
|
||||
.gauge svg { width: 100%; height: 100%; }
|
||||
.gauge-bg { fill: none; stroke: #243140; stroke-width: 10; stroke-linecap: round; }
|
||||
.gauge-fill { fill: none; stroke: #4a9eff; stroke-width: 10; stroke-linecap: round; transition: stroke-dasharray .4s ease, stroke .4s; }
|
||||
.gauge-text { fill: #cde; font-size: 16px; font-weight: 600; dominant-baseline: middle; }
|
||||
.gauge.low .gauge-fill { stroke: #d97745; }
|
||||
.gauge.critical .gauge-fill { stroke: #d9534f; }
|
||||
.gauge.good .gauge-fill { stroke: #3bb273; }
|
||||
|
||||
.events { margin-top: 0.75rem; }
|
||||
.events-header { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; opacity: 0.6; margin-bottom: 0.25rem; }
|
||||
.events-list { list-style: none; padding: 0; margin: 0; max-height: 90px; overflow-y: auto; font-size: 0.65rem; line-height: 1.2; }
|
||||
.events-list li { padding: 2px 0; border-bottom: 1px solid #233; }
|
||||
.events-list li:last-child { border-bottom: none; }
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: #eef;
|
||||
}
|
||||
|
||||
.ups-connection {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 32px; /* Reserve space for footer */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-row > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-weight: bold;
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
.ups-status {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ups-status.online {
|
||||
background-color: #155724;
|
||||
color: #d4edda;
|
||||
}
|
||||
|
||||
.ups-status.onbattery {
|
||||
background-color: #856404;
|
||||
color: #fff3cd;
|
||||
}
|
||||
|
||||
.ups-status.unknown {
|
||||
background-color: #383d41;
|
||||
color: #d6d8db;
|
||||
}
|
||||
|
||||
.charts {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#ups-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
canvas.chart {
|
||||
width: 300px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
background: linear-gradient(135deg, #2f3b48 0%, #3e4c5c 60%, #4a5b6e 100%);
|
||||
border: 1px solid #556;
|
||||
border-radius: 3px;
|
||||
cursor: nwse-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
color: #9fb1c3;
|
||||
user-select: none;
|
||||
opacity: 0.75;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.resize-handle:hover { opacity: 1; }
|
||||
|
||||
.card.resizing { outline: 1px dashed #4a9eff; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #999;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #eef;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state a {
|
||||
color: #4a9eff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.empty-state a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Mode toggle */
|
||||
.mode-toggle {
|
||||
margin-left: auto;
|
||||
background: #2b3542;
|
||||
color: #cdd6e2;
|
||||
border: 1px solid #3a4756;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.mode-toggle:hover { background:#364250; }
|
||||
|
||||
/* Add Metric Button */
|
||||
.add-metric-btn { background:#2b3542; color:#9fb1c3; border:1px solid #3a4756; border-radius:4px; cursor:pointer; width:26px; height:26px; font-size:16px; line-height:1; display:flex; align-items:center; justify-content:center; }
|
||||
.add-metric-btn:hover { background:#364250; }
|
||||
|
||||
/* Reset layout button */
|
||||
.reset-layout-btn { background:#2b3542; color:#9fb1c3; border:1px solid #3a4756; border-radius:4px; cursor:pointer; width:28px; height:26px; font-size:14px; display:flex; align-items:center; justify-content:center; }
|
||||
.reset-layout-btn:hover { background:#364250; }
|
||||
|
||||
/* Metric Modal */
|
||||
.metric-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; z-index:1000; }
|
||||
.metric-modal { background:#1d2530; border:1px solid #2c3846; padding:1rem 1.25rem; width:340px; border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.4); }
|
||||
.metric-modal h3 { margin-top:0; margin-bottom:0.75rem; font-size:1rem; color:#eef; }
|
||||
.metric-modal label { font-size:0.7rem; text-transform:uppercase; letter-spacing:1px; opacity:0.75; display:block; margin-top:0.75rem; margin-bottom:0.25rem; }
|
||||
.metric-modal select, .metric-modal input { width:100%; background:#283240; color:#cdd6e2; border:1px solid #364250; border-radius:4px; padding:4px 6px; font-size:0.85rem; }
|
||||
.metric-modal-actions { display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1rem; }
|
||||
.metric-modal button { background:#2b3542; border:1px solid #3a4756; color:#cdd6e2; padding:6px 10px; border-radius:4px; cursor:pointer; font-size:0.75rem; }
|
||||
.metric-modal button.primary { background:#1f5d85; border-color:#2675aa; }
|
||||
.metric-modal button.primary:hover { background:#2675aa; }
|
||||
.metric-modal button:hover { background:#364250; }
|
||||
|
||||
/* Compact mode */
|
||||
.card.compact .charts { display: none; }
|
||||
.card.compact .events { display: none; }
|
||||
.card.compact { min-height: 140px; }
|
||||
.card.compact .metric-row { margin-bottom: 0.25rem; }
|
||||
.card.compact .highline { margin-bottom: 0.4rem; }
|
||||
|
||||
/* Tile system */
|
||||
.tile-grid { position: relative; margin-top: 0.6rem; min-height: 140px; }
|
||||
.tile { background:#212b36; border:1px solid #2c3a48; border-radius:6px; padding:0.5rem 0.6rem; position:absolute; display:flex; flex-direction:column; min-height:110px; min-width:140px; box-sizing:border-box; }
|
||||
.tile h4 { margin:0 0 0.4rem 0; font-size:0.7rem; letter-spacing:1px; text-transform:uppercase; opacity:0.7; font-weight:600; }
|
||||
.tile .tile-body { flex:1; display:flex; align-items:center; justify-content:center; }
|
||||
.tile .big-value { font-size:1.4rem; font-weight:600; color:#4a9eff; }
|
||||
.tile canvas { width:100%!important; height:90px!important; }
|
||||
.tile .gauge { margin:auto; }
|
||||
.tile-controls { position:absolute; top:4px; right:4px; display:flex; gap:4px; }
|
||||
.tile-controls select { background:#2b3542; border:1px solid #364250; color:#cdd6e2; font-size:0.6rem; padding:2px 4px; border-radius:4px; }
|
||||
.tile.small { min-height:80px; }
|
||||
.tile.hidden { display:none; }
|
||||
.tile.dragging { opacity:0.5; border:1px dashed #4a9eff; }
|
||||
.tile.selected { outline:2px solid #4a9eff; }
|
||||
.tile-resize { position:absolute; right:4px; bottom:4px; width:12px; height:12px; cursor:nwse-resize; background:rgba(255,255,255,0.08); border:1px solid #3a4756; border-radius:2px; }
|
||||
.tile-resize:after { content:''; position:absolute; right:2px; bottom:2px; width:6px; height:6px; border-right:2px solid #4a9eff; border-bottom:2px solid #4a9eff; }
|
||||
|
||||
/* Event footer */
|
||||
.ups-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 0.65rem;
|
||||
color: #9fb1c3;
|
||||
border-top: 1px solid #2c3a48;
|
||||
padding: 4px 8px;
|
||||
min-height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background: rgba(29, 37, 48, 0.95);
|
||||
border-radius: 0 0 8px 8px;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ups-footer .event-marquee { display: inline-block; animation: marquee 18s linear infinite; }
|
||||
|
||||
/* Value tile display */
|
||||
.big-value-container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
.big-value-container .big-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #4a9eff;
|
||||
}
|
||||
.big-value-container .value-unit {
|
||||
font-size: 0.9rem;
|
||||
color: #9fb1c3;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Voltage color coding based on US electrical specs */
|
||||
.voltage-good {
|
||||
color: #22c55e !important; /* Green for voltage in acceptable range (114V-126V) */
|
||||
}
|
||||
.voltage-bad {
|
||||
color: #ef4444 !important; /* Red for voltage out of acceptable range */
|
||||
}
|
||||
|
||||
@keyframes marquee { 0% { transform:translateX(0); } 100% { transform:translateX(-100%); } }
|
||||
.legend-badge { background:#243140; padding:2px 6px; border-radius:10px; font-size:0.6rem; margin:2px; display:inline-block; }
|
||||
|
||||
.card.compact .tile-grid { display:none; }
|
||||
@@ -0,0 +1,400 @@
|
||||
// Configuration management JavaScript
|
||||
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.loadUPSList();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Add UPS button
|
||||
document.getElementById('add-ups-btn').addEventListener('click', () => {
|
||||
this.showUPSModal();
|
||||
});
|
||||
|
||||
// Modal close buttons
|
||||
document.querySelectorAll('.close').forEach(close => {
|
||||
close.addEventListener('click', (e) => {
|
||||
this.closeModal(e.target.closest('.modal'));
|
||||
});
|
||||
});
|
||||
|
||||
// UPS form submission
|
||||
document.getElementById('ups-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveUPS();
|
||||
});
|
||||
|
||||
// Test connection button
|
||||
document.getElementById('test-connection-btn').addEventListener('click', () => {
|
||||
this.testConnection();
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
document.getElementById('cancel-btn').addEventListener('click', () => {
|
||||
this.closeModal(document.getElementById('ups-modal'));
|
||||
});
|
||||
|
||||
// Confirmation modal buttons
|
||||
document.getElementById('confirm-yes').addEventListener('click', () => {
|
||||
if (this.confirmCallback) {
|
||||
this.confirmCallback();
|
||||
}
|
||||
this.closeModal(document.getElementById('confirm-modal'));
|
||||
});
|
||||
|
||||
document.getElementById('confirm-no').addEventListener('click', () => {
|
||||
this.closeModal(document.getElementById('confirm-modal'));
|
||||
});
|
||||
|
||||
// Click outside modal to close
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal')) {
|
||||
this.closeModal(e.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadUPSList() {
|
||||
try {
|
||||
const response = await fetch('/api/config/ups');
|
||||
const upsList = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(upsList.detail || 'Failed to load UPS configurations');
|
||||
}
|
||||
|
||||
this.renderUPSList(upsList);
|
||||
} catch (error) {
|
||||
this.showToast('Error loading UPS configurations: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderUPSList(upsList) {
|
||||
const container = document.getElementById('ups-list');
|
||||
|
||||
if (upsList.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No UPS Configured</h3>
|
||||
<p>Get started by clicking "Add UPS" above to configure your first UPS monitoring.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = upsList.map(ups => this.renderUPSItem(ups)).join('');
|
||||
|
||||
// Bind action buttons
|
||||
container.querySelectorAll('.edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const upsName = e.target.dataset.upsName;
|
||||
this.editUPS(upsName);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const upsName = e.target.dataset.upsName;
|
||||
this.confirmDelete(upsName);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.test-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const upsName = e.target.dataset.upsName;
|
||||
this.testUPSConnection(upsName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderUPSItem(ups) {
|
||||
const alertsEnabled = [
|
||||
ups.alert_loadpct_high && `Load: ${ups.alert_loadpct_high}%`,
|
||||
ups.alert_bcharge_low && `Battery: ${ups.alert_bcharge_low}%`,
|
||||
ups.alert_on_battery && 'On Battery',
|
||||
ups.alert_runtime_low_minutes && `Runtime: ${ups.alert_runtime_low_minutes}min`
|
||||
].filter(Boolean);
|
||||
|
||||
return `
|
||||
<div class="ups-item">
|
||||
<div class="ups-item-header">
|
||||
<div class="ups-item-title">${ups.name}</div>
|
||||
<div class="ups-item-actions">
|
||||
<button class="btn btn-secondary test-btn" data-ups-name="${ups.name}">Test</button>
|
||||
<button class="btn btn-primary edit-btn" data-ups-name="${ups.name}">Edit</button>
|
||||
<button class="btn btn-danger delete-btn" data-ups-name="${ups.name}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ups-item-details">
|
||||
<div class="ups-detail">
|
||||
<div class="ups-detail-label">Host</div>
|
||||
<div class="ups-detail-value">${ups.host}:${ups.port}</div>
|
||||
</div>
|
||||
<div class="ups-detail">
|
||||
<div class="ups-detail-label">Polling Interval</div>
|
||||
<div class="ups-detail-value">${ups.interval_seconds}s</div>
|
||||
</div>
|
||||
<div class="ups-detail">
|
||||
<div class="ups-detail-label">Alerts</div>
|
||||
<div class="ups-detail-value">${alertsEnabled.length > 0 ? alertsEnabled.join(', ') : 'None'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showUPSModal(ups = null) {
|
||||
const modal = document.getElementById('ups-modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const form = document.getElementById('ups-form');
|
||||
|
||||
// Reset form
|
||||
form.reset();
|
||||
|
||||
if (ups) {
|
||||
// Edit mode
|
||||
title.textContent = 'Edit UPS';
|
||||
this.populateForm(ups);
|
||||
form.dataset.mode = 'edit';
|
||||
form.dataset.upsName = ups.name;
|
||||
} else {
|
||||
// Add mode
|
||||
title.textContent = 'Add UPS';
|
||||
form.dataset.mode = 'add';
|
||||
delete form.dataset.upsName;
|
||||
}
|
||||
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
populateForm(ups) {
|
||||
document.getElementById('ups-name').value = ups.name || '';
|
||||
document.getElementById('ups-host').value = ups.host || '';
|
||||
document.getElementById('ups-port').value = ups.port || 3551;
|
||||
document.getElementById('ups-interval').value = ups.interval_seconds || 30;
|
||||
document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || '';
|
||||
document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || '';
|
||||
document.getElementById('ups-onbattery').checked = ups.alert_on_battery || false;
|
||||
document.getElementById('ups-runtime').value = ups.alert_runtime_low_minutes || '';
|
||||
}
|
||||
|
||||
closeModal(modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
async saveUPS() {
|
||||
const form = document.getElementById('ups-form');
|
||||
const formData = new FormData(form);
|
||||
const mode = form.dataset.mode;
|
||||
const upsName = form.dataset.upsName;
|
||||
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
host: formData.get('host'),
|
||||
port: parseInt(formData.get('port')) || 3551,
|
||||
interval_seconds: parseInt(formData.get('interval_seconds')) || 30,
|
||||
alert_on_battery: formData.has('alert_on_battery')
|
||||
};
|
||||
|
||||
// Add optional alert thresholds
|
||||
const loadpct = formData.get('alert_loadpct_high');
|
||||
if (loadpct) data.alert_loadpct_high = parseFloat(loadpct);
|
||||
|
||||
const bcharge = formData.get('alert_bcharge_low');
|
||||
if (bcharge) data.alert_bcharge_low = parseFloat(bcharge);
|
||||
|
||||
const runtime = formData.get('alert_runtime_low_minutes');
|
||||
if (runtime) data.alert_runtime_low_minutes = parseFloat(runtime);
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (mode === 'edit') {
|
||||
response = await fetch(`/api/config/ups/${upsName}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
response = await fetch('/api/config/ups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || 'Failed to save UPS configuration');
|
||||
}
|
||||
|
||||
this.showToast(result.message, 'success');
|
||||
this.closeModal(document.getElementById('ups-modal'));
|
||||
this.loadUPSList();
|
||||
} catch (error) {
|
||||
this.showToast('Error saving UPS: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async editUPS(upsName) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/ups/${upsName}`);
|
||||
const ups = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(ups.detail || 'Failed to load UPS configuration');
|
||||
}
|
||||
|
||||
this.showUPSModal(ups);
|
||||
} catch (error) {
|
||||
this.showToast('Error loading UPS configuration: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
confirmDelete(upsName) {
|
||||
const modal = document.getElementById('confirm-modal');
|
||||
const message = document.getElementById('confirm-message');
|
||||
|
||||
message.textContent = `Are you sure you want to delete the UPS configuration "${upsName}"? This action cannot be undone.`;
|
||||
|
||||
this.confirmCallback = () => this.deleteUPS(upsName);
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
async deleteUPS(upsName) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/ups/${upsName}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || 'Failed to delete UPS configuration');
|
||||
}
|
||||
|
||||
this.showToast(result.message, 'success');
|
||||
this.loadUPSList();
|
||||
} catch (error) {
|
||||
this.showToast('Error deleting UPS: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
const form = document.getElementById('ups-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
name: formData.get('name') || 'test',
|
||||
host: formData.get('host'),
|
||||
port: parseInt(formData.get('port')) || 3551,
|
||||
interval_seconds: 30,
|
||||
alert_on_battery: false
|
||||
};
|
||||
|
||||
if (!data.host) {
|
||||
this.showToast('Host is required for connection test', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const testBtn = document.getElementById('test-connection-btn');
|
||||
const originalText = testBtn.textContent;
|
||||
testBtn.textContent = 'Testing...';
|
||||
testBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/ups/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
let msg;
|
||||
if (result.success) {
|
||||
msg = 'Connection successful';
|
||||
if (result.data && result.data.STATUS) {
|
||||
msg += ` (STATUS=${result.data.STATUS})`;
|
||||
}
|
||||
this.showToast(msg, 'success');
|
||||
} else {
|
||||
if (result.connectivity && !result.connectivity.ok) {
|
||||
msg = 'TCP connectivity failed: ' + (result.connectivity.error || 'unknown error');
|
||||
} else if (result.protocol && !result.protocol.ok) {
|
||||
msg = 'Protocol error after TCP success: ' + (result.protocol.error || 'unknown error');
|
||||
} else {
|
||||
msg = result.message || 'Connection failed';
|
||||
}
|
||||
this.showToast(msg, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast('Connection test failed: ' + error.message, 'error');
|
||||
} finally {
|
||||
testBtn.textContent = originalText;
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async testUPSConnection(upsName) {
|
||||
const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`);
|
||||
const originalText = testBtn.textContent;
|
||||
testBtn.textContent = 'Testing...';
|
||||
testBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/config/ups/${upsName}/test`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
let msg;
|
||||
if (result.success) {
|
||||
msg = `Connection to ${upsName} successful`;
|
||||
if (result.data && result.data.STATUS) {
|
||||
msg += ` (STATUS=${result.data.STATUS})`;
|
||||
}
|
||||
this.showToast(msg, 'success');
|
||||
} else {
|
||||
if (result.connectivity && !result.connectivity.ok) {
|
||||
msg = `TCP connectivity failed: ${result.connectivity.error}`;
|
||||
} else if (result.protocol && !result.protocol.ok) {
|
||||
msg = `Protocol error: ${result.protocol.error}`;
|
||||
} else {
|
||||
msg = result.message || 'Connection failed';
|
||||
}
|
||||
this.showToast(`Connection to ${upsName} failed: ${msg}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(`Connection test failed: ${error.message}`, 'error');
|
||||
} finally {
|
||||
testBtn.textContent = originalText;
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new ConfigManager();
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
// Clean UPS Dashboard - Simplified but Functional
|
||||
const DEBUG_ENABLED = localStorage.getItem('ups_debug') === 'true' ||
|
||||
new URLSearchParams(window.location.search).get('debug') === 'true';
|
||||
|
||||
function debugLog(...args) {
|
||||
if (DEBUG_ENABLED) console.log('[UPS Debug]', ...args);
|
||||
}
|
||||
|
||||
function debugError(...args) {
|
||||
console.error('[UPS Error]', ...args);
|
||||
}
|
||||
|
||||
// Debug control functions
|
||||
window.upsDebug = {
|
||||
enable: () => {
|
||||
localStorage.setItem('ups_debug', 'true');
|
||||
location.reload();
|
||||
},
|
||||
disable: () => {
|
||||
localStorage.removeItem('ups_debug');
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
const evtSource = new EventSource('/api/stream');
|
||||
const charts = {};
|
||||
|
||||
// Core tile definitions
|
||||
const TILE_REGISTRY = [
|
||||
{ id: 'load_pct', metric: 'LOADPCT', label: 'Load %', type: 'gauge' },
|
||||
{ id: 'battery_pct', metric: 'BCHARGE', label: 'Battery %', type: 'gauge' },
|
||||
{ id: 'watts_usage', metric: 'DERIVED_WATTS', label: 'Watts', type: 'line' },
|
||||
{ id: 'volt_line', metric: 'LINEV', label: 'Line V', type: 'value' },
|
||||
{ id: 'volt_output', metric: 'OUTPUTV', label: 'Output V', type: 'value', fallback: 'LINEV' }
|
||||
];
|
||||
|
||||
// Storage for tile positions and card sizes
|
||||
const TILE_POS_KEY = 'upsTilePos.v1';
|
||||
const CARD_SIZE_KEY = 'upsCardSizes.v1';
|
||||
let savedTilePos = {};
|
||||
let savedCardSizes = {};
|
||||
|
||||
function loadFromStorage(key, defaultValue = {}) {
|
||||
try { return JSON.parse(localStorage.getItem(key) || '{}'); } catch(_) { return defaultValue; }
|
||||
}
|
||||
|
||||
function saveToStorage(key, data) {
|
||||
try { localStorage.setItem(key, JSON.stringify(data)); } catch(_) {}
|
||||
}
|
||||
|
||||
// Initialize storage
|
||||
savedTilePos = loadFromStorage(TILE_POS_KEY);
|
||||
savedCardSizes = loadFromStorage(CARD_SIZE_KEY);
|
||||
|
||||
// Initialize tiles for a UPS
|
||||
function initTilesFor(name, grid) {
|
||||
if (!grid) return;
|
||||
debugLog('Creating tiles for UPS:', name);
|
||||
|
||||
TILE_REGISTRY.forEach((tileDef, idx) => {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'tile';
|
||||
tile.dataset.tile = tileDef.id;
|
||||
|
||||
// Default positioning
|
||||
tile.style.left = (10 + (idx % 3) * 220) + 'px';
|
||||
tile.style.top = (10 + Math.floor(idx / 3) * 180) + 'px';
|
||||
tile.style.width = '200px';
|
||||
tile.style.height = '160px';
|
||||
|
||||
tile.innerHTML = `
|
||||
<h4>${tileDef.label}</h4>
|
||||
<div class="tile-body">
|
||||
${createTileContent(name, tileDef)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
grid.appendChild(tile);
|
||||
attachTileBehavior(name, tile, tileDef);
|
||||
});
|
||||
|
||||
applyStoredPositions(name, grid);
|
||||
}
|
||||
|
||||
function createTileContent(name, tileDef) {
|
||||
if (tileDef.type === 'gauge') {
|
||||
return `<div class="gauge" data-gauge="${tileDef.metric}">
|
||||
<svg viewBox="0 0 120 70">
|
||||
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-bg" />
|
||||
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-fill" stroke-dasharray="0 157" />
|
||||
<text x="60" y="45" class="gauge-text" text-anchor="middle">--%</text>
|
||||
</svg>
|
||||
</div>`;
|
||||
} else if (tileDef.type === 'value') {
|
||||
const unit = tileDef.metric.includes('V') ? 'V' : 'W';
|
||||
return `<div class="big-value-container">
|
||||
<span class="big-value" data-value="${tileDef.metric}">--</span>
|
||||
<span class="value-unit">${unit}</span>
|
||||
</div>`;
|
||||
} else if (tileDef.type === 'line') {
|
||||
const canvasId = `chart-${name}-${tileDef.id}`;
|
||||
return `<canvas id="${canvasId}" height="90"></canvas>`;
|
||||
}
|
||||
return '<div>--</div>';
|
||||
}
|
||||
|
||||
function attachTileBehavior(name, tile, tileDef) {
|
||||
// Make tiles draggable
|
||||
let isDragging = false;
|
||||
let startX, startY, initialLeft, initialTop;
|
||||
|
||||
tile.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'CANVAS') return; // Don't drag on charts
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
initialLeft = parseInt(tile.style.left) || 0;
|
||||
initialTop = parseInt(tile.style.top) || 0;
|
||||
tile.style.zIndex = 1000;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
tile.style.left = (initialLeft + deltaX) + 'px';
|
||||
tile.style.top = (initialTop + deltaY) + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
tile.style.zIndex = '';
|
||||
savePositions(name);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize chart if needed
|
||||
if (tileDef.type === 'line') {
|
||||
setTimeout(() => initChart(name, tileDef), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function initChart(name, tileDef) {
|
||||
const canvasId = `chart-${name}-${tileDef.id}`;
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) return;
|
||||
|
||||
debugLog('Creating chart:', canvasId);
|
||||
|
||||
try {
|
||||
charts[canvasId] = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: tileDef.label,
|
||||
data: [],
|
||||
borderColor: '#ff6b35',
|
||||
backgroundColor: 'rgba(255,107,53,0.15)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { beginAtZero: false },
|
||||
x: { display: false }
|
||||
},
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
debugError('Chart creation failed:', canvasId, error);
|
||||
}
|
||||
}
|
||||
|
||||
function savePositions(name) {
|
||||
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
|
||||
if (!grid) return;
|
||||
|
||||
savedTilePos[name] = {};
|
||||
grid.querySelectorAll('.tile').forEach(tile => {
|
||||
const id = tile.dataset.tile;
|
||||
savedTilePos[name][id] = {
|
||||
left: parseInt(tile.style.left) || 0,
|
||||
top: parseInt(tile.style.top) || 0,
|
||||
width: parseInt(tile.style.width) || 200,
|
||||
height: parseInt(tile.style.height) || 160
|
||||
};
|
||||
});
|
||||
|
||||
saveToStorage(TILE_POS_KEY, savedTilePos);
|
||||
}
|
||||
|
||||
function applyStoredPositions(name, grid) {
|
||||
const positions = savedTilePos[name];
|
||||
if (!positions) return;
|
||||
|
||||
grid.querySelectorAll('.tile').forEach(tile => {
|
||||
const id = tile.dataset.tile;
|
||||
const pos = positions[id];
|
||||
if (pos) {
|
||||
tile.style.left = pos.left + 'px';
|
||||
tile.style.top = pos.top + 'px';
|
||||
tile.style.width = pos.width + 'px';
|
||||
tile.style.height = pos.height + 'px';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset layout function
|
||||
function resetLayout(name) {
|
||||
debugLog('Resetting layout for:', name);
|
||||
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
|
||||
if (!grid) return;
|
||||
|
||||
// Clear existing tiles and charts
|
||||
Object.keys(charts).forEach(chartId => {
|
||||
if (chartId.includes(name)) {
|
||||
try { charts[chartId].destroy(); } catch(_) {}
|
||||
delete charts[chartId];
|
||||
}
|
||||
});
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
// Clear saved positions
|
||||
delete savedTilePos[name];
|
||||
saveToStorage(TILE_POS_KEY, savedTilePos);
|
||||
|
||||
// Recreate tiles
|
||||
initTilesFor(name, grid);
|
||||
|
||||
// Auto-arrange with proper spacing
|
||||
autoArrangeTiles(name);
|
||||
}
|
||||
|
||||
function autoArrangeTiles(name) {
|
||||
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
|
||||
if (!grid) return;
|
||||
|
||||
const tiles = Array.from(grid.querySelectorAll('.tile'));
|
||||
const cols = 3;
|
||||
const tileWidth = 200;
|
||||
const tileHeight = 160;
|
||||
const gap = 20;
|
||||
|
||||
tiles.forEach((tile, idx) => {
|
||||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
tile.style.left = (10 + col * (tileWidth + gap)) + 'px';
|
||||
tile.style.top = (10 + row * (tileHeight + gap)) + 'px';
|
||||
tile.style.width = tileWidth + 'px';
|
||||
tile.style.height = tileHeight + 'px';
|
||||
});
|
||||
|
||||
savePositions(name);
|
||||
}
|
||||
|
||||
// Update functions
|
||||
function updateUPSData(name, data) {
|
||||
const card = document.getElementById(`card-${name}`);
|
||||
if (!card) return;
|
||||
|
||||
// Ensure tiles exist
|
||||
const grid = card.querySelector('[data-tile-grid]');
|
||||
if (grid && grid.children.length === 0) {
|
||||
initTilesFor(name, grid);
|
||||
}
|
||||
|
||||
// Update status and badges
|
||||
updateBadges(card, data);
|
||||
|
||||
// Update tiles
|
||||
TILE_REGISTRY.forEach(tileDef => {
|
||||
updateTile(name, tileDef, data);
|
||||
});
|
||||
}
|
||||
|
||||
function updateBadges(card, data) {
|
||||
// Update status
|
||||
const status = card.querySelector('[data-field="STATUS"]');
|
||||
if (status) {
|
||||
status.textContent = data.STATUS || 'UNKNOWN';
|
||||
status.className = 'ups-status ' + (data.STATUS === 'ONLINE' ? 'online' : 'unknown');
|
||||
}
|
||||
|
||||
// Update model/serial
|
||||
const modelSerial = card.querySelector('[data-model-serial]');
|
||||
if (modelSerial && (data.MODEL || data.SERIALNO)) {
|
||||
const parts = [];
|
||||
if (data.MODEL) parts.push(data.MODEL);
|
||||
if (data.SERIALNO) parts.push(`S/N: ${data.SERIALNO}`);
|
||||
modelSerial.textContent = parts.join(' • ');
|
||||
}
|
||||
|
||||
// Update other badges
|
||||
['RUNTIME_MINUTES', 'DERIVED_WATTS', 'HEADROOM_PCT'].forEach(field => {
|
||||
const badge = card.querySelector(`[data-field="${field}"]`);
|
||||
if (badge && data[field]) {
|
||||
const value = parseFloat(data[field]) || 0;
|
||||
const unit = field === 'RUNTIME_MINUTES' ? ' m' :
|
||||
field === 'DERIVED_WATTS' ? ' W' : ' headroom';
|
||||
badge.textContent = value.toFixed(0) + unit;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateTile(name, tileDef, data) {
|
||||
const rawValue = data[tileDef.metric] || (tileDef.fallback ? data[tileDef.fallback] : null);
|
||||
if (!rawValue) return;
|
||||
|
||||
const value = parseFloat(rawValue) || 0;
|
||||
|
||||
if (tileDef.type === 'gauge') {
|
||||
updateGauge(name, tileDef.metric, value);
|
||||
} else if (tileDef.type === 'value') {
|
||||
updateValueDisplay(name, tileDef.metric, value);
|
||||
} else if (tileDef.type === 'line') {
|
||||
updateChart(name, tileDef, value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGauge(name, metric, value) {
|
||||
const gauge = document.querySelector(`#card-${name} [data-gauge="${metric}"]`);
|
||||
if (!gauge) return;
|
||||
|
||||
const fill = gauge.querySelector('.gauge-fill');
|
||||
const text = gauge.querySelector('.gauge-text');
|
||||
|
||||
if (fill && text) {
|
||||
const total = 157;
|
||||
const filled = (value / 100) * total;
|
||||
fill.setAttribute('stroke-dasharray', `${filled} ${total - filled}`);
|
||||
text.textContent = value.toFixed(0) + '%';
|
||||
|
||||
gauge.className = 'gauge ' + (value < 15 ? 'critical' : value < 35 ? 'low' : 'good');
|
||||
}
|
||||
}
|
||||
|
||||
function updateValueDisplay(name, metric, value) {
|
||||
const element = document.querySelector(`#card-${name} [data-value="${metric}"]`);
|
||||
if (!element) return;
|
||||
|
||||
element.textContent = value.toFixed(1);
|
||||
|
||||
if (metric.includes('V')) {
|
||||
element.className = 'big-value ' + (value >= 114 && value <= 126 ? 'voltage-good' : 'voltage-bad');
|
||||
}
|
||||
}
|
||||
|
||||
function updateChart(name, tileDef, value) {
|
||||
const chartId = `chart-${name}-${tileDef.id}`;
|
||||
const chart = charts[chartId];
|
||||
if (!chart) return;
|
||||
|
||||
const now = new Date().toLocaleTimeString();
|
||||
chart.data.labels.push(now);
|
||||
chart.data.datasets[0].data.push(value);
|
||||
|
||||
// Keep last 60 points
|
||||
if (chart.data.labels.length > 60) {
|
||||
chart.data.labels.shift();
|
||||
chart.data.datasets[0].data.shift();
|
||||
}
|
||||
|
||||
// Smart scaling for watts
|
||||
if (tileDef.id === 'watts_usage') {
|
||||
const values = chart.data.datasets[0].data;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min;
|
||||
|
||||
if (range <= 50) {
|
||||
chart.options.scales.y.min = Math.max(0, min - 10);
|
||||
chart.options.scales.y.max = max + 10;
|
||||
}
|
||||
}
|
||||
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
// SSE event handling
|
||||
evtSource.onmessage = (e) => {
|
||||
try {
|
||||
const payload = JSON.parse(e.data);
|
||||
const snapshots = payload.snapshots || {};
|
||||
|
||||
Object.entries(snapshots).forEach(([name, snap]) => {
|
||||
updateUPSData(name, snap);
|
||||
});
|
||||
} catch (err) {
|
||||
debugError('SSE Error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Card resize functionality
|
||||
function persistCardSize(name, width, height) {
|
||||
savedCardSizes[name] = { width, height };
|
||||
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
||||
}
|
||||
|
||||
function restoreCardSize(name) {
|
||||
const card = document.getElementById(`card-${name}`);
|
||||
const saved = savedCardSizes[name];
|
||||
if (card && saved) {
|
||||
card.style.width = saved.width + 'px';
|
||||
card.style.height = saved.height + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
debugLog('Dashboard initializing...');
|
||||
|
||||
// Initialize existing cards
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
const name = card.id.replace('card-', '');
|
||||
const grid = card.querySelector('[data-tile-grid]');
|
||||
if (grid) {
|
||||
initTilesFor(name, grid);
|
||||
restoreCardSize(name);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach reset button listeners
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('[data-reset-layout]')) {
|
||||
const card = e.target.closest('.card');
|
||||
const name = card.id.replace('card-', '');
|
||||
resetLayout(name);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for new cards added dynamically
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('card')) {
|
||||
const name = node.id.replace('card-', '');
|
||||
const grid = node.querySelector('[data-tile-grid]');
|
||||
if (grid) {
|
||||
debugLog('New UPS card detected:', name);
|
||||
initTilesFor(name, grid);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const upsCards = document.getElementById('ups-cards');
|
||||
if (upsCards) {
|
||||
observer.observe(upsCards, { childList: true });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,336 @@
|
||||
// Simple UPS Dashboard
|
||||
const DEBUG = localStorage.getItem('debug') === 'true' || true; // Temporarily enable debug
|
||||
const log = DEBUG ? console.log.bind(console, '[UPS]') : () => {};
|
||||
|
||||
// Global state
|
||||
const charts = {};
|
||||
const evtSource = new EventSource('/api/stream');
|
||||
let lastUpdateTs = {};
|
||||
|
||||
// Core tile types
|
||||
const TILES = {
|
||||
load_pct: { metric: 'LOADPCT', label: 'Load %', type: 'gauge' },
|
||||
battery_pct: { metric: 'BCHARGE', label: 'Battery %', type: 'gauge' },
|
||||
watts_usage: { metric: 'DERIVED_WATTS', label: 'Watts', type: 'line' },
|
||||
volt_line: { metric: 'LINEV', label: 'Line V', type: 'value' },
|
||||
volt_output: { metric: 'OUTPUTV', label: 'Output V', type: 'value' }
|
||||
};
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
log('DOM loaded, Chart.js available:', typeof Chart !== 'undefined');
|
||||
initializeDashboard();
|
||||
setupEventListeners();
|
||||
setupDynamicCardObserver();
|
||||
});
|
||||
|
||||
function setupDynamicCardObserver() {
|
||||
// Watch for new UPS cards being added to the DOM
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('card')) {
|
||||
const name = node.id.replace('card-', '');
|
||||
log('New UPS card detected:', name);
|
||||
createTiles(name);
|
||||
initChartsForUPS(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const upsCards = document.getElementById('ups-cards');
|
||||
if (upsCards) {
|
||||
observer.observe(upsCards, { childList: true });
|
||||
}
|
||||
}
|
||||
|
||||
function initializeDashboard() {
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
const name = card.id.replace('card-', '');
|
||||
createTiles(name);
|
||||
});
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Reset button handlers
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('[data-reset-layout]')) {
|
||||
const card = e.target.closest('.card');
|
||||
const name = card.id.replace('card-', '');
|
||||
resetLayout(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createTiles(upsName) {
|
||||
log('Creating tiles for UPS:', upsName);
|
||||
const grid = document.querySelector(`#card-${upsName} [data-tile-grid]`);
|
||||
if (!grid) {
|
||||
console.warn('Grid not found for UPS:', upsName);
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = '';
|
||||
log('Creating', Object.keys(TILES).length, 'tiles');
|
||||
|
||||
Object.entries(TILES).forEach(([id, config], index) => {
|
||||
log('Creating tile:', id, config);
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'tile';
|
||||
tile.dataset.tile = id;
|
||||
tile.innerHTML = `
|
||||
<h4>${config.label}</h4>
|
||||
<div class="tile-body">
|
||||
${createTileContent(upsName, id, config)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Simple positioning
|
||||
const x = (index % 3) * 220 + 10;
|
||||
const y = Math.floor(index / 3) * 180 + 10;
|
||||
tile.style.left = x + 'px';
|
||||
tile.style.top = y + 'px';
|
||||
tile.style.width = '200px';
|
||||
tile.style.height = '160px';
|
||||
|
||||
grid.appendChild(tile);
|
||||
});
|
||||
}
|
||||
|
||||
function createTileContent(upsName, tileId, config) {
|
||||
if (config.type === 'gauge') {
|
||||
return `<div class="gauge" data-gauge="${config.metric}">
|
||||
<svg viewBox="0 0 120 70">
|
||||
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-bg" />
|
||||
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-fill" stroke-dasharray="0 157" />
|
||||
<text x="60" y="45" class="gauge-text" text-anchor="middle">--%</text>
|
||||
</svg>
|
||||
</div>`;
|
||||
} else if (config.type === 'value') {
|
||||
const unit = config.metric.includes('V') ? 'V' : 'W';
|
||||
return `<div class="big-value-container">
|
||||
<span class="big-value" data-value="${config.metric}">--</span>
|
||||
<span class="value-unit">${unit}</span>
|
||||
</div>`;
|
||||
} else if (config.type === 'line') {
|
||||
const canvasId = `chart-${upsName}-${tileId}`;
|
||||
return `<canvas id="${canvasId}" height="90"></canvas>`;
|
||||
}
|
||||
return '<div>--</div>';
|
||||
}
|
||||
|
||||
function resetLayout(upsName) {
|
||||
log('Resetting layout for', upsName);
|
||||
createTiles(upsName);
|
||||
|
||||
// Clear any existing charts
|
||||
Object.keys(charts).forEach(key => {
|
||||
if (key.includes(upsName)) {
|
||||
charts[key].destroy();
|
||||
delete charts[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Recreate charts
|
||||
initChartsForUPS(upsName);
|
||||
}
|
||||
|
||||
function initChartsForUPS(upsName) {
|
||||
log('Initializing charts for UPS:', upsName);
|
||||
Object.entries(TILES).forEach(([id, config]) => {
|
||||
if (config.type === 'line') {
|
||||
const canvasId = `chart-${upsName}-${id}`;
|
||||
const canvas = document.getElementById(canvasId);
|
||||
log('Looking for canvas:', canvasId, 'found:', !!canvas);
|
||||
if (canvas) {
|
||||
try {
|
||||
charts[canvasId] = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: config.label,
|
||||
data: [],
|
||||
borderColor: '#ff6b35',
|
||||
backgroundColor: 'rgba(255,107,53,0.15)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: { beginAtZero: false },
|
||||
x: { display: false }
|
||||
},
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
log('Chart created successfully for:', canvasId);
|
||||
} catch (error) {
|
||||
console.error('Error creating chart:', canvasId, error);
|
||||
}
|
||||
} else {
|
||||
console.warn('Canvas not found for chart:', canvasId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// SSE Event handling
|
||||
evtSource.onmessage = (e) => {
|
||||
try {
|
||||
const payload = JSON.parse(e.data);
|
||||
const snapshots = payload.snapshots || {};
|
||||
|
||||
Object.entries(snapshots).forEach(([name, snap]) => {
|
||||
updateUPSData(name, snap);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('SSE Error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
function updateUPSData(name, data) {
|
||||
const card = document.getElementById(`card-${name}`);
|
||||
if (!card) return;
|
||||
|
||||
// Ensure tiles exist for this UPS (in case card was added dynamically)
|
||||
const grid = card.querySelector('[data-tile-grid]');
|
||||
if (grid && grid.children.length === 0) {
|
||||
log('Creating tiles for new UPS:', name);
|
||||
createTiles(name);
|
||||
initChartsForUPS(name);
|
||||
}
|
||||
|
||||
lastUpdateTs[name] = Date.now();
|
||||
|
||||
// Update status badges
|
||||
updateBadges(card, data);
|
||||
|
||||
// Update tiles
|
||||
Object.entries(TILES).forEach(([id, config]) => {
|
||||
updateTile(name, id, config, data);
|
||||
});
|
||||
}
|
||||
|
||||
function updateBadges(card, data) {
|
||||
// Update status
|
||||
const status = card.querySelector('[data-field="STATUS"]');
|
||||
if (status) {
|
||||
status.textContent = data.STATUS || 'UNKNOWN';
|
||||
status.className = 'ups-status ' + (data.STATUS === 'ONLINE' ? 'online' : 'unknown');
|
||||
}
|
||||
|
||||
// Update model/serial display
|
||||
const modelSerial = card.querySelector('[data-model-serial]');
|
||||
if (modelSerial && (data.MODEL || data.SERIALNO)) {
|
||||
const parts = [];
|
||||
if (data.MODEL) parts.push(data.MODEL);
|
||||
if (data.SERIALNO) parts.push(`S/N: ${data.SERIALNO}`);
|
||||
modelSerial.textContent = parts.join(' • ');
|
||||
}
|
||||
|
||||
// Update other badges
|
||||
['RUNTIME_MINUTES', 'DERIVED_WATTS', 'HEADROOM_PCT'].forEach(field => {
|
||||
const badge = card.querySelector(`[data-field="${field}"]`);
|
||||
if (badge && data[field]) {
|
||||
const value = parseFloat(data[field]) || 0;
|
||||
const unit = field === 'RUNTIME_MINUTES' ? ' m' :
|
||||
field === 'DERIVED_WATTS' ? ' W' : ' headroom';
|
||||
badge.textContent = value.toFixed(0) + unit;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateTile(upsName, tileId, config, data) {
|
||||
const rawValue = data[config.metric] || (config.metric === 'OUTPUTV' ? data['LINEV'] : null);
|
||||
if (!rawValue) return;
|
||||
|
||||
const value = parseFloat(rawValue) || 0;
|
||||
|
||||
if (config.type === 'gauge') {
|
||||
updateGauge(upsName, config.metric, value);
|
||||
} else if (config.type === 'value') {
|
||||
updateValueDisplay(upsName, config.metric, value);
|
||||
} else if (config.type === 'line') {
|
||||
updateChart(upsName, tileId, value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGauge(upsName, metric, value) {
|
||||
const gauge = document.querySelector(`#card-${upsName} [data-gauge="${metric}"]`);
|
||||
if (!gauge) return;
|
||||
|
||||
const fill = gauge.querySelector('.gauge-fill');
|
||||
const text = gauge.querySelector('.gauge-text');
|
||||
|
||||
if (fill && text) {
|
||||
const total = 157;
|
||||
const filled = (value / 100) * total;
|
||||
fill.setAttribute('stroke-dasharray', `${filled} ${total - filled}`);
|
||||
text.textContent = value.toFixed(0) + '%';
|
||||
|
||||
// Color coding
|
||||
gauge.className = 'gauge ' + (value < 15 ? 'critical' : value < 35 ? 'low' : 'good');
|
||||
}
|
||||
}
|
||||
|
||||
function updateValueDisplay(upsName, metric, value) {
|
||||
const element = document.querySelector(`#card-${upsName} [data-value="${metric}"]`);
|
||||
if (!element) return;
|
||||
|
||||
element.textContent = value.toFixed(1);
|
||||
|
||||
// Voltage color coding
|
||||
if (metric.includes('V')) {
|
||||
element.className = 'big-value ' + (value >= 114 && value <= 126 ? 'voltage-good' : 'voltage-bad');
|
||||
}
|
||||
}
|
||||
|
||||
function updateChart(upsName, tileId, value) {
|
||||
const chartId = `chart-${upsName}-${tileId}`;
|
||||
const chart = charts[chartId];
|
||||
if (!chart) return;
|
||||
|
||||
const now = new Date().toLocaleTimeString();
|
||||
chart.data.labels.push(now);
|
||||
chart.data.datasets[0].data.push(value);
|
||||
|
||||
// Keep last 60 points
|
||||
if (chart.data.labels.length > 60) {
|
||||
chart.data.labels.shift();
|
||||
chart.data.datasets[0].data.shift();
|
||||
}
|
||||
|
||||
// Auto-scale for watts
|
||||
if (tileId === 'watts_usage') {
|
||||
const values = chart.data.datasets[0].data;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min;
|
||||
|
||||
if (range <= 50) {
|
||||
chart.options.scales.y.min = Math.max(0, min - 10);
|
||||
chart.options.scales.y.max = max + 10;
|
||||
}
|
||||
}
|
||||
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
// Initialize charts after DOM is ready
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
const name = card.id.replace('card-', '');
|
||||
initChartsForUPS(name);
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// Debug controls
|
||||
window.upsDebug = {
|
||||
enable: () => { localStorage.setItem('debug', 'true'); location.reload(); },
|
||||
disable: () => { localStorage.removeItem('debug'); location.reload(); }
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user