v0.4.1: app filtering + clickable dashboard stat cards

Add filter bar (Mind/Futó/Leállítva/Telepíthető) to Alkalmazások page with
URL-based filter persistence. Dashboard stat cards are now clickable links
that navigate to the filtered stacks view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 12:19:26 +01:00
parent 0753adec6a
commit d51e67f199
4 changed files with 125 additions and 7 deletions
+13
View File
@@ -144,5 +144,18 @@ func (s *Server) templateFuncMap() template.FuncMap {
"fmtLoad": func(load float64) string {
return fmt.Sprintf("%.2f", load)
},
"filterCategory": func(state stacks.ContainerState, deployed bool) string {
switch state {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
return "running"
case stacks.StateStopped, stacks.StateExited, stacks.StatePaused:
return "stopped"
default:
if deployed {
return "stopped"
}
return "available"
}
},
}
}
@@ -7,18 +7,18 @@
</div>
<div class="stats-grid">
<div class="stat-card stat-running">
<a href="/stacks?filter=running" class="stat-card stat-running">
<div class="stat-value">{{.RunningCount}}</div>
<div class="stat-label">Futó alkalmazás</div>
</div>
<div class="stat-card stat-stopped">
</a>
<a href="/stacks?filter=stopped" class="stat-card stat-stopped">
<div class="stat-value">{{.StoppedCount}}</div>
<div class="stat-label">Leállítva</div>
</div>
<div class="stat-card stat-total">
</a>
<a href="/stacks" class="stat-card stat-total">
<div class="stat-value">{{.TotalCount}}</div>
<div class="stat-label">Összes alkalmazás</div>
</div>
</a>
</div>
{{if .SystemInfo.TotalMemMB}}
+58 -1
View File
@@ -8,9 +8,16 @@
</div>
<div id="sync-toast" class="sync-toast" style="display:none"></div>
<div class="filter-bar" id="filter-bar">
<button class="filter-btn active" data-filter="all">Mind <span class="filter-count" id="count-all"></span></button>
<button class="filter-btn" data-filter="running">Futó <span class="filter-count" id="count-running"></span></button>
<button class="filter-btn" data-filter="stopped">Leállítva <span class="filter-count" id="count-stopped"></span></button>
<button class="filter-btn" data-filter="available">Telepíthető <span class="filter-count" id="count-available"></span></button>
</div>
<div class="stack-grid">
{{range .Stacks}}
<div class="stack-detail-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<div class="stack-detail-card stack-state-{{stateColor .State}}" data-filter-state="{{filterCategory .State .Deployed}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<div class="stack-detail-header">
<div class="stack-title-row">
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
@@ -72,5 +79,55 @@
{{end}}
</div>
<script>
(function() {
var cards = document.querySelectorAll('.stack-detail-card[data-filter-state]');
var counts = {all: cards.length, running: 0, stopped: 0, available: 0};
cards.forEach(function(card) {
var cat = card.getAttribute('data-filter-state');
if (counts[cat] !== undefined) counts[cat]++;
});
['all', 'running', 'stopped', 'available'].forEach(function(f) {
var el = document.getElementById('count-' + f);
if (el) el.textContent = '(' + counts[f] + ')';
});
function applyFilter(filter) {
cards.forEach(function(card) {
if (filter === 'all' || card.getAttribute('data-filter-state') === filter) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
document.querySelectorAll('.filter-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-filter') === filter);
});
var url = new URL(window.location);
if (filter === 'all') {
url.searchParams.delete('filter');
} else {
url.searchParams.set('filter', filter);
}
history.replaceState(null, '', url);
}
document.querySelectorAll('.filter-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
applyFilter(this.getAttribute('data-filter'));
});
});
var urlFilter = new URLSearchParams(window.location.search).get('filter');
if (urlFilter && ['running', 'stopped', 'available'].indexOf(urlFilter) !== -1) {
applyFilter(urlFilter);
}
})();
</script>
{{template "layout_end" .}}
{{end}}
@@ -822,6 +822,54 @@ select.form-control option { background: var(--bg-secondary); color: var(--text-
border-color: rgba(218, 54, 51, 0.3);
}
/* --- Filter bar --- */
.filter-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 1rem;
border-radius: 20px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: var(--accent-blue);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
.filter-count {
opacity: 0.7;
font-size: 0.8rem;
}
/* Clickable stat cards */
a.stat-card {
text-decoration: none;
color: inherit;
transition: transform 0.15s, box-shadow 0.15s;
}
a.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* Clickable cards */
[data-href] { cursor: pointer; }