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:
CanbiZ (MickLesk) 2026-02-10 16:20:26 +01:00
parent f4ccccfb32
commit 1dcd83abea
3 changed files with 121 additions and 14 deletions

View File

@ -421,8 +421,14 @@ post_update_to_api() {
duration=$(($(date +%s) - INSTALL_START_TIME))
fi
# Update payload: only fields that change (status, error, exit_code, duration, gpu)
# The Go service will find the record by random_id and PATCH only these fields
# Get PVE version
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
JSON_PAYLOAD=$(
cat <<EOF
@ -431,6 +437,14 @@ post_update_to_api() {
"type": "${TELEMETRY_TYPE:-lxc}",
"nsapp": "${NSAPP:-unknown}",
"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},
"error": "${error}",
"error_category": "${error_category}",

View File

@ -886,6 +886,19 @@ func DashboardHTML() string {
.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); }
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 {
display: flex;
justify-content: center;
@ -1233,21 +1246,22 @@ func DashboardHTML() string {
<option value="">All OS</option>
</select>
</div>
<table>
<table id="installTable">
<thead>
<tr>
<th>App</th>
<th>Status</th>
<th>OS</th>
<th>Type</th>
<th>Method</th>
<th data-sort="nsapp" class="sortable">App</th>
<th data-sort="status" class="sortable">Status</th>
<th data-sort="os_type" class="sortable">OS</th>
<th data-sort="type" class="sortable">Type</th>
<th data-sort="method" class="sortable">Method</th>
<th>Resources</th>
<th>Exit Code</th>
<th data-sort="exit_code" class="sortable">Exit Code</th>
<th>Error</th>
<th data-sort="created" class="sortable sort-desc">Created </th>
</tr>
</thead>
<tbody id="recordsTable">
<tr><td colspan="8" class="loading">Loading...</td></tr>
<tr><td colspan="9" class="loading">Loading...</td></tr>
</tbody>
</table>
<div class="pagination">
@ -1275,6 +1289,7 @@ func DashboardHTML() string {
let currentPage = 1;
let totalPages = 1;
let currentTheme = localStorage.getItem('theme') || 'dark';
let currentSort = { field: 'created', dir: 'desc' };
// Apply saved theme on load
if (currentTheme === 'light') {
@ -1413,6 +1428,59 @@ func DashboardHTML() string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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) {
// Daily chart
if (charts.daily) charts.daily.destroy();
@ -1551,6 +1619,9 @@ func DashboardHTML() string {
if (status) url += '&status=' + encodeURIComponent(status);
if (app) url += '&app=' + encodeURIComponent(app);
if (os) url += '&os=' + encodeURIComponent(os);
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');
@ -1593,6 +1664,7 @@ func DashboardHTML() string {
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'
: '-';
const created = r.created ? formatTimestamp(r.created) : '-';
return '<tr>' +
'<td><strong>' + escapeHtml(r.nsapp || '-') + '</strong></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 title="' + escapeHtml(r.error || '') + '">' +
escapeHtml((r.error || '').slice(0, 40)) + (r.error && r.error.length > 40 ? '...' : '') + '</td>' +
'<td title="' + escapeHtml(r.created || '') + '">' + created + '</td>' +
'</tr>';
}).join('');
}
@ -1657,6 +1730,7 @@ func DashboardHTML() string {
// Initial load
refreshData();
initSortableHeaders();
// Refresh on time range change
document.getElementById('timeRange').addEventListener('change', refreshData);

View File

@ -280,7 +280,7 @@ func (p *PBClient) UpdateTelemetryStatus(ctx context.Context, recordID string, u
}
// 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 {
return nil, 0, err
}
@ -302,8 +302,26 @@ func (p *PBClient) FetchRecordsPaginated(ctx context.Context, page, limit int, s
filterStr = "&filter=" + strings.Join(filters, "&&")
}
reqURL := fmt.Sprintf("%s/api/collections/%s/records?sort=-created&page=%d&perPage=%d%s",
p.baseURL, p.targetColl, page, limit, filterStr)
// Handle sort parameter (default: -created)
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)
if err != nil {
@ -846,6 +864,7 @@ func main() {
status := r.URL.Query().Get("status")
app := r.URL.Query().Get("app")
osType := r.URL.Query().Get("os")
sort := r.URL.Query().Get("sort")
if p := r.URL.Query().Get("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
@ -866,7 +885,7 @@ func main() {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
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 {
log.Printf("records fetch failed: %v", err)
http.Error(w, "failed to fetch records", http.StatusInternalServerError)