Files
felhom.eu/hub/internal/web/templates/app_detail.html
T
admin bc8d54df6a D4 Part 1: hub fonts + sprite + statusColor semantic remap
- static/fonts/: the 4 vendored woff2 (byte-copied from
  felhom-controller), embedded (embed.go) and served at /static/fonts/
  (font/woff2, immutable) mirroring the chart.min.js pattern. No CDN
  before, none now.
- templates/icons.html: 12-symbol Lucide sprite partial (icon_sprite),
  included at the top of <body> on all 9 pages ({{template}} — the hub
  has no shared layout; per-page include is the minimal shared block).
- statusColor now returns v2 semantic tokens (nominal/warn/crit/
  neutral) consumed as class suffixes: ok->nominal, warn+stale->warn,
  down+fail->crit, pending+disabled->neutral (a not-yet-provisioned or
  deliberately paused customer is a normal fleet state), blocked->warn
  (intentional operator cut-off, attention-worthy not an outage),
  unknown->neutral. The inline style="color: {{statusColor}}" pattern
  is dead: dashboard + customer_unified render a class-based
  .status-dot-<token>; statusIcon (constant "●") retired from funcmap
  and templates.
- Tests (new; the hub web package had no funcmap/template tests):
  TestStatusColorTruthTable over the full enumerated status set —
  red-proven vs the old implementation (ok returned "#4ade80") — and
  TestTemplatesParseWithFuncmap.
2026-07-02 22:34:40 +02:00

246 lines
12 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">
<script src="/static/chart.min.js"></script>
</head>
<body>
{{template "icon_sprite"}}
<div class="container">
<header>
<h1>Felhom Hub</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="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">&larr; Apps</a>
<!-- Period selector -->
<div class="period-selector" style="margin-top: 1rem;">
<a href="?period=24h" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
<a href="?period=7d" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
<a href="?period=30d" 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_deleted"}}
<div class="flash flash-success" style="margin-top: 1rem;">Selected issues deleted successfully.</div>
{{end}}
{{if eq .Flash "no_issues_selected"}}
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected for deletion.</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: '#60a5fa',
backgroundColor: 'rgba(96,165,250,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2
},
{
label: 'Peak Memory (MB)',
data: chartData.peak_memory,
borderColor: '#f87171',
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: '#4ade80',
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: '#94a3b8', font: { size: 11 } } }
},
scales: {
x: { ticks: { color: '#64748b', font: { size: 10 }, maxTicksLimit: 10 }, grid: { color: 'rgba(100,116,139,0.15)' } },
y: { ticks: { color: '#64748b', font: { size: 10 } }, grid: { color: 'rgba(100,116,139,0.15)' }, title: { display: true, text: 'MB', color: '#64748b' } }
}
}
});
})();
</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 -->
{{if .Issues}}
<section class="card">
<h2>Known Issues</h2>
<form method="POST" action="/apps/{{.AppName}}/delete-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</th>
<th>Affected Customers</th>
<th>First Seen</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{{range .Issues}}
<tr>
<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}}
</td>
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{.Message}}">{{.Message}}</td>
<td>{{.OccurrenceCount}}</td>
<td>{{len .AffectedCustomers}}</td>
<td>{{timeAgo .FirstSeen}}</td>
<td>{{timeAgo .LastSeen}}</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';">Delete Selected</button>
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Delete ALL issues for {{.AppName}}? This cannot be undone.')) return false; document.getElementById('issueAction').value='all';">Delete 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;
});
</script>
</section>
{{end}}
<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>