Files
felhom.eu/hub/internal/web/templates/app_detail.html
T
admin 1d94b1a9e9 hub: scoped auto-refresh + style.css cache-bust + staging rule (v0.48.0 parts 3-4)
- Auto-refresh: the 60s reload fires only while a live tab is active
  (nav data-live-tabs="overview,applications,events,host") AND no form is
  dirty (delegated input/change listener; never reset — a reload clears it).
  Skipped ticks reschedule; a muted (paused) hint shows next to the toggle on
  non-live tabs / dirty forms. Toggle, localStorage key, 60s cadence, and
  default-on behavior unchanged. The refresh script resolves the legacy
  settings→edit hash alias like the tabs script.
- Rider 4a: every template's stylesheet link is /style.css?v={{hubVersion}}
  (the v0.47.0 gotcha: max-age=3600 served stale styling for up to an hour
  after a deploy). Red-proof run: a reverted bare link fails the test.
- Rider 4b: CLAUDE.md standing rule — never git add -A in this repo
  (the 146d165 sweep incident); explicit paths + pull-rebase + one writing
  session per clone.
- Tests: Group C structural pins (attribute read, dirty listeners, alias x2,
  hint element, cadence/key survivors) + Group D cache-bust over six pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZc5w5jDhFLv6qDC32KN5v
2026-07-12 17:37:04 +02:00

322 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.AppName}} — Felhom Hub</title>
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
<script src="/static/chart.min.js"></script>
</head>
<body>
{{template "icon_sprite"}}
<div class="container">
<header>
<h1>Felhom <span>Hub</span></h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link">Customers</a>
<a href="/apps" class="nav-link active">Apps</a>
<a href="/hosts" class="nav-link">Hosts</a>
<a href="/offsite" class="nav-link">Offsite</a>
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">&larr; Apps</a>
<!-- Period selector (carries the customer filter + show-dismissed state) -->
<div class="period-selector" style="margin-top: 1rem;">
<a href="?period=24h{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
<a href="?period=7d{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
<a href="?period=30d{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if eq .Period "30d"}} active{{end}}">30d</a>
</div>
{{if eq .Flash "telemetry_reset"}}
<div class="flash flash-success" style="margin-top: 1rem;">Telemetry data deleted successfully.</div>
{{end}}
{{if eq .Flash "issues_dismissed"}}
<div class="flash flash-success" style="margin-top: 1rem;">Issues dismissed — they stay hidden until a NEW occurrence resurfaces them.</div>
{{end}}
{{if eq .Flash "no_issues_selected"}}
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected.</div>
{{end}}
<!-- Overview card -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">{{if .Summary}}{{if .Summary.DisplayName}}{{.Summary.DisplayName}}{{else}}{{.AppName}}{{end}}{{else}}{{.AppName}}{{end}}</h2>
<form method="POST" action="/apps/{{.AppName}}/reset-telemetry{{if .Period}}?period={{.Period}}{{end}}"
onsubmit="return confirm('Are you sure you want to delete all telemetry data for {{.AppName}}? This cannot be undone.');">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-sm btn-danger">Reset Telemetry</button>
</form>
</div>
<div class="info-grid">
<div class="info-item">
<span class="label">App Name</span>
<span class="value" style="font-family: var(--font-mono)">{{.AppName}}</span>
</div>
{{if .Summary}}
<div class="info-item">
<span class="label">Deployments</span>
<span class="value">{{.Summary.DeploymentCount}}</span>
</div>
<div class="info-item">
<span class="label">Catalog Estimate</span>
<span class="value">{{if .Summary.CatalogEstimate}}{{.Summary.CatalogEstimate}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Catalog Limit</span>
<span class="value">{{if .Summary.CatalogLimit}}{{.Summary.CatalogLimit}}{{else}}—{{end}}</span>
</div>
{{if .SuggestedLimit}}
<div class="info-item">
<span class="label">Suggested Limit (P95×1.2)</span>
<span class="value" style="color: var(--yellow)">{{.SuggestedLimit}} MB</span>
</div>
{{end}}
<div class="info-item">
<span class="label">Avg Memory</span>
<span class="value">{{formatFloat .Summary.AvgMemoryMB}} MB</span>
</div>
<div class="info-item">
<span class="label">P95 Memory</span>
<span class="value {{accuracyClass .Summary.P95MemoryMB .Summary.CatalogLimit}}">{{formatFloat .Summary.P95MemoryMB}} MB</span>
</div>
<div class="info-item">
<span class="label">Avg CPU</span>
<span class="value">{{formatFloat .Summary.AvgCPU}}%</span>
</div>
{{end}}
</div>
</section>
<!-- Memory trend chart -->
<section class="card">
<h2>Memory Trend</h2>
<div class="chart-container">
<canvas id="memoryChart"></canvas>
</div>
<script>
(function() {
var chartData = {{json .ChartData}};
if (!chartData || !chartData.labels || chartData.labels.length === 0) {
document.getElementById('memoryChart').parentElement.innerHTML = '<p class="text-muted">Not enough data for the chart.</p>';
return;
}
var ctx = document.getElementById('memoryChart').getContext('2d');
var datasets = [
{
label: 'Avg Memory (MB)',
data: chartData.avg_memory,
borderColor: '#2EA8F5',
backgroundColor: 'rgba(46,168,245,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2
},
{
label: 'Peak Memory (MB)',
data: chartData.peak_memory,
borderColor: '#8E7CE8',
backgroundColor: 'transparent',
fill: false,
tension: 0.3,
pointRadius: 2,
borderDash: [4, 2]
}
];
if (chartData.catalog_limit > 0) {
datasets.push({
label: 'Catalog Limit',
data: chartData.labels.map(function() { return chartData.catalog_limit; }),
borderColor: '#E0A93E',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 1,
borderDash: [6, 4]
});
}
new Chart(ctx, {
type: 'line',
data: { labels: chartData.labels, datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94A6BF', font: { size: 11 } } }
},
scales: {
x: { ticks: { color: '#5E7392', font: { size: 10 }, maxTicksLimit: 10 }, grid: { color: 'rgba(34,52,79,.5)' } },
y: { ticks: { color: '#5E7392', font: { size: 10 } }, grid: { color: 'rgba(34,52,79,.5)' }, title: { display: true, text: 'MB', color: '#5E7392' } }
}
}
});
})();
</script>
</section>
<!-- Customer breakdown -->
{{if .Customers}}
<section class="card">
<h2>Customer Breakdown</h2>
<table class="data-table">
<thead>
<tr>
<th>Customer</th>
<th>Avg Memory</th>
<th>Peak Memory</th>
<th>Avg CPU</th>
<th>Total Errors</th>
<th>Last Report</th>
</tr>
</thead>
<tbody>
{{range .Customers}}
<tr>
<td><a href="/customers/{{.CustomerID}}">{{.CustomerID}}</a></td>
<td>{{formatFloat .AvgMemoryMB}} MB</td>
<td>{{formatFloat .PeakMemoryMB}} MB</td>
<td>{{formatFloat .AvgCPU}}%</td>
<td>{{if gt .TotalErrors 0}}<span class="badge badge-error">{{.TotalErrors}}</span>{{else}}0{{end}}</td>
<td>{{timeAgo .LastReport}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<!-- Known issues -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">Known Issues{{if .CustomerFilter}} <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">— filtered: {{.CustomerFilter}}</span>{{end}}</h2>
<div style="display: flex; gap: 0.75rem; align-items: center;">
{{if .CustomerFilter}}
<a href="?period={{.Period}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" style="font-size: 0.85rem;">Clear filter (fleet view)</a>
{{end}}
{{if .ShowDismissed}}
<a href="?period={{.Period}}{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}" style="font-size: 0.85rem;">Hide dismissed</a>
{{else}}
<a href="?period={{.Period}}{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}&amp;show_dismissed=1" style="font-size: 0.85rem;">Show dismissed</a>
{{end}}
</div>
</div>
{{if .Issues}}
<form method="POST" action="/apps/{{.AppName}}/dismiss-issues{{if .Period}}?period={{.Period}}{{end}}" id="issueForm">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="selected" id="issueAction">
<table class="data-table">
<thead>
<tr>
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
<th>Severity</th>
<th>Message</th>
<th>Occurrences (all customers)</th>
<th>Affected Customers</th>
<th>First Seen</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Issues}}
<tr{{if .DismissedAt}} style="opacity: 0.55;"{{end}}>
<td><input type="checkbox" name="issue_ids" value="{{.ID}}" class="issue-cb"></td>
<td>
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
{{else}}<span class="badge badge-warn">warn</span>{{end}}
{{if .DismissedAt}}<span class="badge badge-neutral">dismissed</span>{{end}}
</td>
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;" onclick="toggleIssue({{.ID}})">{{.Message}}</td>
<td>{{.OccurrenceCount}}</td>
<td>{{len .AffectedCustomers}}</td>
<td>{{timeAgo .FirstSeen}}</td>
<td>{{timeAgo .LastSeen}}</td>
<td><button type="button" class="btn btn-sm" onclick="toggleIssue({{.ID}})">Details</button></td>
</tr>
<tr id="issue-detail-{{.ID}}" style="display: none;">
<td colspan="8" style="background: var(--bg-1); border: 1px solid var(--line-soft); padding: 0.75rem;">
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
fingerprint: <span style="font-family: var(--font-mono);">{{.Fingerprint}}</span>
&middot; severity: {{.Severity}}
&middot; first seen: {{.FirstSeen.Format "2006-01-02 15:04:05"}}
&middot; last seen: {{.LastSeen.Format "2006-01-02 15:04:05"}}
{{if .DismissedAt}}&middot; dismissed: {{.DismissedAt.Format "2006-01-02 15:04:05"}}{{end}}
</div>
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
affected customers:
{{range $i, $c := .AffectedCustomers}}{{if $i}}, {{end}}<a href="/customers/{{$c}}">{{$c}}</a>{{else}}—{{end}}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
<span class="text-muted" style="font-size: 0.8rem;">Full message:</span>
<button type="button" class="btn btn-sm" onclick="copyText('issue-msg-{{.ID}}', this)">Copy</button>
</div>
<pre id="issue-msg-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0.75rem;">{{.Message}}</pre>
{{if .Context}}
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
<span class="text-muted" style="font-size: 0.8rem;">Context around first occurrence{{if .ContextCustomer}} — from <a href="/customers/{{.ContextCustomer}}">{{.ContextCustomer}}</a>{{end}}:</span>
<button type="button" class="btn btn-sm" onclick="copyText('issue-ctx-{{.ID}}', this)">Copy</button>
</div>
<pre id="issue-ctx-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0;">{{joinStrings .Context "\n"}}</pre>
{{else}}
<span class="text-muted" style="font-size: 0.8rem;">No context captured (pre-v0.111 report or warn-severity issue).</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem;">
<button type="submit" class="btn btn-sm btn-danger" onclick="document.getElementById('issueAction').value='selected';">Dismiss Selected</button>
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Dismiss ALL issues for {{.AppName}}? They resurface on a new occurrence.')) return false; document.getElementById('issueAction').value='all';">Dismiss All Issues</button>
</div>
</form>
<script>
document.getElementById('selectAll').addEventListener('change', function() {
var cbs = document.querySelectorAll('.issue-cb');
for (var i = 0; i < cbs.length; i++) cbs[i].checked = this.checked;
});
function toggleIssue(id) {
var el = document.getElementById('issue-detail-' + id);
if (el) el.style.display = (el.style.display === 'none') ? '' : 'none';
}
function copyText(id, btn) {
var el = document.getElementById(id);
if (!el) return;
var text = el.textContent;
var done = function() {
var old = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function() { btn.textContent = old; }, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, function() { fallbackCopy(text, done); });
} else {
fallbackCopy(text, done);
}
}
function fallbackCopy(text, done) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
}
</script>
{{else}}
<p class="text-muted">No issues in this period{{if .CustomerFilter}} for this customer{{end}}.</p>
{{end}}
</section>
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
</footer>
</div>
</body>
</html>