feat: nav links, least used scripts table, caching for /api/errors & /api/scripts TTL fix

This commit is contained in:
CanbiZ (MickLesk)
2026-02-16 14:36:42 +01:00
parent b329076b93
commit d6ef143f9c
3 changed files with 135 additions and 8 deletions
+12 -7
View File
@@ -194,21 +194,26 @@ func (c *Cache) Delete(ctx context.Context, key string) error {
return nil
}
// InvalidateDashboard clears all dashboard cache keys
// InvalidateDashboard clears all dashboard, scripts and errors cache keys
func (c *Cache) InvalidateDashboard(ctx context.Context) {
prefixes := []string{"dashboard:", "scripts:", "errors:"}
if c.useRedis {
// Scan and delete dashboard keys
iter := c.redis.Scan(ctx, 0, "dashboard:*", 100).Iterator()
for iter.Next(ctx) {
c.redis.Del(ctx, iter.Val())
for _, prefix := range prefixes {
iter := c.redis.Scan(ctx, 0, prefix+"*", 100).Iterator()
for iter.Next(ctx) {
c.redis.Del(ctx, iter.Val())
}
}
return
}
c.mu.Lock()
for k := range c.memData {
if len(k) > 10 && k[:10] == "dashboard:" {
delete(c.memData, k)
for _, prefix := range prefixes {
if len(k) >= len(prefix) && k[:len(prefix)] == prefix {
delete(c.memData, k)
break
}
}
}
c.mu.Unlock()
+83
View File
@@ -3034,6 +3034,8 @@ func DashboardHTML() string {
</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">
<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"/>
@@ -5213,6 +5215,36 @@ func ScriptAnalysisHTML() string {
</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 style="min-width:100px;">Distribution</th>
</tr>
</thead>
<tbody id="bottomTableBody"></tbody>
</table>
</div>
</div>
<!-- Recent Scripts -->
<div class="section-card">
<h2>
@@ -5245,6 +5277,7 @@ func ScriptAnalysisHTML() string {
<script>
let currentData = null;
let expandTop = false;
let expandBottom = false;
let expandRecent = false;
const LIMIT = 10;
@@ -5322,6 +5355,52 @@ func ScriptAnalysisHTML() string {
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="10" 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;
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><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) {
@@ -5360,6 +5439,9 @@ func ScriptAnalysisHTML() string {
if (which === 'top') {
expandTop = !expandTop;
renderTopTable();
} else if (which === 'bottom') {
expandBottom = !expandBottom;
renderBottomTable();
} else {
expandRecent = !expandRecent;
renderRecentTable();
@@ -5372,6 +5454,7 @@ func ScriptAnalysisHTML() string {
currentData = data;
updateStats(data);
renderTopTable();
renderBottomTable();
renderRecentTable();
}
+40 -1
View File
@@ -1289,7 +1289,10 @@ func main() {
log.Printf("[CACHE] background refresh failed for %s: %v", cacheKey, err)
return
}
_ = cache.Set(context.Background(), cacheKey, freshData, cfg.CacheTTL)
refreshTTL := 2 * time.Minute
if days > 7 { refreshTTL = 5 * time.Minute }
if days > 30 || days == 0 { refreshTTL = 15 * time.Minute }
_ = cache.Set(context.Background(), cacheKey, freshData, refreshTTL)
}()
}
}
@@ -1346,6 +1349,34 @@ func main() {
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
cacheKey := fmt.Sprintf("errors:%d:%s", days, repoSource)
var data *ErrorAnalysisData
if cfg.CacheEnabled && cache.Get(ctx, cacheKey, &data) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "HIT")
if cache.IsStale(ctx, cacheKey) {
w.Header().Set("X-Cache", "STALE")
if cache.TryStartRefresh(cacheKey) {
go func() {
defer cache.FinishRefresh(cacheKey)
refreshCtx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
freshData, err := pb.FetchErrorAnalysisData(refreshCtx, days, repoSource)
if err != nil {
log.Printf("[CACHE] background refresh failed for %s: %v", cacheKey, err)
return
}
cacheTTL := 2 * time.Minute
if days > 7 { cacheTTL = 5 * time.Minute }
if days > 30 || days == 0 { cacheTTL = 15 * time.Minute }
_ = cache.Set(context.Background(), cacheKey, freshData, cacheTTL)
}()
}
}
json.NewEncoder(w).Encode(data)
return
}
data, err := pb.FetchErrorAnalysisData(ctx, days, repoSource)
if err != nil {
log.Printf("error analysis fetch failed: %v", err)
@@ -1353,7 +1384,15 @@ func main() {
return
}
if cfg.CacheEnabled {
cacheTTL := 2 * time.Minute
if days > 7 { cacheTTL = 5 * time.Minute }
if days > 30 || days == 0 { cacheTTL = 15 * time.Minute }
_ = cache.Set(ctx, cacheKey, data, cacheTTL)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "MISS")
json.NewEncoder(w).Encode(data)
})