Add sortable dashboard; extend telemetry data
Dashboard: add sortable table UI and client-side sorting support — CSS for sortable headers, data-sort attributes, default sort on Created (desc), timestamp formatting, header click handling, and inclusion of sort param in paginated fetches. Records now show a formatted Created column with full timestamp in the title. Initialize sortable headers on load. Telemetry/client: switch to sending a full JSON payload (allows create if initial PATCH failed) and include extra fields (ct_type, disk_size, core_count, ram_size, os_type, os_version, pve_version, method). pve_version is detected when available. Server: extend FetchRecordsPaginated to accept a sort field, validate allowed sort fields to prevent injection, use the sort when building the PB API request (default -created), and propagate the sort query param from the HTTP handler to the fetch call. Overall this enables server-side sorted pagination from the dashboard and richer telemetry records.
This commit is contained in:
parent
f4ccccfb32
commit
1dcd83abea
@ -421,8 +421,14 @@ post_update_to_api() {
|
|||||||
duration=$(($(date +%s) - INSTALL_START_TIME))
|
duration=$(($(date +%s) - INSTALL_START_TIME))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update payload: only fields that change (status, error, exit_code, duration, gpu)
|
# Get PVE version
|
||||||
# The Go service will find the record by random_id and PATCH only these fields
|
local pve_version=""
|
||||||
|
if command -v pveversion &>/dev/null; then
|
||||||
|
pve_version=$(pveversion 2>/dev/null | awk -F'[/ ]' '{print $2}') || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Full payload including all fields - allows record creation if initial call failed
|
||||||
|
# The Go service will find the record by random_id and PATCH, or create if not found
|
||||||
local JSON_PAYLOAD
|
local JSON_PAYLOAD
|
||||||
JSON_PAYLOAD=$(
|
JSON_PAYLOAD=$(
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@ -431,6 +437,14 @@ post_update_to_api() {
|
|||||||
"type": "${TELEMETRY_TYPE:-lxc}",
|
"type": "${TELEMETRY_TYPE:-lxc}",
|
||||||
"nsapp": "${NSAPP:-unknown}",
|
"nsapp": "${NSAPP:-unknown}",
|
||||||
"status": "${pb_status}",
|
"status": "${pb_status}",
|
||||||
|
"ct_type": ${CT_TYPE:-1},
|
||||||
|
"disk_size": ${DISK_SIZE:-0},
|
||||||
|
"core_count": ${CORE_COUNT:-0},
|
||||||
|
"ram_size": ${RAM_SIZE:-0},
|
||||||
|
"os_type": "${var_os:-}",
|
||||||
|
"os_version": "${var_version:-}",
|
||||||
|
"pve_version": "${pve_version}",
|
||||||
|
"method": "${METHOD:-default}",
|
||||||
"exit_code": ${exit_code},
|
"exit_code": ${exit_code},
|
||||||
"error": "${error}",
|
"error": "${error}",
|
||||||
"error_category": "${error_category}",
|
"error_category": "${error_category}",
|
||||||
|
|||||||
@ -886,6 +886,19 @@ func DashboardHTML() string {
|
|||||||
.status-badge.failed { background: rgba(248, 81, 73, 0.2); color: var(--accent-red); }
|
.status-badge.failed { background: rgba(248, 81, 73, 0.2); color: var(--accent-red); }
|
||||||
.status-badge.installing { background: rgba(210, 153, 34, 0.2); color: var(--accent-yellow); }
|
.status-badge.installing { background: rgba(210, 153, 34, 0.2); color: var(--accent-yellow); }
|
||||||
|
|
||||||
|
th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
th.sortable:hover {
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
}
|
||||||
|
th.sort-asc, th.sort-desc {
|
||||||
|
background: rgba(88, 166, 255, 0.15);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -1233,21 +1246,22 @@ func DashboardHTML() string {
|
|||||||
<option value="">All OS</option>
|
<option value="">All OS</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table id="installTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>App</th>
|
<th data-sort="nsapp" class="sortable">App</th>
|
||||||
<th>Status</th>
|
<th data-sort="status" class="sortable">Status</th>
|
||||||
<th>OS</th>
|
<th data-sort="os_type" class="sortable">OS</th>
|
||||||
<th>Type</th>
|
<th data-sort="type" class="sortable">Type</th>
|
||||||
<th>Method</th>
|
<th data-sort="method" class="sortable">Method</th>
|
||||||
<th>Resources</th>
|
<th>Resources</th>
|
||||||
<th>Exit Code</th>
|
<th data-sort="exit_code" class="sortable">Exit Code</th>
|
||||||
<th>Error</th>
|
<th>Error</th>
|
||||||
|
<th data-sort="created" class="sortable sort-desc">Created ▼</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="recordsTable">
|
<tbody id="recordsTable">
|
||||||
<tr><td colspan="8" class="loading">Loading...</td></tr>
|
<tr><td colspan="9" class="loading">Loading...</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
@ -1275,6 +1289,7 @@ func DashboardHTML() string {
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let currentTheme = localStorage.getItem('theme') || 'dark';
|
let currentTheme = localStorage.getItem('theme') || 'dark';
|
||||||
|
let currentSort = { field: 'created', dir: 'desc' };
|
||||||
|
|
||||||
// Apply saved theme on load
|
// Apply saved theme on load
|
||||||
if (currentTheme === 'light') {
|
if (currentTheme === 'light') {
|
||||||
@ -1413,6 +1428,59 @@ func DashboardHTML() string {
|
|||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts) {
|
||||||
|
if (!ts) return '-';
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - d;
|
||||||
|
|
||||||
|
// Less than 1 minute ago
|
||||||
|
if (diff < 60000) return 'just now';
|
||||||
|
// Less than 1 hour ago
|
||||||
|
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||||||
|
// Less than 24 hours ago
|
||||||
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||||||
|
// Less than 7 days ago
|
||||||
|
if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
|
||||||
|
|
||||||
|
// Older - show date
|
||||||
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortableHeaders() {
|
||||||
|
document.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
th.style.cursor = 'pointer';
|
||||||
|
th.addEventListener('click', () => sortByColumn(th.dataset.sort));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByColumn(field) {
|
||||||
|
// Toggle direction if same field
|
||||||
|
if (currentSort.field === field) {
|
||||||
|
currentSort.dir = currentSort.dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.field = field;
|
||||||
|
currentSort.dir = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update header indicators
|
||||||
|
document.querySelectorAll('th.sortable').forEach(th => {
|
||||||
|
th.classList.remove('sort-asc', 'sort-desc');
|
||||||
|
const arrow = th.textContent.replace(/[▲▼]/g, '').trim();
|
||||||
|
th.textContent = arrow;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTh = document.querySelector('th[data-sort=\"' + field + '\"]');
|
||||||
|
if (activeTh) {
|
||||||
|
activeTh.classList.add(currentSort.dir === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||||
|
activeTh.textContent = activeTh.textContent + ' ' + (currentSort.dir === 'asc' ? '▲' : '▼');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch with new sort
|
||||||
|
currentPage = 1;
|
||||||
|
fetchPaginatedRecords();
|
||||||
|
}
|
||||||
|
|
||||||
function updateCharts(data) {
|
function updateCharts(data) {
|
||||||
// Daily chart
|
// Daily chart
|
||||||
if (charts.daily) charts.daily.destroy();
|
if (charts.daily) charts.daily.destroy();
|
||||||
@ -1551,6 +1619,9 @@ func DashboardHTML() string {
|
|||||||
if (status) url += '&status=' + encodeURIComponent(status);
|
if (status) url += '&status=' + encodeURIComponent(status);
|
||||||
if (app) url += '&app=' + encodeURIComponent(app);
|
if (app) url += '&app=' + encodeURIComponent(app);
|
||||||
if (os) url += '&os=' + encodeURIComponent(os);
|
if (os) url += '&os=' + encodeURIComponent(os);
|
||||||
|
if (currentSort.field) {
|
||||||
|
url += '&sort=' + (currentSort.dir === 'desc' ? '-' : '') + currentSort.field;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error('Failed to fetch records');
|
if (!response.ok) throw new Error('Failed to fetch records');
|
||||||
@ -1593,6 +1664,7 @@ func DashboardHTML() string {
|
|||||||
const resources = r.core_count || r.ram_size || r.disk_size
|
const resources = r.core_count || r.ram_size || r.disk_size
|
||||||
? (r.core_count || '?') + 'C / ' + (r.ram_size ? Math.round(r.ram_size/1024) + 'G' : '?') + ' / ' + (r.disk_size || '?') + 'GB'
|
? (r.core_count || '?') + 'C / ' + (r.ram_size ? Math.round(r.ram_size/1024) + 'G' : '?') + ' / ' + (r.disk_size || '?') + 'GB'
|
||||||
: '-';
|
: '-';
|
||||||
|
const created = r.created ? formatTimestamp(r.created) : '-';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td><strong>' + escapeHtml(r.nsapp || '-') + '</strong></td>' +
|
'<td><strong>' + escapeHtml(r.nsapp || '-') + '</strong></td>' +
|
||||||
'<td><span class="status-badge ' + statusClass + '">' + escapeHtml(r.status || '-') + '</span></td>' +
|
'<td><span class="status-badge ' + statusClass + '">' + escapeHtml(r.status || '-') + '</span></td>' +
|
||||||
@ -1603,6 +1675,7 @@ func DashboardHTML() string {
|
|||||||
'<td>' + (r.exit_code || '-') + '</td>' +
|
'<td>' + (r.exit_code || '-') + '</td>' +
|
||||||
'<td title="' + escapeHtml(r.error || '') + '">' +
|
'<td title="' + escapeHtml(r.error || '') + '">' +
|
||||||
escapeHtml((r.error || '').slice(0, 40)) + (r.error && r.error.length > 40 ? '...' : '') + '</td>' +
|
escapeHtml((r.error || '').slice(0, 40)) + (r.error && r.error.length > 40 ? '...' : '') + '</td>' +
|
||||||
|
'<td title="' + escapeHtml(r.created || '') + '">' + created + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@ -1657,6 +1730,7 @@ func DashboardHTML() string {
|
|||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
refreshData();
|
refreshData();
|
||||||
|
initSortableHeaders();
|
||||||
|
|
||||||
// Refresh on time range change
|
// Refresh on time range change
|
||||||
document.getElementById('timeRange').addEventListener('change', refreshData);
|
document.getElementById('timeRange').addEventListener('change', refreshData);
|
||||||
|
|||||||
@ -280,7 +280,7 @@ func (p *PBClient) UpdateTelemetryStatus(ctx context.Context, recordID string, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FetchRecordsPaginated retrieves records with pagination and optional filters
|
// FetchRecordsPaginated retrieves records with pagination and optional filters
|
||||||
func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, status, app, osType string) ([]TelemetryRecord, int, error) {
|
func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, status, app, osType, sortField string) ([]TelemetryRecord, int, error) {
|
||||||
if err := p.ensureAuth(ctx); err != nil {
|
if err := p.ensureAuth(ctx); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -302,8 +302,26 @@ func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, s
|
|||||||
filterStr = "&filter=" + strings.Join(filters, "&&")
|
filterStr = "&filter=" + strings.Join(filters, "&&")
|
||||||
}
|
}
|
||||||
|
|
||||||
reqURL := fmt.Sprintf("%s/api/collections/%s/records?sort=-created&page=%d&perPage=%d%s",
|
// Handle sort parameter (default: -created)
|
||||||
p.baseURL, p.targetColl, page, limit, filterStr)
|
sort := "-created"
|
||||||
|
if sortField != "" {
|
||||||
|
// Validate sort field to prevent injection
|
||||||
|
allowedFields := map[string]bool{
|
||||||
|
"created": true, "-created": true,
|
||||||
|
"nsapp": true, "-nsapp": true,
|
||||||
|
"status": true, "-status": true,
|
||||||
|
"os_type": true, "-os_type": true,
|
||||||
|
"type": true, "-type": true,
|
||||||
|
"method": true, "-method": true,
|
||||||
|
"exit_code": true, "-exit_code": true,
|
||||||
|
}
|
||||||
|
if allowedFields[sortField] {
|
||||||
|
sort = sortField
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reqURL := fmt.Sprintf("%s/api/collections/%s/records?sort=%s&page=%d&perPage=%d%s",
|
||||||
|
p.baseURL, p.targetColl, sort, page, limit, filterStr)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -846,6 +864,7 @@ func main() {
|
|||||||
status := r.URL.Query().Get("status")
|
status := r.URL.Query().Get("status")
|
||||||
app := r.URL.Query().Get("app")
|
app := r.URL.Query().Get("app")
|
||||||
osType := r.URL.Query().Get("os")
|
osType := r.URL.Query().Get("os")
|
||||||
|
sort := r.URL.Query().Get("sort")
|
||||||
|
|
||||||
if p := r.URL.Query().Get("page"); p != "" {
|
if p := r.URL.Query().Get("page"); p != "" {
|
||||||
fmt.Sscanf(p, "%d", &page)
|
fmt.Sscanf(p, "%d", &page)
|
||||||
@ -866,7 +885,7 @@ func main() {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
records, total, err := pb.FetchRecordsPaginated(ctx, page, limit, status, app, osType)
|
records, total, err := pb.FetchRecordsPaginated(ctx, page, limit, status, app, osType, sort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("records fetch failed: %v", err)
|
log.Printf("records fetch failed: %v", err)
|
||||||
http.Error(w, "failed to fetch records", http.StatusInternalServerError)
|
http.Error(w, "failed to fetch records", http.StatusInternalServerError)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user