Merge pull request #2 from ls-root/refactor/inline-html-assets
refactor: extract inline HTML/assets and fix theme toggle styling
@@ -0,0 +1,696 @@
|
||||
:root {
|
||||
--bg-primary: #0a0e14;
|
||||
--bg-secondary: #131920;
|
||||
--bg-tertiary: #1a2029;
|
||||
--bg-card: #151b23;
|
||||
--border-color: #2d3748;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #64748b;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-green: #22c55e;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-orange: #f97316;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-pink: #ec4899;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f8fafc;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--bg-card: #ffffff;
|
||||
--border-color: #e2e8f0;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-brand svg {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px 40px;
|
||||
max-width: 1920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-card .sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-card.red .value {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.stat-card.yellow .value {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.stat-card.orange .value {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.stat-card.purple .value {
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-header p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.quickfilter {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.source-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.source-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.source-btn.active {
|
||||
background: var(--accent-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.status-badge.aborted {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type-badge.lxc {
|
||||
background: rgba(34, 211, 238, 0.15);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.type-badge.vm {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.exit-code {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.exit-code.err {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.exit-code.ok {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-red);
|
||||
max-width: 400px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-flex;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-badge.apt {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.category-badge.network {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.category-badge.permission {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.category-badge.command_not_found {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.category-badge.user_aborted {
|
||||
background: rgba(100, 116, 139, 0.15);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.category-badge.timeout {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.category-badge.storage {
|
||||
background: rgba(236, 72, 153, 0.15);
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.category-badge.resource {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.category-badge.dependency {
|
||||
background: rgba(34, 211, 238, 0.15);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.category-badge.signal {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.category-badge.config {
|
||||
background: rgba(132, 204, 22, 0.15);
|
||||
color: #84cc16;
|
||||
}
|
||||
|
||||
.category-badge.unknown,
|
||||
.category-badge.uncategorized {
|
||||
background: rgba(100, 116, 139, 0.15);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 60px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert-box {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-box.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.alert-box.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.alert-box.warning {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--accent-yellow);
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.stuck-banner {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
color: var(--accent-yellow);
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-card: #1c2333;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #b0b8c4;
|
||||
--text-muted: #6b7685;
|
||||
--border-color: #2d3748;
|
||||
--accent-green: #22c55e;
|
||||
--accent-red: #ef4444;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-orange: #f97316;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-purple: #a855f7;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-brand svg {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.quickfilter {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn,
|
||||
.source-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.source-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-btn.active,
|
||||
.source-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-card h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: rgba(34, 211, 238, 0.03);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-badge.lxc {
|
||||
background: rgba(34, 211, 238, 0.15);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.type-badge.vm {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.status-badge.aborted {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.status-badge.installing {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.success-bar {
|
||||
display: flex;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: var(--border-color);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.success-bar .seg-success {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.success-bar .seg-failed {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.success-bar .seg-aborted {
|
||||
background: var(--accent-purple);
|
||||
}
|
||||
|
||||
.success-bar .seg-installing {
|
||||
background: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-wrap::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.table-wrap::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.table-wrap::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.exit-code {
|
||||
font-family: 'Consolas', monospace;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.exit-code.ok {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.exit-code.err {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.section-card {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.79 19.79 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.865-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.74 19.74 0 003.677 4.37a.07.07 0 00-.032.028C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.873-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.11 13.11 0 01-1.872-.892.077.077 0 01-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 01.078-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.009c.12.1.246.198.373.292a.077.077 0 01-.006.127 12.3 12.3 0 01-1.873.892.076.076 0 00-.041.107c.36.698.772 1.363 1.225 1.993a.076.076 0 00.084.029 19.84 19.84 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.06.06 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.086-2.157-2.419s.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42 0 1.332-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.086-2.157-2.419s.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42 0 1.332-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="var(--accent-red)"
|
||||
stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 846 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="var(--accent-orange)"
|
||||
stroke-width="2">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 195 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="9" y1="9" x2="15" y2="9" />
|
||||
<line x1="9" y1="13" x2="15" y2="13" />
|
||||
<line x1="9" y1="17" x2="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2"
|
||||
style="animation: spin 1s linear infinite; margin-right: 6px;">
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.3" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 305 B |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 4v6h-6" />
|
||||
<path d="M1 20v-6h6" />
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1,890 @@
|
||||
let charts = {};
|
||||
let allRecords = [];
|
||||
let allAppsData = [];
|
||||
let showingAllApps = false;
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 25;
|
||||
let currentTheme = localStorage.getItem('theme') || 'dark';
|
||||
let currentSort = { field: 'created', dir: 'desc' };
|
||||
|
||||
// Auto-refresh state
|
||||
let autoRefreshEnabled = localStorage.getItem('autoRefresh') === 'true';
|
||||
let autoRefreshInterval = 15000; // 15 seconds
|
||||
let autoRefreshTimer = null;
|
||||
|
||||
// Colorful palette for Top Applications chart
|
||||
const appBarColors = [
|
||||
'#3b82f6', '#f97316', '#22c55e', '#a855f7', '#ef4444',
|
||||
'#22d3ee', '#eab308', '#ec4899', '#84cc16', '#6366f1',
|
||||
'#14b8a6', '#f43f5e', '#8b5cf6', '#10b981', '#06b6d4',
|
||||
'#d946ef', '#facc15', '#2dd4bf'
|
||||
];
|
||||
|
||||
// Apply saved theme on load
|
||||
if (currentTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.getElementById('themeIcon').textContent = '☀️';
|
||||
}
|
||||
|
||||
// Fetch GitHub stars
|
||||
async function fetchGitHubStars() {
|
||||
try {
|
||||
const resp = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE');
|
||||
const data = await resp.json();
|
||||
if (data.stargazers_count) {
|
||||
document.getElementById('starCount').textContent = data.stargazers_count.toLocaleString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not fetch GitHub stars');
|
||||
}
|
||||
}
|
||||
fetchGitHubStars();
|
||||
|
||||
function toggleTheme() {
|
||||
if (currentTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.getElementById('themeIcon').textContent = '☀️';
|
||||
currentTheme = 'light';
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.getElementById('themeIcon').textContent = '🌙';
|
||||
currentTheme = 'dark';
|
||||
}
|
||||
localStorage.setItem('theme', currentTheme);
|
||||
if (Object.keys(charts).length > 0) {
|
||||
refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalSearch(event) {
|
||||
if (event.key === 'Enter') {
|
||||
const query = event.target.value.trim();
|
||||
if (query) {
|
||||
document.getElementById('filterApp').value = query;
|
||||
filterTable();
|
||||
document.querySelector('.section-card:last-of-type').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut for search
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
document.getElementById('globalSearch').focus();
|
||||
}
|
||||
});
|
||||
|
||||
const chartDefaults = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: '#8b949e' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#8b949e' },
|
||||
grid: { color: '#2d3748' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#8b949e' },
|
||||
grid: { color: '#2d3748' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function fetchData() {
|
||||
const activeBtn = document.querySelector('.filter-btn.active');
|
||||
const days = activeBtn ? activeBtn.dataset.days : '1';
|
||||
const repo = document.querySelector('.source-btn.active')?.dataset.repo || 'ProxmoxVE';
|
||||
|
||||
// Show loading indicator
|
||||
document.getElementById('loadingIndicator').style.display = 'flex';
|
||||
document.getElementById('cacheStatus').textContent = '';
|
||||
|
||||
try {
|
||||
// Add cache-busting timestamp for filter changes to ensure fresh data
|
||||
const cacheBuster = '&_t=' + Date.now();
|
||||
const response = await fetch('/api/dashboard?days=' + days + '&repo=' + repo + cacheBuster);
|
||||
if (!response.ok) throw new Error('Failed to fetch data');
|
||||
|
||||
// Check cache status from header
|
||||
const cacheHit = response.headers.get('X-Cache') === 'HIT';
|
||||
document.getElementById('cacheStatus').textContent = cacheHit ? '(cached)' : '(fresh)';
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
document.getElementById('error').style.display = 'flex';
|
||||
document.getElementById('errorText').textContent = error.message;
|
||||
throw error;
|
||||
} finally {
|
||||
document.getElementById('loadingIndicator').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
// Use total_all_time for display if available, otherwise total_installs
|
||||
const displayTotal = data.total_all_time || data.total_installs;
|
||||
document.getElementById('totalInstalls').textContent = displayTotal.toLocaleString();
|
||||
|
||||
// Failed count (separate card)
|
||||
document.getElementById('failedCount').textContent = (data.failed_count || 0).toLocaleString();
|
||||
document.getElementById('failedSubtitle').textContent = data.failed_count > 0 ? 'installation failures' : 'no failures';
|
||||
|
||||
// Aborted count (separate card)
|
||||
document.getElementById('abortedCount').textContent = (data.aborted_count || 0).toLocaleString();
|
||||
|
||||
document.getElementById('successRate').textContent = data.success_rate.toFixed(1) + '%';
|
||||
document.getElementById('successSubtitle').textContent = data.success_count.toLocaleString() + ' successful installations';
|
||||
document.getElementById('lastUpdated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
document.getElementById('error').style.display = 'none';
|
||||
|
||||
// Most Popular - update podium
|
||||
function formatCompact(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return n.toString();
|
||||
}
|
||||
if (data.top_apps && data.top_apps.length >= 3) {
|
||||
document.getElementById('podium1App').textContent = data.top_apps[0].app;
|
||||
document.getElementById('podium1Count').textContent = formatCompact(data.top_apps[0].count);
|
||||
document.getElementById('podium2App').textContent = data.top_apps[1].app;
|
||||
document.getElementById('podium2Count').textContent = formatCompact(data.top_apps[1].count);
|
||||
document.getElementById('podium3App').textContent = data.top_apps[2].app;
|
||||
document.getElementById('podium3Count').textContent = formatCompact(data.top_apps[2].count);
|
||||
} else if (data.top_apps && data.top_apps.length > 0) {
|
||||
document.getElementById('podium1App').textContent = data.top_apps[0].app;
|
||||
document.getElementById('podium1Count').textContent = formatCompact(data.top_apps[0].count);
|
||||
}
|
||||
|
||||
// Store all apps data for View All feature
|
||||
allAppsData = data.top_apps || [];
|
||||
|
||||
// Error Analysis
|
||||
updateErrorAnalysis(data.error_analysis || []);
|
||||
|
||||
// Failed Apps
|
||||
updateFailedApps(data.failed_apps || []);
|
||||
}
|
||||
|
||||
function updateErrorAnalysis(errors) {
|
||||
const container = document.getElementById('errorList');
|
||||
if (!errors || errors.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 20px; color: var(--text-muted); text-align: center; font-size: 13px;">No errors recorded</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = errors.slice(0, 6).map(e =>
|
||||
'<div class="error-item">' +
|
||||
'<div style="min-width:0;flex:1;">' +
|
||||
'<div class="pattern">' + escapeHtml(e.pattern) + '</div>' +
|
||||
'<div class="meta">' + e.unique_apps + ' app' + (e.unique_apps !== 1 ? 's' : '') + ' affected</div>' +
|
||||
'</div>' +
|
||||
'<span class="count-badge">' + e.count.toLocaleString() + 'x</span>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function updateFailedApps(apps) {
|
||||
const container = document.getElementById('failedAppsGrid');
|
||||
const activeDays = parseInt(document.querySelector('.filter-btn.active')?.dataset.days || '1');
|
||||
let minInstalls = 10;
|
||||
if (activeDays <= 1) minInstalls = 5;
|
||||
else if (activeDays <= 7) minInstalls = 15;
|
||||
else if (activeDays <= 30) minInstalls = 40;
|
||||
else if (activeDays <= 90) minInstalls = 100;
|
||||
else minInstalls = 100;
|
||||
document.getElementById('failedAppsThreshold').textContent = '(min. ' + minInstalls + ' installs)';
|
||||
if (!apps || apps.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 20px; color: var(--text-muted); text-align: center; font-size: 13px;">Not enough data (min. ' + minInstalls + ' installs)</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = apps.slice(0, 8).map(a => {
|
||||
const typeClass = (a.type || '').toLowerCase();
|
||||
const typeBadge = a.type && a.type !== 'unknown' ? '<span class="type-badge ' + typeClass + '">' + a.type.toUpperCase() + '</span>' : '';
|
||||
const rate = a.failure_rate;
|
||||
const severityClass = rate >= 30 ? 'critical' : rate >= 15 ? 'warning' : 'moderate';
|
||||
return '<div class="failed-app-card">' +
|
||||
'<div class="app-info">' + typeBadge + '<span class="app-name">' + escapeHtml(a.app) + '</span>' +
|
||||
'<span class="details">' + a.failed_count + '/' + a.total_count + '</span>' +
|
||||
'</div>' +
|
||||
'<span class="failure-rate ' + severityClass + '">' + rate.toFixed(1) + '%</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '-';
|
||||
const d = new Date(ts);
|
||||
// Format: "Feb 11, 2026, 4:33 PM"
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
function initSortableHeaders() {
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.style.cursor = 'pointer';
|
||||
th.addEventListener('click', () => sortByColumn(th.dataset.sort));
|
||||
});
|
||||
}
|
||||
|
||||
function sortByColumn(field) {
|
||||
if (currentSort.field === field) {
|
||||
currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.field = field;
|
||||
currentSort.dir = 'desc';
|
||||
}
|
||||
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
th.textContent = th.textContent.replace(/[▲▼]/g, '').trim();
|
||||
});
|
||||
|
||||
const activeTh = document.querySelector('th[data-sort=\"' + field + '\"]');
|
||||
if (activeTh) {
|
||||
activeTh.classList.add(currentSort.dir === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
activeTh.textContent += ' ' + (currentSort.dir === 'asc' ? '▲' : '▼');
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
|
||||
function toggleAllApps() {
|
||||
showingAllApps = !showingAllApps;
|
||||
const btn = document.getElementById('viewAllAppsBtn');
|
||||
const container = document.getElementById('appsChartContainer');
|
||||
|
||||
if (showingAllApps) {
|
||||
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg> Show Less';
|
||||
container.style.height = '600px';
|
||||
} else {
|
||||
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg> View All';
|
||||
container.style.height = '420px';
|
||||
}
|
||||
|
||||
updateAppsChart(allAppsData);
|
||||
}
|
||||
|
||||
function updateAppsChart(topApps) {
|
||||
const displayApps = showingAllApps ? topApps.slice(0, 30) : topApps.slice(0, 15);
|
||||
const colors = displayApps.map((_, i) => appBarColors[i % appBarColors.length]);
|
||||
|
||||
if (charts.apps) charts.apps.destroy();
|
||||
charts.apps = new Chart(document.getElementById('appsChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: displayApps.map(a => a.app),
|
||||
datasets: [{
|
||||
label: 'Installations',
|
||||
data: displayApps.map(a => a.count),
|
||||
backgroundColor: colors,
|
||||
borderRadius: 6,
|
||||
borderSkipped: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'x',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(21, 27, 35, 0.95)',
|
||||
titleColor: '#e2e8f0',
|
||||
bodyColor: '#e2e8f0',
|
||||
borderColor: '#2d3748',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return ctx.parsed.y.toLocaleString() + ' installations';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#8b949e',
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
},
|
||||
grid: { display: false }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#8b949e',
|
||||
callback: function(value) {
|
||||
if (value >= 1000) return (value / 1000).toFixed(0) + 'k';
|
||||
return value;
|
||||
}
|
||||
},
|
||||
grid: { color: '#2d3748' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCharts(data) {
|
||||
// Daily chart
|
||||
if (charts.daily) charts.daily.destroy();
|
||||
charts.daily = new Chart(document.getElementById('dailyChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.daily_stats.map(d => d.date.slice(5)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Success',
|
||||
data: data.daily_stats.map(d => d.success),
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
data: data.daily_stats.map(d => d.failed),
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...chartDefaults,
|
||||
plugins: { legend: { display: true, position: 'top', labels: { color: '#8b949e', usePointStyle: true } } }
|
||||
}
|
||||
});
|
||||
|
||||
// OS distribution - horizontal bar chart
|
||||
if (charts.os) charts.os.destroy();
|
||||
charts.os = new Chart(document.getElementById('osChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.os_distribution.map(o => o.os),
|
||||
datasets: [{
|
||||
data: data.os_distribution.map(o => o.count),
|
||||
backgroundColor: appBarColors.slice(0, data.os_distribution.length),
|
||||
borderRadius: 4,
|
||||
borderSkipped: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return ctx.parsed.x.toLocaleString() + ' installations';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#8b949e',
|
||||
callback: function(v) { return v >= 1000 ? (v / 1000).toFixed(0) + 'k' : v; }
|
||||
},
|
||||
grid: { color: '#2d3748' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#8b949e' },
|
||||
grid: { display: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Status pie chart
|
||||
if (charts.status) charts.status.destroy();
|
||||
charts.status = new Chart(document.getElementById('statusChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Success', 'Failed', 'Installing'],
|
||||
datasets: [{
|
||||
data: [data.success_count, data.failed_count, data.installing_count],
|
||||
backgroundColor: ['#22c55e', '#ef4444', '#eab308'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { color: '#8b949e', padding: 12 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Top apps chart
|
||||
updateAppsChart(data.top_apps || []);
|
||||
}
|
||||
|
||||
function updateTable(records) {
|
||||
allRecords = records || [];
|
||||
|
||||
// Populate OS filter
|
||||
const osFilter = document.getElementById('filterOs');
|
||||
const uniqueOs = [...new Set(allRecords.map(r => r.os_type).filter(Boolean))];
|
||||
osFilter.innerHTML = '<option value="">All OS</option>' +
|
||||
uniqueOs.map(os => '<option value="' + os + '">' + os + '</option>').join('');
|
||||
|
||||
filterTable();
|
||||
}
|
||||
|
||||
function changePerPage() {
|
||||
perPage = parseInt(document.getElementById('perPageSelect').value);
|
||||
currentPage = 1;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
|
||||
async function fetchPaginatedRecords() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const app = document.getElementById('filterApp').value;
|
||||
const os = document.getElementById('filterOs').value;
|
||||
const type = document.getElementById('filterType').value;
|
||||
|
||||
try {
|
||||
const activeBtn = document.querySelector('.filter-btn.active');
|
||||
const days = activeBtn ? activeBtn.dataset.days : '1';
|
||||
const repo = document.querySelector('.source-btn.active')?.dataset.repo || 'ProxmoxVE';
|
||||
|
||||
let url = '/api/records?page=' + currentPage + '&limit=' + perPage + '&days=' + days + '&repo=' + encodeURIComponent(repo);
|
||||
if (status) url += '&status=' + encodeURIComponent(status);
|
||||
if (app) url += '&app=' + encodeURIComponent(app);
|
||||
if (os) url += '&os=' + encodeURIComponent(os);
|
||||
if (type) url += '&type=' + encodeURIComponent(type);
|
||||
if (currentSort.field) {
|
||||
url += '&sort=' + (currentSort.dir === 'desc' ? '-' : '') + currentSort.field;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch records');
|
||||
const data = await response.json();
|
||||
|
||||
totalPages = data.total_pages || 1;
|
||||
document.getElementById('pageInfo').textContent = 'Page ' + currentPage + ' of ' + totalPages + ' (' + data.total + ' total)';
|
||||
document.getElementById('prevBtn').disabled = currentPage <= 1;
|
||||
document.getElementById('nextBtn').disabled = currentPage >= totalPages;
|
||||
|
||||
renderTableRows(data.records || []);
|
||||
} catch (e) {
|
||||
console.error('Pagination error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
}
|
||||
|
||||
// Store current records for detail view
|
||||
let currentRecords = [];
|
||||
|
||||
function renderTableRows(records) {
|
||||
const tbody = document.getElementById('recordsTable');
|
||||
currentRecords = records;
|
||||
|
||||
if (records.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8"><div class="loading" style="padding: 40px;">No records found</div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = records.map((r, index) => {
|
||||
const statusClass = r.status || 'unknown';
|
||||
const typeClass = (r.type || '').toLowerCase();
|
||||
const diskSize = r.disk_size ? r.disk_size + 'GB' : '-';
|
||||
const coreCount = r.core_count || '-';
|
||||
const ramSize = r.ram_size ? r.ram_size + 'MB' : '-';
|
||||
const created = r.created ? formatTimestamp(r.created) : '-';
|
||||
const osDisplay = r.os_type ? (r.os_type + (r.os_version ? ' ' + r.os_version : '')) : '-';
|
||||
|
||||
return '<tr class="clickable-row" onclick="showRecordDetail(' + index + ')">' +
|
||||
'<td><span class="status-badge ' + statusClass + '">' + escapeHtml(r.status || 'unknown') + '</span></td>' +
|
||||
'<td><span class="type-badge ' + typeClass + '">' + escapeHtml((r.type || '-').toUpperCase()) + '</span></td>' +
|
||||
'<td><strong>' + escapeHtml(r.nsapp || '-') + '</strong></td>' +
|
||||
'<td>' + escapeHtml(osDisplay) + '</td>' +
|
||||
'<td>' + diskSize + '</td>' +
|
||||
'<td style="text-align: center;">' + coreCount + '</td>' +
|
||||
'<td>' + ramSize + '</td>' +
|
||||
'<td>' + created + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showRecordDetail(index) {
|
||||
const record = currentRecords[index];
|
||||
if (!record) return;
|
||||
|
||||
const modal = document.getElementById('detailModal');
|
||||
const modalTitle = document.getElementById('modalTitle').querySelector('span');
|
||||
const modalBody = document.getElementById('modalBody');
|
||||
|
||||
modalTitle.textContent = record.nsapp || 'Record Details';
|
||||
|
||||
// Build detail content with sections
|
||||
let html = '';
|
||||
|
||||
// General Information Section
|
||||
html += '<div class="detail-section">';
|
||||
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> General Information</div>';
|
||||
html += '<div class="detail-grid">';
|
||||
html += buildDetailItem('App Name', record.nsapp);
|
||||
html += buildDetailItem('Status', record.status, 'status-' + (record.status || 'unknown'));
|
||||
html += buildDetailItem('Type', formatType(record.type));
|
||||
html += buildDetailItem('Method', record.method || 'default');
|
||||
html += buildDetailItem('Random ID', record.random_id, 'mono');
|
||||
html += '</div></div>';
|
||||
|
||||
// System Resources Section
|
||||
html += '<div class="detail-section">';
|
||||
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg> System Resources</div>';
|
||||
html += '<div class="detail-grid">';
|
||||
html += buildDetailItem('CPU Cores', record.core_count ? record.core_count + ' Cores' : null);
|
||||
html += buildDetailItem('RAM', record.ram_size ? formatBytes(record.ram_size * 1024 * 1024) : null);
|
||||
html += buildDetailItem('Disk Size', record.disk_size ? record.disk_size + ' GB' : null);
|
||||
html += buildDetailItem('CT Type', record.ct_type !== undefined ? (record.ct_type === 1 ? 'Unprivileged' : 'Privileged') : null);
|
||||
html += '</div></div>';
|
||||
|
||||
// Operating System Section
|
||||
html += '<div class="detail-section">';
|
||||
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg> Operating System</div>';
|
||||
html += '<div class="detail-grid">';
|
||||
html += buildDetailItem('OS Type', record.os_type);
|
||||
html += buildDetailItem('OS Version', record.os_version);
|
||||
html += buildDetailItem('PVE Version', record.pve_version);
|
||||
html += '</div></div>';
|
||||
|
||||
// Hardware Section (CPU & GPU)
|
||||
const hasHardwareInfo = record.cpu_vendor || record.cpu_model || record.gpu_vendor || record.gpu_model || record.ram_speed;
|
||||
if (hasHardwareInfo) {
|
||||
html += '<div class="detail-section">';
|
||||
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> Hardware</div>';
|
||||
html += '<div class="detail-grid">';
|
||||
html += buildDetailItem('CPU Vendor', record.cpu_vendor);
|
||||
html += buildDetailItem('CPU Model', record.cpu_model);
|
||||
html += buildDetailItem('RAM Speed', record.ram_speed);
|
||||
html += buildDetailItem('GPU Vendor', record.gpu_vendor);
|
||||
html += buildDetailItem('GPU Model', record.gpu_model);
|
||||
html += buildDetailItem('GPU Passthrough', formatPassthrough(record.gpu_passthrough));
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Installation Details Section
|
||||
html += '<div class="detail-section">';
|
||||
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Installation</div>';
|
||||
html += '<div class="detail-grid">';
|
||||
html += buildDetailItem('Exit Code', record.exit_code !== undefined ? record.exit_code : null, record.exit_code === 0 ? 'status-success' : (record.exit_code ? 'status-failed' : ''));
|
||||
html += buildDetailItem('Duration', record.install_duration ? formatDuration(record.install_duration) : null);
|
||||
html += buildDetailItem('Error Category', record.error_category);
|
||||
html += '</div></div>';
|
||||
|
||||
// Error Section (if present)
|
||||
if (record.error) {
|
||||
html += '<div class="detail-section">';
|
||||
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg> Error Details</div>';
|
||||
html += '<div class="error-box">' + escapeHtml(record.error) + '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Timestamps Section
|
||||
html += '<div class="detail-section">';
|
||||
html += '<div class="detail-section-header"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Timestamps</div>';
|
||||
html += '<div class="detail-grid">';
|
||||
html += buildDetailItem('Created', formatFullTimestamp(record.created));
|
||||
html += buildDetailItem('Updated', formatFullTimestamp(record.updated));
|
||||
html += '</div></div>';
|
||||
|
||||
modalBody.innerHTML = html;
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function buildDetailItem(label, value, extraClass) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '<div class="detail-item"><div class="label">' + escapeHtml(label) + '</div><div class="value" style="color: var(--text-secondary);">—</div></div>';
|
||||
}
|
||||
const valueClass = extraClass ? 'value ' + extraClass : 'value';
|
||||
return '<div class="detail-item"><div class="label">' + escapeHtml(label) + '</div><div class="' + valueClass + '">' + escapeHtml(String(value)) + '</div></div>';
|
||||
}
|
||||
|
||||
function formatType(type) {
|
||||
if (!type) return null;
|
||||
const types = {
|
||||
'lxc': 'LXC Container',
|
||||
'vm': 'Virtual Machine',
|
||||
'addon': 'Add-on',
|
||||
'pve': 'Proxmox VE',
|
||||
'tool': 'Tool'
|
||||
};
|
||||
return types[type.toLowerCase()] || type;
|
||||
}
|
||||
|
||||
function formatPassthrough(pt) {
|
||||
if (!pt) return null;
|
||||
const modes = {
|
||||
'igpu': 'Integrated GPU',
|
||||
'dgpu': 'Dedicated GPU',
|
||||
'vgpu': 'Virtual GPU',
|
||||
'none': 'None',
|
||||
'unknown': 'Unknown'
|
||||
};
|
||||
return modes[pt.toLowerCase()] || pt;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return null;
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
if (gb >= 1) return gb.toFixed(1) + ' GB';
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return mb.toFixed(0) + ' MB';
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return null;
|
||||
if (seconds < 60) return seconds + 's';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins < 60) return mins + 'm ' + secs + 's';
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
return hours + 'h ' + remainMins + 'm';
|
||||
}
|
||||
|
||||
function formatFullTimestamp(ts) {
|
||||
if (!ts) return null;
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('detailModal');
|
||||
modal.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function closeModalOutside(event) {
|
||||
if (event.target === document.getElementById('detailModal')) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal with Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
closeHealthModal();
|
||||
}
|
||||
});
|
||||
|
||||
function filterTable() {
|
||||
currentPage = 1;
|
||||
fetchPaginatedRecords();
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (allRecords.length === 0) {
|
||||
alert('No data to export');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['App', 'Status', 'OS Type', 'OS Version', 'Type', 'Method', 'Cores', 'RAM (MB)', 'Disk (GB)', 'Exit Code', 'Error', 'PVE Version'];
|
||||
const rows = allRecords.map(r => [
|
||||
r.nsapp || '',
|
||||
r.status || '',
|
||||
r.os_type || '',
|
||||
r.os_version || '',
|
||||
r.type || '',
|
||||
r.method || '',
|
||||
r.core_count || '',
|
||||
r.ram_size || '',
|
||||
r.disk_size || '',
|
||||
r.exit_code || '',
|
||||
(r.error || '').replace(/,/g, ';'),
|
||||
r.pve_version || ''
|
||||
]);
|
||||
|
||||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'telemetry_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function showHealthCheck() {
|
||||
const modal = document.getElementById('healthModal');
|
||||
const body = document.getElementById('healthModalBody');
|
||||
body.innerHTML = '<div class="loading">Checking...</div>';
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/healthz');
|
||||
const data = await resp.json();
|
||||
|
||||
const isOk = data.status === 'ok';
|
||||
const statusClass = isOk ? 'ok' : 'error';
|
||||
const icon = isOk ? '✅' : '❌';
|
||||
const title = isOk ? 'All Systems Operational' : 'Service Degraded';
|
||||
|
||||
let html = '<div class="health-status ' + statusClass + '">';
|
||||
html += '<span class="icon">' + icon + '</span>';
|
||||
html += '<div class="details">';
|
||||
html += '<div class="title">' + title + '</div>';
|
||||
html += '<div class="subtitle">Last checked: ' + new Date().toLocaleTimeString() + '</div>';
|
||||
html += '</div></div>';
|
||||
|
||||
html += '<div class="health-info">';
|
||||
html += '<div><span>Status</span><span>' + data.status + '</span></div>';
|
||||
html += '<div><span>Server Time</span><span>' + new Date(data.time).toLocaleString() + '</span></div>';
|
||||
if (data.pocketbase) {
|
||||
html += '<div><span>PocketBase</span><span>' + (data.pocketbase === 'connected' ? '🟢 Connected' : '🔴 ' + data.pocketbase) + '</span></div>';
|
||||
}
|
||||
if (data.version) {
|
||||
html += '<div><span>Version</span><span>' + data.version + '</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
body.innerHTML = html;
|
||||
} catch (e) {
|
||||
body.innerHTML = '<div class="health-status error"><span class="icon">❌</span><div class="details"><div class="title">Connection Failed</div><div class="subtitle">' + e.message + '</div></div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeHealthModal(event) {
|
||||
if (event && event.target !== document.getElementById('healthModal')) return;
|
||||
document.getElementById('healthModal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
try {
|
||||
const data = await fetchData();
|
||||
updateStats(data);
|
||||
updateCharts(data);
|
||||
// Refresh paginated Installation Log with current filters (NOT from cached recent_records)
|
||||
currentPage = 1;
|
||||
fetchPaginatedRecords();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
refreshData();
|
||||
initSortableHeaders();
|
||||
|
||||
// Source button clicks
|
||||
document.querySelectorAll('.source-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.source-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
refreshData();
|
||||
});
|
||||
});
|
||||
|
||||
// Quickfilter button clicks
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
refreshData();
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-refresh functionality
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshEnabled = document.getElementById('autoRefreshToggle').checked;
|
||||
localStorage.setItem('autoRefresh', autoRefreshEnabled);
|
||||
|
||||
const intervalDisplay = document.getElementById('refreshInterval');
|
||||
|
||||
if (autoRefreshEnabled) {
|
||||
intervalDisplay.classList.add('active');
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
intervalDisplay.classList.remove('active');
|
||||
stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh(); // Clear any existing timer
|
||||
|
||||
let countdown = autoRefreshInterval / 1000;
|
||||
const intervalDisplay = document.getElementById('refreshInterval');
|
||||
|
||||
// Update countdown display
|
||||
const countdownTimer = setInterval(() => {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
countdown = autoRefreshInterval / 1000;
|
||||
}
|
||||
intervalDisplay.textContent = countdown + 's';
|
||||
}, 1000);
|
||||
|
||||
// Actual refresh
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
refreshData();
|
||||
countdown = autoRefreshInterval / 1000;
|
||||
}, autoRefreshInterval);
|
||||
|
||||
// Store countdown timer for cleanup
|
||||
autoRefreshTimer.countdownTimer = countdownTimer;
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer);
|
||||
if (autoRefreshTimer.countdownTimer) {
|
||||
clearInterval(autoRefreshTimer.countdownTimer);
|
||||
}
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
document.getElementById('refreshInterval').textContent = '15s';
|
||||
}
|
||||
|
||||
|
||||
// Initialize auto-refresh state on load
|
||||
document.getElementById('autoRefreshToggle').checked = autoRefreshEnabled;
|
||||
if (autoRefreshEnabled) {
|
||||
document.getElementById('refreshInterval').classList.add('active');
|
||||
startAutoRefresh();
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
let charts = {};
|
||||
let currentData = null;
|
||||
let currentTheme = localStorage.getItem('theme') || 'dark';
|
||||
if (currentTheme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
|
||||
const catColors = {
|
||||
'apt': '#ef4444', 'network': '#3b82f6', 'permission': '#f97316',
|
||||
'command_not_found': '#a855f7', 'user_aborted': '#64748b', 'timeout': '#eab308',
|
||||
'storage': '#ec4899', 'resource': '#f97316', 'dependency': '#22d3ee',
|
||||
'signal': '#eab308', 'config': '#84cc16', 'unknown': '#64748b',
|
||||
'uncategorized': '#94a3b8'
|
||||
};
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\n/g, ' ').replace(/\r/g, ' ');
|
||||
}
|
||||
|
||||
function toggleError(id) {
|
||||
var s = document.getElementById(id + '-short');
|
||||
var f = document.getElementById(id + '-full');
|
||||
if (f && s) {
|
||||
if (f.style.display === 'none') { f.style.display = 'block'; s.style.display = 'none'; }
|
||||
else { f.style.display = 'none'; s.style.display = 'block'; }
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '-';
|
||||
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
const days = document.querySelector('.filter-btn.active')?.dataset.days || '1';
|
||||
const repo = document.querySelector('.source-btn.active')?.dataset.repo || 'ProxmoxVE';
|
||||
try {
|
||||
const resp = await fetch('/api/errors?days=' + days + '&repo=' + repo);
|
||||
if (!resp.ok) throw new Error('Fetch failed');
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
document.getElementById('totalErrors').textContent = (data.total_errors || 0).toLocaleString();
|
||||
document.getElementById('failRate').textContent = (data.overall_fail_rate || 0).toFixed(1) + '%';
|
||||
document.getElementById('stuckCount2').textContent = (data.stuck_installing || 0).toLocaleString();
|
||||
document.getElementById('totalInstalls').textContent = (data.total_installs || 0).toLocaleString();
|
||||
|
||||
// Stuck banner
|
||||
if (data.stuck_installing > 0) {
|
||||
document.getElementById('stuckBanner').style.display = 'flex';
|
||||
document.getElementById('stuckCount').textContent = data.stuck_installing;
|
||||
} else {
|
||||
document.getElementById('stuckBanner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateExitCodeTable(exitCodes) {
|
||||
const tbody = document.getElementById('exitCodeTable');
|
||||
if (!exitCodes || exitCodes.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-muted);padding:24px;">No exit code data</td></tr>';
|
||||
return;
|
||||
}
|
||||
const maxCount = Math.max(...exitCodes.map(e => e.count));
|
||||
tbody.innerHTML = exitCodes.map(e => {
|
||||
const barWidth = (e.count / maxCount * 100).toFixed(0);
|
||||
const codeClass = e.exit_code === 0 ? 'ok' : 'err';
|
||||
const catClass = (e.category || 'unknown').replace(/ /g, '_');
|
||||
return '<tr>' +
|
||||
'<td><span class="exit-code ' + codeClass + '">' + e.exit_code + '</span></td>' +
|
||||
'<td>' + escapeHtml(e.description) + '</td>' +
|
||||
'<td><span class="category-badge ' + catClass + '">' + escapeHtml(e.category) + '</span></td>' +
|
||||
'<td><strong>' + e.count.toLocaleString() + '</strong></td>' +
|
||||
'<td>' + e.percentage.toFixed(1) + '%</td>' +
|
||||
'<td style="min-width:150px;"><div class="progress-bar"><div class="progress-bar-fill" style="width:' + barWidth + '%;background:var(--accent-red);"></div></div></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateCategoryTable(categories) {
|
||||
const tbody = document.getElementById('categoryTable');
|
||||
if (!categories || categories.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:24px;">No category data</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = categories.map(c => {
|
||||
const catClass = (c.category || 'unknown').replace(/ /g, '_');
|
||||
return '<tr>' +
|
||||
'<td><span class="category-badge ' + catClass + '">' + escapeHtml(c.category) + '</span></td>' +
|
||||
'<td><strong>' + c.count.toLocaleString() + '</strong></td>' +
|
||||
'<td>' + c.percentage.toFixed(1) + '%</td>' +
|
||||
'<td style="font-size:12px;color:var(--text-secondary);max-width:400px;overflow:hidden;text-overflow:ellipsis;">' + escapeHtml(c.top_apps) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
let allAppErrors = [];
|
||||
function updateAppErrorTable(apps) {
|
||||
allAppErrors = apps || [];
|
||||
filterAppTable();
|
||||
}
|
||||
|
||||
function filterAppTable() {
|
||||
const filter = (document.getElementById('appFilter').value || '').toLowerCase();
|
||||
const filtered = filter ? allAppErrors.filter(a => a.app.toLowerCase().includes(filter)) : allAppErrors;
|
||||
const tbody = document.getElementById('appErrorTable');
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-muted);padding:24px;">No matching apps</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = filtered.map((a, idx) => {
|
||||
const typeClass = (a.type || '').toLowerCase();
|
||||
const failRateColor = a.failure_rate > 50 ? 'var(--accent-red)' : a.failure_rate > 20 ? 'var(--accent-orange)' : 'var(--accent-yellow)';
|
||||
const topCat = a.top_category ? '<span class="category-badge ' + a.top_category + '">' + escapeHtml(a.top_category) + '</span>' : '-';
|
||||
const errorId = 'err-app-' + idx;
|
||||
const shortError = escapeHtml((a.top_error || '-').substring(0, 120));
|
||||
const fullError = escapeHtml(a.top_error || '-');
|
||||
const isLong = (a.top_error || '').length > 120;
|
||||
return '<tr>' +
|
||||
'<td><strong>' + escapeHtml(a.app) + '</strong></td>' +
|
||||
'<td><span class="type-badge ' + typeClass + '">' + (a.type || '-').toUpperCase() + '</span></td>' +
|
||||
'<td>' + a.total_count + '</td>' +
|
||||
'<td style="color:var(--accent-red);font-weight:600;">' + a.failed_count + '</td>' +
|
||||
'<td style="color:var(--accent-purple);">' + (a.aborted_count || 0) + '</td>' +
|
||||
'<td style="color:' + failRateColor + ';font-weight:600;">' + a.failure_rate.toFixed(1) + '%</td>' +
|
||||
'<td>' + (a.top_exit_code ? '<span class="exit-code err">' + a.top_exit_code + '</span>' : '-') + '</td>' +
|
||||
'<td class="error-text">' +
|
||||
'<div id="' + errorId + '-short">' + shortError + (isLong ? ' <a href="#" onclick="toggleError(\'' + errorId + '\');return false;" style="color:var(--accent-blue);font-size:11px;">show more</a>' : '') + '</div>' +
|
||||
(isLong ? '<div id="' + errorId + '-full" style="display:none;white-space:pre-wrap;word-break:break-all;max-height:600px;overflow-y:auto;">' + fullError + ' <a href="#" onclick="toggleError(\'' + errorId + '\');return false;" style="color:var(--accent-blue);font-size:11px;">show less</a></div>' : '') +
|
||||
'</td>' +
|
||||
'<td><button class="btn issue-btn" data-app="' + escapeAttr(a.app) + '" data-exit="' + (a.top_exit_code || 0) + '" data-error="' + escapeAttr(a.top_error || '') + '" data-rate="' + a.failure_rate.toFixed(1) + '">🐛 Issue</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateRecentErrors(errors) {
|
||||
const tbody = document.getElementById('recentErrorTable');
|
||||
if (!errors || errors.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-muted);padding:24px;">No recent errors</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = errors.map((e, idx) => {
|
||||
const statusClass = e.status || 'unknown';
|
||||
const typeClass = (e.type || '').toLowerCase();
|
||||
const codeClass = e.exit_code === 0 ? 'ok' : 'err';
|
||||
const catClass = (e.error_category || 'unknown').replace(/ /g, '_');
|
||||
const os = e.os_type ? e.os_type + (e.os_version ? ' ' + e.os_version : '') : '-';
|
||||
const errorId = 'err-recent-' + idx;
|
||||
const shortError = escapeHtml((e.error || '-').substring(0, 120));
|
||||
const fullError = escapeHtml(e.error || '-');
|
||||
const isLong = (e.error || '').length > 120;
|
||||
return '<tr>' +
|
||||
'<td><span class="status-badge ' + statusClass + '">' + escapeHtml(e.status) + '</span></td>' +
|
||||
'<td><span class="type-badge ' + typeClass + '">' + (e.type || '-').toUpperCase() + '</span></td>' +
|
||||
'<td><strong>' + escapeHtml(e.nsapp) + '</strong></td>' +
|
||||
'<td><span class="exit-code ' + codeClass + '">' + e.exit_code + '</span></td>' +
|
||||
'<td><span class="category-badge ' + catClass + '">' + escapeHtml(e.error_category || 'unknown') + '</span></td>' +
|
||||
'<td class="error-text">' +
|
||||
'<div id="' + errorId + '-short">' + shortError + (isLong ? ' <a href="#" onclick="toggleError(\'' + errorId + '\');return false;" style="color:var(--accent-blue);font-size:11px;">show more</a>' : '') + '</div>' +
|
||||
(isLong ? '<div id="' + errorId + '-full" style="display:none;white-space:pre-wrap;word-break:break-all;max-height:600px;overflow-y:auto;">' + fullError + ' <a href="#" onclick="toggleError(\'' + errorId + '\');return false;" style="color:var(--accent-blue);font-size:11px;">show less</a></div>' : '') +
|
||||
'</td>' +
|
||||
'<td>' + escapeHtml(os) + '</td>' +
|
||||
'<td style="white-space:nowrap;">' + formatTimestamp(e.created) + '</td>' +
|
||||
'<td><button class="btn issue-btn" data-app="' + escapeAttr(e.nsapp) + '" data-exit="' + e.exit_code + '" data-error="' + escapeAttr(e.error || '') + '" data-rate="0">🐛</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateCharts(data) {
|
||||
// Timeline chart
|
||||
if (charts.timeline) charts.timeline.destroy();
|
||||
const timeline = data.error_timeline || [];
|
||||
charts.timeline = new Chart(document.getElementById('timelineChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeline.map(d => d.date.slice(5)),
|
||||
datasets: [
|
||||
{ label: 'Failed', data: timeline.map(d => d.failed), borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.1)', fill: true, tension: 0.4, borderWidth: 2 },
|
||||
{ label: 'Aborted', data: timeline.map(d => d.aborted), borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.1)', fill: true, tension: 0.4, borderWidth: 2 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#8b949e', usePointStyle: true } } }, scales: { x: { ticks: { color: '#8b949e' }, grid: { color: '#2d3748' } }, y: { ticks: { color: '#8b949e' }, grid: { color: '#2d3748' } } } }
|
||||
});
|
||||
|
||||
// Category pie chart
|
||||
if (charts.category) charts.category.destroy();
|
||||
const cats = data.category_stats || [];
|
||||
charts.category = new Chart(document.getElementById('categoryChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: cats.map(c => c.category),
|
||||
datasets: [{ data: cats.map(c => c.count), backgroundColor: cats.map(c => catColors[c.category] || '#64748b'), borderWidth: 0 }]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: '#8b949e', padding: 12 } } } }
|
||||
});
|
||||
}
|
||||
|
||||
// GitHub Issue Modal
|
||||
function openIssueModal(app, exitCode, errorText, failRate) {
|
||||
const title = '[Telemetry] ' + app + ': Error (exit code ' + exitCode + ')';
|
||||
const fence = String.fromCharCode(96, 96, 96);
|
||||
const body = '## Telemetry Error Report\n\n' +
|
||||
'**Application:** ' + app + '\n' +
|
||||
'**Exit Code:** ' + exitCode + '\n' +
|
||||
'**Failure Rate:** ' + failRate + '%\n\n' +
|
||||
'### Error Details\n' + fence + '\n' + errorText + '\n' + fence + '\n\n' +
|
||||
'---\n*Created from telemetry error analysis dashboard.*';
|
||||
document.getElementById('issueTitle').value = title;
|
||||
document.getElementById('issueBody').value = body;
|
||||
document.getElementById('issueAlert').style.display = 'none';
|
||||
document.getElementById('issueModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeIssueModal() {
|
||||
document.getElementById('issueModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function submitIssue() {
|
||||
const btn = document.getElementById('submitIssueBtn');
|
||||
const alert = document.getElementById('issueAlert');
|
||||
const password = document.getElementById('issuePassword').value;
|
||||
if (!password) { alert.className = 'alert-box error'; alert.textContent = 'Password required'; alert.style.display = 'block'; return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/github/create-issue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
password: password,
|
||||
title: document.getElementById('issueTitle').value,
|
||||
body: document.getElementById('issueBody').value,
|
||||
labels: document.getElementById('issueLabels').value.split(',').map(l => l.trim()).filter(Boolean)
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok && data.success) {
|
||||
alert.className = 'alert-box success';
|
||||
alert.innerHTML = '✅ Issue created! <a href="' + data.issue_url + '" target="_blank" style="color:var(--accent-green);">View on GitHub →</a>';
|
||||
alert.style.display = 'block';
|
||||
} else {
|
||||
throw new Error(data.error || data.message || resp.statusText || 'Failed');
|
||||
}
|
||||
} catch (e) {
|
||||
alert.className = 'alert-box error';
|
||||
alert.textContent = '❌ ' + e.message;
|
||||
alert.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Issue';
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup Modal
|
||||
function triggerCleanup() {
|
||||
document.getElementById('cleanupAlert').style.display = 'none';
|
||||
document.getElementById('cleanupModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeCleanupModal() {
|
||||
document.getElementById('cleanupModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function runCleanup() {
|
||||
const btn = document.getElementById('runCleanupBtn');
|
||||
const alert = document.getElementById('cleanupAlert');
|
||||
const password = document.getElementById('cleanupPassword').value;
|
||||
if (!password) { alert.className = 'alert-box error'; alert.textContent = 'Password required'; alert.style.display = 'block'; return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Running...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/cleanup/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
alert.className = 'alert-box success';
|
||||
alert.textContent = '✅ ' + data.message;
|
||||
alert.style.display = 'block';
|
||||
setTimeout(() => { closeCleanupModal(); refreshData(); }, 2000);
|
||||
} else {
|
||||
throw new Error(data.message || resp.statusText || 'Failed');
|
||||
}
|
||||
} catch (e) {
|
||||
alert.className = 'alert-box error';
|
||||
alert.textContent = '❌ ' + e.message;
|
||||
alert.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Run Cleanup';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
const data = await fetchData();
|
||||
if (!data) return;
|
||||
currentData = data;
|
||||
updateStats(data);
|
||||
updateExitCodeTable(data.exit_code_stats);
|
||||
updateCategoryTable(data.category_stats);
|
||||
updateAppErrorTable(data.app_errors);
|
||||
updateRecentErrors(data.recent_errors);
|
||||
updateCharts(data);
|
||||
}
|
||||
|
||||
// Filter button handling
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
refreshData();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.source-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.source-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
refreshData();
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeIssueModal(); closeCleanupModal(); } });
|
||||
|
||||
// Event delegation for Issue buttons (avoids inline onclick escaping issues)
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.issue-btn');
|
||||
if (!btn) return;
|
||||
var app = btn.getAttribute('data-app') || '';
|
||||
var exitCode = parseInt(btn.getAttribute('data-exit') || '0', 10);
|
||||
var errorText = btn.getAttribute('data-error') || '';
|
||||
var rate = parseFloat(btn.getAttribute('data-rate') || '0');
|
||||
openIssueModal(app, exitCode, errorText, rate);
|
||||
});
|
||||
|
||||
// Initial load
|
||||
refreshData();
|
||||
@@ -0,0 +1,197 @@
|
||||
let currentData = null;
|
||||
let expandTop = false;
|
||||
let expandBottom = false;
|
||||
let expandRecent = false;
|
||||
const LIMIT = 10;
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '-';
|
||||
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
const days = document.querySelector('.filter-btn.active')?.dataset.days || '30';
|
||||
const repo = 'ProxmoxVE';
|
||||
try {
|
||||
const resp = await fetch('/api/scripts?days=' + days + '&repo=' + repo);
|
||||
if (!resp.ok) throw new Error('Fetch failed');
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
console.error('Fetch error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(data) {
|
||||
document.getElementById('totalInstalls').textContent = (data.total_installs || 0).toLocaleString();
|
||||
document.getElementById('uniqueScripts').textContent = (data.total_scripts || 0).toLocaleString();
|
||||
const avg = data.total_scripts > 0 ? (data.total_installs / data.total_scripts).toFixed(1) : '0';
|
||||
document.getElementById('avgInstalls').textContent = avg;
|
||||
}
|
||||
|
||||
function renderTopTable() {
|
||||
const tbody = document.getElementById('topTableBody');
|
||||
if (!currentData || !currentData.top_scripts) {
|
||||
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;color:var(--text-muted);padding:24px;">No data</td></tr>';
|
||||
return;
|
||||
}
|
||||
const search = (document.getElementById('searchTop').value || '').toLowerCase();
|
||||
let scripts = currentData.top_scripts;
|
||||
if (search) {
|
||||
scripts = scripts.filter(s => s.app.toLowerCase().includes(search) || (s.type || '').toLowerCase().includes(search));
|
||||
}
|
||||
const limit = expandTop ? scripts.length : Math.min(LIMIT, scripts.length);
|
||||
const shown = scripts.slice(0, limit);
|
||||
|
||||
tbody.innerHTML = shown.map((s, idx) => {
|
||||
const typeClass = (s.type || '').toLowerCase();
|
||||
const rateColor = s.success_rate >= 90 ? 'var(--accent-green)' : s.success_rate >= 70 ? 'var(--accent-yellow)' : 'var(--accent-red)';
|
||||
const total = s.success + s.failed + s.aborted + s.installing;
|
||||
const pctSuccess = total > 0 ? (s.success / total * 100) : 0;
|
||||
const pctFailed = total > 0 ? (s.failed / total * 100) : 0;
|
||||
const pctAborted = total > 0 ? (s.aborted / total * 100) : 0;
|
||||
const pctInstalling = total > 0 ? (s.installing / total * 100) : 0;
|
||||
const ipd = (s.installs_per_day || 0).toFixed(2);
|
||||
const ipdColor = s.installs_per_day >= 10 ? 'var(--accent-green)' : s.installs_per_day >= 1 ? 'var(--accent-cyan)' : 'var(--text-muted)';
|
||||
return '<tr>' +
|
||||
'<td style="color:var(--text-muted);font-weight:600;">' + (idx + 1) + '</td>' +
|
||||
'<td><strong>' + escapeHtml(s.app) + '</strong></td>' +
|
||||
'<td><span class="type-badge ' + typeClass + '">' + (s.type || '-').toUpperCase() + '</span></td>' +
|
||||
'<td style="font-weight:600;">' + s.total.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-green);">' + s.success.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-red);">' + s.failed.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-purple);">' + s.aborted.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-yellow);">' + s.installing.toLocaleString() + '</td>' +
|
||||
'<td style="color:' + rateColor + ';font-weight:600;">' + s.success_rate.toFixed(1) + '%</td>' +
|
||||
'<td style="color:' + ipdColor + ';font-weight:600;">' + ipd + '</td>' +
|
||||
'<td><div class="success-bar">' +
|
||||
'<div class="seg-success" style="width:' + pctSuccess + '%"></div>' +
|
||||
'<div class="seg-failed" style="width:' + pctFailed + '%"></div>' +
|
||||
'<div class="seg-aborted" style="width:' + pctAborted + '%"></div>' +
|
||||
'<div class="seg-installing" style="width:' + pctInstalling + '%"></div>' +
|
||||
'</div></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
document.getElementById('expandTopBtn').textContent = expandTop ? 'Show Top 10' : 'Show All (' + scripts.length + ')';
|
||||
}
|
||||
|
||||
function renderBottomTable() {
|
||||
const tbody = document.getElementById('bottomTableBody');
|
||||
if (!currentData || !currentData.top_scripts) {
|
||||
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;color:var(--text-muted);padding:24px;">No data</td></tr>';
|
||||
return;
|
||||
}
|
||||
const search = (document.getElementById('searchBottom').value || '').toLowerCase();
|
||||
// Reverse: least used first
|
||||
let scripts = [...currentData.top_scripts].reverse();
|
||||
if (search) {
|
||||
scripts = scripts.filter(s => s.app.toLowerCase().includes(search) || (s.type || '').toLowerCase().includes(search));
|
||||
}
|
||||
const limit = expandBottom ? scripts.length : Math.min(LIMIT, scripts.length);
|
||||
const shown = scripts.slice(0, limit);
|
||||
const totalScripts = currentData.top_scripts.length;
|
||||
|
||||
tbody.innerHTML = shown.map((s, idx) => {
|
||||
const typeClass = (s.type || '').toLowerCase();
|
||||
const rateColor = s.success_rate >= 90 ? 'var(--accent-green)' : s.success_rate >= 70 ? 'var(--accent-yellow)' : 'var(--accent-red)';
|
||||
const total = s.success + s.failed + s.aborted + s.installing;
|
||||
const pctSuccess = total > 0 ? (s.success / total * 100) : 0;
|
||||
const pctFailed = total > 0 ? (s.failed / total * 100) : 0;
|
||||
const pctAborted = total > 0 ? (s.aborted / total * 100) : 0;
|
||||
const pctInstalling = total > 0 ? (s.installing / total * 100) : 0;
|
||||
const ipd = (s.installs_per_day || 0).toFixed(2);
|
||||
const ipdColor = s.installs_per_day >= 10 ? 'var(--accent-green)' : s.installs_per_day >= 1 ? 'var(--accent-cyan)' : 'var(--text-muted)';
|
||||
return '<tr>' +
|
||||
'<td style="color:var(--text-muted);font-weight:600;">' + (totalScripts - idx) + '</td>' +
|
||||
'<td><strong>' + escapeHtml(s.app) + '</strong></td>' +
|
||||
'<td><span class="type-badge ' + typeClass + '">' + (s.type || '-').toUpperCase() + '</span></td>' +
|
||||
'<td style="font-weight:600;">' + s.total.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-green);">' + s.success.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-red);">' + s.failed.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-purple);">' + s.aborted.toLocaleString() + '</td>' +
|
||||
'<td style="color:var(--accent-yellow);">' + s.installing.toLocaleString() + '</td>' +
|
||||
'<td style="color:' + rateColor + ';font-weight:600;">' + s.success_rate.toFixed(1) + '%</td>' +
|
||||
'<td style="color:' + ipdColor + ';font-weight:600;">' + ipd + '</td>' +
|
||||
'<td><div class="success-bar">' +
|
||||
'<div class="seg-success" style="width:' + pctSuccess + '%"></div>' +
|
||||
'<div class="seg-failed" style="width:' + pctFailed + '%"></div>' +
|
||||
'<div class="seg-aborted" style="width:' + pctAborted + '%"></div>' +
|
||||
'<div class="seg-installing" style="width:' + pctInstalling + '%"></div>' +
|
||||
'</div></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
document.getElementById('expandBottomBtn').textContent = expandBottom ? 'Show Bottom 10' : 'Show All (' + scripts.length + ')';
|
||||
}
|
||||
|
||||
function renderRecentTable() {
|
||||
const tbody = document.getElementById('recentTableBody');
|
||||
if (!currentData || !currentData.recent_scripts) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:24px;">No data</td></tr>';
|
||||
return;
|
||||
}
|
||||
const search = (document.getElementById('searchRecent').value || '').toLowerCase();
|
||||
let scripts = currentData.recent_scripts;
|
||||
if (search) {
|
||||
scripts = scripts.filter(s => s.app.toLowerCase().includes(search) || (s.status || '').toLowerCase().includes(search) || (s.type || '').toLowerCase().includes(search));
|
||||
}
|
||||
const limit = expandRecent ? scripts.length : Math.min(LIMIT, scripts.length);
|
||||
const shown = scripts.slice(0, limit);
|
||||
|
||||
tbody.innerHTML = shown.map(s => {
|
||||
const typeClass = (s.type || '').toLowerCase();
|
||||
const statusClass = s.status || 'unknown';
|
||||
const codeClass = s.exit_code === 0 ? 'ok' : 'err';
|
||||
const os = s.os_type ? s.os_type + (s.os_version ? ' ' + s.os_version : '') : '-';
|
||||
return '<tr>' +
|
||||
'<td><strong>' + escapeHtml(s.app) + '</strong></td>' +
|
||||
'<td><span class="type-badge ' + typeClass + '">' + (s.type || '-').toUpperCase() + '</span></td>' +
|
||||
'<td><span class="status-badge ' + statusClass + '">' + escapeHtml(s.status) + '</span></td>' +
|
||||
'<td><span class="exit-code ' + codeClass + '">' + s.exit_code + '</span></td>' +
|
||||
'<td>' + escapeHtml(os) + '</td>' +
|
||||
'<td>' + escapeHtml(s.pve_version || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(s.method || '-') + '</td>' +
|
||||
'<td style="white-space:nowrap;">' + formatTimestamp(s.created) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
document.getElementById('expandRecentBtn').textContent = expandRecent ? 'Show Last 10' : 'Show All (' + scripts.length + ')';
|
||||
}
|
||||
|
||||
function toggleExpand(which) {
|
||||
if (which === 'top') {
|
||||
expandTop = !expandTop;
|
||||
renderTopTable();
|
||||
} else if (which === 'bottom') {
|
||||
expandBottom = !expandBottom;
|
||||
renderBottomTable();
|
||||
} else {
|
||||
expandRecent = !expandRecent;
|
||||
renderRecentTable();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
const data = await fetchData();
|
||||
if (!data) return;
|
||||
currentData = data;
|
||||
updateStats(data);
|
||||
renderTopTable();
|
||||
renderBottomTable();
|
||||
renderRecentTable();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
refreshData();
|
||||
});
|
||||
});
|
||||
refreshData();
|
||||
@@ -0,0 +1,37 @@
|
||||
const svgCache = new Map()
|
||||
|
||||
function inlineSVGs() {
|
||||
document.querySelectorAll('img[src$=".svg"]:not([data-inlined])').forEach(img => {
|
||||
const url = img.src
|
||||
img.dataset.inlined = "true"
|
||||
|
||||
if (svgCache.has(url)) {
|
||||
replaceWithSvg(img, svgCache.get(url))
|
||||
return
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.text())
|
||||
.then(svgText => {
|
||||
replaceWithSvg(img, svgText)
|
||||
svgCache.set(url, svgText); // Store in cache
|
||||
})
|
||||
.catch(console.error)
|
||||
})
|
||||
}
|
||||
|
||||
function replaceWithSvg(img, svgText) {
|
||||
// Parse safely (prevents XSS)
|
||||
const parser = new DOMParser()
|
||||
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml')
|
||||
const svg = svgDoc.querySelector('svg')
|
||||
|
||||
if (!svg) return
|
||||
|
||||
svg.querySelectorAll('[fill]:not([fill="none"])').forEach(el => {
|
||||
el.setAttribute('fill', 'currentColor');
|
||||
})
|
||||
|
||||
img.replaceWith(svg)
|
||||
}
|
||||
inlineSVGs()
|
||||
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Analytics - Proxmox VE Helper-Scripts</title>
|
||||
<meta name="description" content="Installation analytics and telemetry for Proxmox VE Helper Scripts">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="/static/js/svg-inliner.js" defer></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="navbar">
|
||||
<a href="https://community-scripts.github.io/ProxmoxVE/" class="navbar-brand" target="_blank">
|
||||
<img width="28" src="/static/img/logo.png" alt="">
|
||||
Proxmox VE Helper-Scripts
|
||||
</a>
|
||||
|
||||
<div class="navbar-center">
|
||||
<div style="display: flex; align-items: center; gap: 12px;">
|
||||
<span id="loadingIndicator" style="display: none; color: var(--accent-cyan); font-size: 14px;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation: spin 1s linear infinite; margin-right: 6px;">
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.3"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10"/>
|
||||
</svg>
|
||||
Loading data...
|
||||
</span>
|
||||
<span id="cacheStatus" style="font-size: 12px; color: var(--text-muted);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-actions">
|
||||
<a href="/error-analysis" class="nav-icon" title="Error Analysis" style="font-size:14px;text-decoration:none;">🔍 Errors</a>
|
||||
<a href="/script-analysis" class="nav-icon" title="Script Analysis" style="font-size:14px;text-decoration:none;">📜 Scripts</a>
|
||||
<a href="https://github.com/community-scripts/ProxmoxVE" target="_blank" class="github-stars" id="githubStars">
|
||||
<img src="/static/img/star.svg" alt="">
|
||||
<span id="starCount">-</span>
|
||||
</a>
|
||||
<a href="https://github.com/community-scripts/ProxmoxVE" target="_blank" class="nav-icon" title="GitHub">
|
||||
<img src="/static/img/github.svg">
|
||||
</a>
|
||||
<a href="https://discord.gg/2wvnMDgeFz" target="_blank" class="nav-icon" title="Discord">
|
||||
<img src="/static/img/discord.svg">
|
||||
</a>
|
||||
<button class="theme-toggle nav-icon" onclick="toggleTheme()" title="Toggle theme">
|
||||
<span id="themeIcon">🌙</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1>Analytics</h1>
|
||||
<p>Overview of container installations and system statistics.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters Bar -->
|
||||
<div class="filters-bar" style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; margin-bottom: 24px;">
|
||||
<div class="filter-group">
|
||||
<label>Source:</label>
|
||||
<div class="quickfilter">
|
||||
<button class="source-btn active" data-repo="ProxmoxVE">ProxmoxVE</button>
|
||||
<button class="source-btn" data-repo="ProxmoxVED">ProxmoxVED</button>
|
||||
<button class="source-btn" data-repo="external">External</button>
|
||||
<button class="source-btn" data-repo="all">All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-divider"></div>
|
||||
<div class="filter-group">
|
||||
<label>Period:</label>
|
||||
<div class="quickfilter">
|
||||
<button class="filter-btn active" data-days="1">Today</button>
|
||||
<button class="filter-btn" data-days="7">7 Days</button>
|
||||
<button class="filter-btn" data-days="30">30 Days</button>
|
||||
<button class="filter-btn" data-days="90">90 Days</button>
|
||||
<button class="filter-btn" data-days="365">1 Year</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-divider"></div>
|
||||
<div class="auto-refresh-toggle">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="autoRefreshToggle" onchange="toggleAutoRefresh()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span>Auto-refresh</span>
|
||||
<span class="auto-refresh-interval" id="refreshInterval">15s</span>
|
||||
</div>
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<button class="btn" onclick="refreshData()">
|
||||
<img src="/static/img/refresh.svg" alt="">
|
||||
Refresh
|
||||
</button>
|
||||
<span class="last-updated" id="lastUpdated" style="align-self: center; font-size: 12px; color: var(--text-muted);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner -->
|
||||
<div id="error" class="error-banner" style="display: none;">
|
||||
<img src="/static/img/clock.svg" alt="">
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Total Created</span>
|
||||
<div class="stat-card-icon">
|
||||
<img src="/static/img/list.svg" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card-value" id="totalInstalls">-</div>
|
||||
<div class="stat-card-subtitle">Total LXC/VM entries found</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card success">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Success Rate</span>
|
||||
<div class="stat-card-icon">
|
||||
<img src="/static/img/success.svg" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card-value" id="successRate">-</div>
|
||||
<div class="stat-card-subtitle" id="successSubtitle">successful installations</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card failed">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Failed</span>
|
||||
<div class="stat-card-icon">
|
||||
<img src="/static/img/fail.svg" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card-value" id="failedCount">-</div>
|
||||
<div class="stat-card-subtitle" id="failedSubtitle">installation failures</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card aborted">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Aborted</span>
|
||||
<div class="stat-card-icon">
|
||||
<img src="/static/img/clock.svg" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card-value" id="abortedCount">-</div>
|
||||
<div class="stat-card-subtitle">user cancelled (Ctrl+C)</div>
|
||||
</div>
|
||||
|
||||
<!-- Most Popular Card -->
|
||||
<div class="stat-card podium-card">
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-card-label">Most Popular</span>
|
||||
<div class="stat-card-icon" style="font-size: 20px;">🏆</div>
|
||||
</div>
|
||||
<div class="mini-podium">
|
||||
<div class="mini-podium-item gold">
|
||||
<span class="medal">🥇</span>
|
||||
<span class="app-name" id="podium1App">-</span>
|
||||
<span class="count" id="podium1Count">-</span>
|
||||
</div>
|
||||
<div class="mini-podium-item silver">
|
||||
<span class="medal">🥈</span>
|
||||
<span class="app-name" id="podium2App">-</span>
|
||||
<span class="count" id="podium2Count">-</span>
|
||||
</div>
|
||||
<div class="mini-podium-item bronze">
|
||||
<span class="medal">🥉</span>
|
||||
<span class="app-name" id="podium3App">-</span>
|
||||
<span class="count" id="podium3Count">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Applications Section -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Top Applications</h2>
|
||||
<p>The most frequently installed applications.</p>
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
<button class="btn" id="viewAllAppsBtn" onclick="toggleAllApps()">
|
||||
<img src="/static/img/list2.svg" alt="">
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container" id="appsChartContainer">
|
||||
<canvas id="appsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secondary Charts -->
|
||||
<div class="section-card" style="margin-bottom: 24px;">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Installations Over Time</h2>
|
||||
<p>Daily success and failure trends.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 24px; height: 320px;">
|
||||
<canvas id="dailyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px;">
|
||||
<div class="chart-card">
|
||||
<h3>OS Distribution</h3>
|
||||
<div class="chart-wrapper" style="height: 260px;">
|
||||
<canvas id="osChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>Status Distribution</h3>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Analysis Section (combined) -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Error Analysis</h2>
|
||||
<p>Error patterns, failure rates, and applications that need attention. <span id="failedAppsThreshold" style="color: var(--text-muted); font-size: 12px;"></span></p>
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
<a href="/error-analysis" class="btn" style="text-decoration:none;">
|
||||
<img src="/static/img/search.svg" alt="">
|
||||
Deep Analysis
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-analysis-grid">
|
||||
<div class="error-analysis-col">
|
||||
<h3 class="error-analysis-subtitle">
|
||||
<img src="/static/img/fail2.svg" alt="">
|
||||
Common Error Patterns
|
||||
</h3>
|
||||
<div class="error-list" id="errorList">
|
||||
<div class="loading"><div class="loading-spinner"></div>Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-analysis-col">
|
||||
<h3 class="error-analysis-subtitle">
|
||||
<img src="/static/img/heartbeat.svg" alt="">
|
||||
Highest Failure Rates
|
||||
</h3>
|
||||
<div class="failed-apps-grid" id="failedAppsGrid">
|
||||
<div class="loading"><div class="loading-spinner"></div>Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installation Log Section -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Installation Log</h2>
|
||||
<p>Detailed records of all container creation attempts.</p>
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
<button class="btn" onclick="exportCSV()">
|
||||
<img src="/static/img/download.svg" alt="">
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters-bar">
|
||||
<input type="text" class="search-input" id="filterApp" placeholder="Filter by application..." oninput="filterTable()">
|
||||
<select id="filterStatus" class="custom-select" onchange="filterTable()">
|
||||
<option value="">All Status</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="aborted">Aborted</option>
|
||||
<option value="installing">Installing</option>
|
||||
<option value="configuring">Configuring</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
<select id="filterOs" class="custom-select" onchange="filterTable()">
|
||||
<option value="">All OS</option>
|
||||
</select>
|
||||
<select id="filterType" class="custom-select" onchange="filterTable()">
|
||||
<option value="">All Types</option>
|
||||
<option value="lxc">LXC</option>
|
||||
<option value="vm">VM</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table id="installTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="status" class="sortable">Status</th>
|
||||
<th data-sort="type" class="sortable">Type</th>
|
||||
<th data-sort="nsapp" class="sortable">Application</th>
|
||||
<th data-sort="os_type" class="sortable">OS</th>
|
||||
<th>Disk Size</th>
|
||||
<th>Core Count</th>
|
||||
<th>RAM Size</th>
|
||||
<th data-sort="created" class="sortable sort-desc">Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recordsTable">
|
||||
<tr><td colspan="8"><div class="loading"><div class="loading-spinner"></div>Loading...</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-footer">
|
||||
<div class="per-page-select">
|
||||
<label>Show:</label>
|
||||
<select id="perPageSelect" class="custom-select" onchange="changePerPage()">
|
||||
<option value="25" selected>25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button onclick="prevPage()" id="prevBtn" disabled>Previous</button>
|
||||
<span class="pagination-info" id="pageInfo">Page 1</span>
|
||||
<button onclick="nextPage()" id="nextBtn">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Check Modal -->
|
||||
<div class="modal-overlay" id="healthModal" onclick="closeHealthModal(event)">
|
||||
<div class="modal-content health-modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2>Health Check</h2>
|
||||
<button class="modal-close" onclick="closeHealthModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="healthModalBody">
|
||||
<div class="loading"><div class="loading-spinner"></div>Checking...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<div class="modal-overlay" id="detailModal" onclick="closeModalOutside(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">
|
||||
<img src="/static/img/list.svg" alt="">
|
||||
<span>Record Details</span>
|
||||
</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- Content filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,279 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error Analysis - Proxmox VE Helper-Scripts</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="/static/js/svg-inliner.js" defer></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/error-analysis.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="navbar-brand">
|
||||
<img width="28" src="/static/img/logo.png" alt="">
|
||||
Proxmox VE Helper-Scripts
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">📊 Dashboard</a>
|
||||
<a href="/error-analysis" class="nav-link active">🔍 Error Analysis</a>
|
||||
<a href="/script-analysis" class="nav-link">📜 Scripts</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="page-header">
|
||||
<h1>🔍 Error Analysis</h1>
|
||||
<p>Detailed error and failure analysis with exit code breakdowns.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-bar" style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; margin-bottom: 24px;">
|
||||
<div class="filter-group">
|
||||
<label>Source:</label>
|
||||
<div class="quickfilter">
|
||||
<button class="source-btn active" data-repo="ProxmoxVE">ProxmoxVE</button>
|
||||
<button class="source-btn" data-repo="ProxmoxVED">ProxmoxVED</button>
|
||||
<button class="source-btn" data-repo="all">All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-divider"></div>
|
||||
<div class="filter-group">
|
||||
<label>Period:</label>
|
||||
<div class="quickfilter">
|
||||
<button class="filter-btn active" data-days="1">Today</button>
|
||||
<button class="filter-btn" data-days="7">7 Days</button>
|
||||
<button class="filter-btn" data-days="30">30 Days</button>
|
||||
<button class="filter-btn" data-days="90">90 Days</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-left: auto;">
|
||||
<button class="btn" onclick="refreshData()">
|
||||
<img src="/static/img/refresh.svg" alt="">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stuck Installing Banner -->
|
||||
<div id="stuckBanner" class="stuck-banner" style="display: none;">
|
||||
<div>
|
||||
<strong>⚠️ <span id="stuckCount">0</span> installations stuck in "Installing" state</strong>
|
||||
<div style="font-size: 12px; margin-top: 4px;">These records have not received a completion status. Cleanup runs every 15 min for records older than 2h.</div>
|
||||
</div>
|
||||
<button class="btn btn-danger" onclick="triggerCleanup()">🧹 Run Cleanup Now</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card red">
|
||||
<div class="label">Total Errors</div>
|
||||
<div class="value" id="totalErrors">-</div>
|
||||
<div class="sub">failed + aborted</div>
|
||||
</div>
|
||||
<div class="stat-card orange">
|
||||
<div class="label">Overall Failure Rate</div>
|
||||
<div class="value" id="failRate">-</div>
|
||||
<div class="sub">of all installations</div>
|
||||
</div>
|
||||
<div class="stat-card yellow">
|
||||
<div class="label">Stuck Installing</div>
|
||||
<div class="value" id="stuckCount2">-</div>
|
||||
<div class="sub">no completion received</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="label">Total Installations</div>
|
||||
<div class="value" id="totalInstalls">-</div>
|
||||
<div class="sub">in selected period</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>Error Timeline</h3>
|
||||
<div class="chart-wrapper"><canvas id="timelineChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>Error Categories</h3>
|
||||
<div class="chart-wrapper"><canvas id="categoryChart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exit Code Distribution -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Exit Code Distribution</h2>
|
||||
<p>Breakdown of all exit codes returned by failed scripts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Exit Code</th>
|
||||
<th>Description</th>
|
||||
<th>Category</th>
|
||||
<th>Count</th>
|
||||
<th>Percentage</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="exitCodeTable">
|
||||
<tr><td colspan="6"><div class="loading"><div class="loading-spinner"></div>Loading...</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Categories -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Error Categories</h2>
|
||||
<p>Grouped errors by type with affected applications.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Count</th>
|
||||
<th>Percentage</th>
|
||||
<th>Affected Apps</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="categoryTable">
|
||||
<tr><td colspan="4"><div class="loading"><div class="loading-spinner"></div>Loading...</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apps with Errors -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Applications with Errors</h2>
|
||||
<p>All apps that experienced failures, sorted by error count.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" id="appFilter" placeholder="Filter by app name..." style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 8px 14px; border-radius: 8px; font-size: 13px; outline: none; width: 200px;" oninput="filterAppTable()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Application</th>
|
||||
<th>Type</th>
|
||||
<th>Total</th>
|
||||
<th>Failed</th>
|
||||
<th>Aborted</th>
|
||||
<th>Fail Rate</th>
|
||||
<th>Top Exit Code</th>
|
||||
<th>Top Error</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="appErrorTable">
|
||||
<tr><td colspan="9"><div class="loading"><div class="loading-spinner"></div>Loading...</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Error Log -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2>Recent Error Log</h2>
|
||||
<p>Last 100 failed or aborted installations.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Type</th>
|
||||
<th>Application</th>
|
||||
<th>Exit Code</th>
|
||||
<th>Category</th>
|
||||
<th>Error</th>
|
||||
<th>OS</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentErrorTable">
|
||||
<tr><td colspan="9"><div class="loading"><div class="loading-spinner"></div>Loading...</div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitHub Issue Modal -->
|
||||
<div class="modal-overlay" id="issueModal" onclick="if(event.target===this)closeIssueModal()">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2>🐛 Create GitHub Issue</h2>
|
||||
<button class="modal-close" onclick="closeIssueModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="issueAlert" class="alert-box" style="display: none;"></div>
|
||||
<div class="form-group">
|
||||
<label>Admin Password *</label>
|
||||
<input type="password" id="issuePassword" class="form-input" placeholder="Enter admin password...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Issue Title</label>
|
||||
<input type="text" id="issueTitle" class="form-input">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Issue Body</label>
|
||||
<textarea id="issueBody" class="form-input"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Labels (comma-separated)</label>
|
||||
<input type="text" id="issueLabels" class="form-input" value="bug, telemetry">
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button class="btn" onclick="closeIssueModal()">Cancel</button>
|
||||
<button class="btn btn-primary" id="submitIssueBtn" onclick="submitIssue()">Create Issue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cleanup Modal -->
|
||||
<div class="modal-overlay" id="cleanupModal" onclick="if(event.target===this)closeCleanupModal()">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2>🧹 Trigger Cleanup</h2>
|
||||
<button class="modal-close" onclick="closeCleanupModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="cleanupAlert" class="alert-box" style="display: none;"></div>
|
||||
<p style="margin-bottom: 16px; color: var(--text-secondary);">This will mark all stuck "Installing" records (older than 2h) as "unknown". This action requires the admin password.</p>
|
||||
<div class="form-group">
|
||||
<label>Admin Password *</label>
|
||||
<input type="password" id="cleanupPassword" class="form-input" placeholder="Enter admin password...">
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button class="btn" onclick="closeCleanupModal()">Cancel</button>
|
||||
<button class="btn btn-danger" id="runCleanupBtn" onclick="runCleanup()">Run Cleanup</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/error-analysis.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Script Analysis - Proxmox VE Helper-Scripts</title>
|
||||
<link rel="icon" type="image/png" href="/static/img/logo.png">
|
||||
<link rel="stylesheet" href="/static/css/script-analysis.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="navbar-brand">
|
||||
<img width="28" src="/static/img/logo.png" alt="">
|
||||
Proxmox VE Helper-Scripts
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">📊 Dashboard</a>
|
||||
<a href="/error-analysis" class="nav-link">🔍 Error Analysis</a>
|
||||
<a href="/script-analysis" class="nav-link active">📜 Script Analysis</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="page-header">
|
||||
<h1>📜 Script Analysis</h1>
|
||||
<p>Script usage statistics, popularity rankings, and recent activity.</p>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; margin-bottom: 24px;">
|
||||
<div class="filter-group">
|
||||
<label>Period:</label>
|
||||
<div class="quickfilter">
|
||||
<button class="filter-btn" data-days="7">7 Days</button>
|
||||
<button class="filter-btn active" data-days="30">30 Days</button>
|
||||
<button class="filter-btn" data-days="0">All Time</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Installs</div>
|
||||
<div class="value" id="totalInstalls" style="color:var(--accent-cyan);">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Unique Scripts</div>
|
||||
<div class="value" id="uniqueScripts" style="color:var(--accent-blue);">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Avg Installs / Script</div>
|
||||
<div class="value" id="avgInstalls" style="color:var(--accent-green);">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Scripts -->
|
||||
<div class="section-card">
|
||||
<h2>
|
||||
🏆 Most Used Scripts
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="text" class="search-input" id="searchTop" placeholder="Search scripts..." oninput="renderTopTable()">
|
||||
<button class="btn btn-sm" id="expandTopBtn" onclick="toggleExpand('top')">Show All</button>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="table-wrap" id="topTableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Script</th>
|
||||
<th>Type</th>
|
||||
<th>Total</th>
|
||||
<th>Success</th>
|
||||
<th>Failed</th>
|
||||
<th>Aborted</th>
|
||||
<th>Installing</th>
|
||||
<th>Success Rate</th>
|
||||
<th>Installs/Day</th>
|
||||
<th style="min-width:100px;">Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="topTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Least Used Scripts -->
|
||||
<div class="section-card">
|
||||
<h2>
|
||||
📉 Least Used Scripts
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="text" class="search-input" id="searchBottom" placeholder="Search scripts..." oninput="renderBottomTable()">
|
||||
<button class="btn btn-sm" id="expandBottomBtn" onclick="toggleExpand('bottom')">Show All</button>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="table-wrap" id="bottomTableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Script</th>
|
||||
<th>Type</th>
|
||||
<th>Total</th>
|
||||
<th>Success</th>
|
||||
<th>Failed</th>
|
||||
<th>Aborted</th>
|
||||
<th>Installing</th>
|
||||
<th>Success Rate</th>
|
||||
<th>Installs/Day</th>
|
||||
<th style="min-width:100px;">Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bottomTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Scripts -->
|
||||
<div class="section-card">
|
||||
<h2>
|
||||
🕐 Recent Activity
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<input type="text" class="search-input" id="searchRecent" placeholder="Search..." oninput="renderRecentTable()">
|
||||
<button class="btn btn-sm" id="expandRecentBtn" onclick="toggleExpand('recent')">Show All</button>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="table-wrap" id="recentTableWrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Script</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Exit Code</th>
|
||||
<th>OS</th>
|
||||
<th>PVE</th>
|
||||
<th>Method</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/script-analysis.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -942,6 +942,22 @@ func categorizeErrorText(errLower string) string {
|
||||
|
||||
// -------- HTTP server --------
|
||||
|
||||
func serveHTMLFile(w http.ResponseWriter, r *http.Request, filePath string) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
log.Printf("Error reading file %s: %v", filePath, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := Config{
|
||||
ListenAddr: env("LISTEN_ADDR", ":8080"),
|
||||
@@ -1074,14 +1090,12 @@ func main() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
_, _ = w.Write([]byte(DashboardHTML()))
|
||||
serveHTMLFile(w, r, "public/templates/dashboard.html")
|
||||
})
|
||||
|
||||
// Redirect /dashboard to / for backwards compatibility
|
||||
mux.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusMovedPermanently)
|
||||
serveHTMLFile(w, r, "public/templates/dashboard.html")
|
||||
})
|
||||
|
||||
// Prometheus-style metrics endpoint
|
||||
@@ -1309,16 +1323,12 @@ func main() {
|
||||
|
||||
// Error Analysis page
|
||||
mux.HandleFunc("/error-analysis", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
_, _ = w.Write([]byte(ErrorAnalysisHTML()))
|
||||
serveHTMLFile(w, r, "public/templates/error-analysis.html")
|
||||
})
|
||||
|
||||
// Script Analysis page
|
||||
mux.HandleFunc("/script-analysis", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
_, _ = w.Write([]byte(ScriptAnalysisHTML()))
|
||||
serveHTMLFile(w, r, "public/templates/script-analysis.html")
|
||||
})
|
||||
|
||||
// Script Analysis API
|
||||
@@ -1507,6 +1517,9 @@ func main() {
|
||||
json.NewEncoder(w).Encode(exitCodeDescriptions)
|
||||
})
|
||||
|
||||
// Serve static files from the /public/static directory
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public/static"))))
|
||||
|
||||
// Cleanup trigger & status API
|
||||
mux.HandleFunc("/api/cleanup/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
|
||||