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))
|
||||
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}",
|
||||
|
||||
@ -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, '&').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) {
|
||||
// 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);
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user