mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
feat(web): Auto-refresh dashboard and clean up HTMX inheritance (#49)
This commit is contained in:
@@ -32,6 +32,12 @@ compose_farm/
|
||||
|
||||
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html` by copying SVG paths from their site. The `action_btn`, `stat_card`, and `collapse` macros in `components.html` accept an optional `icon` parameter.
|
||||
|
||||
## HTMX Patterns
|
||||
|
||||
- **Multi-element refresh**: Use custom events, not `hx-swap-oob`. Elements have `hx-trigger="cf:refresh from:body"` and JS calls `document.body.dispatchEvent(new CustomEvent('cf:refresh'))`. Simpler to debug/test.
|
||||
- **SPA navigation**: Sidebar uses `hx-boost="true"` to AJAX-ify links.
|
||||
- **Attribute inheritance**: Set `hx-target`/`hx-swap` on parent elements.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Hybrid SSH approach**: asyncssh for parallel streaming with prefixes; native `ssh -t` for raw mode (progress bars)
|
||||
|
||||
@@ -212,15 +212,11 @@ function initExecTerminal(service, container, host) {
|
||||
window.initExecTerminal = initExecTerminal;
|
||||
|
||||
/**
|
||||
* Refresh dashboard partials while preserving collapse states
|
||||
* Refresh dashboard partials by dispatching a custom event.
|
||||
* Elements with hx-trigger="cf:refresh from:body" will automatically refresh.
|
||||
*/
|
||||
function refreshDashboard() {
|
||||
const isExpanded = (id) => document.getElementById(id)?.checked ?? true;
|
||||
htmx.ajax('GET', '/partials/sidebar', {target: '#sidebar nav', swap: 'innerHTML'});
|
||||
htmx.ajax('GET', '/partials/stats', {target: '#stats-cards', swap: 'outerHTML'});
|
||||
htmx.ajax('GET', `/partials/pending?expanded=${isExpanded('pending-collapse')}`, {target: '#pending-operations', swap: 'outerHTML'});
|
||||
htmx.ajax('GET', `/partials/services-by-host?expanded=${isExpanded('services-by-host-collapse')}`, {target: '#services-by-host', swap: 'outerHTML'});
|
||||
htmx.ajax('GET', '/partials/config-error', {target: '#config-error', swap: 'innerHTML'});
|
||||
document.body.dispatchEvent(new CustomEvent('cf:refresh'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<span class="font-semibold rainbow-hover">Compose Farm</span>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="flex-1 p-6 overflow-y-auto" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
|
||||
<main id="main-content" class="flex-1 p-6 overflow-y-auto">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
</a>
|
||||
</h2>
|
||||
</header>
|
||||
<nav class="flex-1 overflow-y-auto p-2" hx-get="/partials/sidebar" hx-trigger="load" hx-swap="innerHTML">
|
||||
<nav class="flex-1 overflow-y-auto p-2" hx-get="/partials/sidebar" hx-trigger="load, cf:refresh from:body" hx-swap="innerHTML">
|
||||
<span class="loading loading-spinner loading-sm"></span> Loading...
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
{{ page_header("Compose Farm", "Cluster overview and management") }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
{% include "partials/stats.html" %}
|
||||
<div id="stats-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"
|
||||
hx-get="/partials/stats" hx-trigger="cf:refresh from:body" hx-swap="innerHTML">
|
||||
{% include "partials/stats.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Global Actions -->
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
@@ -20,7 +23,10 @@
|
||||
{% include "partials/terminal.html" %}
|
||||
|
||||
<!-- Config Error Banner -->
|
||||
<div id="config-error">
|
||||
<div id="config-error"
|
||||
hx-get="/partials/config-error"
|
||||
hx-trigger="cf:refresh from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% if config_error %}
|
||||
{% include "partials/config_error.html" %}
|
||||
{% endif %}
|
||||
@@ -34,10 +40,16 @@
|
||||
{% endcall %}
|
||||
|
||||
<!-- Pending Operations -->
|
||||
{% include "partials/pending.html" %}
|
||||
<div id="pending-operations"
|
||||
hx-get="/partials/pending" hx-trigger="cf:refresh from:body" hx-swap="innerHTML">
|
||||
{% include "partials/pending.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Services by Host -->
|
||||
{% include "partials/services_by_host.html" %}
|
||||
<div id="services-by-host"
|
||||
hx-get="/partials/services-by-host" hx-trigger="cf:refresh from:body" hx-swap="innerHTML">
|
||||
{% include "partials/services_by_host.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Hosts Configuration -->
|
||||
{% call collapse("Hosts (" ~ (hosts | length) ~ ")", icon=server()) %}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% from "partials/components.html" import collapse %}
|
||||
<div id="pending-operations">
|
||||
{% if orphaned or migrations or not_started %}
|
||||
{% call collapse("Pending Operations", id="pending-collapse", checked=expanded|default(true)) %}
|
||||
{% if orphaned %}
|
||||
@@ -35,4 +34,3 @@
|
||||
<span>All services are in sync with configuration.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% from "partials/components.html" import collapse %}
|
||||
{% from "partials/icons.html" import layers, search %}
|
||||
<div id="services-by-host">
|
||||
{% call collapse("Services by Host", id="services-by-host-collapse", checked=expanded|default(true), icon=layers()) %}
|
||||
<div class="flex flex-wrap gap-2 mb-4 items-center">
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 bg-base-200">
|
||||
@@ -38,4 +37,3 @@
|
||||
}
|
||||
</script>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{% from "partials/components.html" import stat_card %}
|
||||
{% from "partials/icons.html" import server, layers, circle_check, circle_x %}
|
||||
<div id="stats-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{{ stat_card("Hosts", hosts | length, icon=server()) }}
|
||||
{{ stat_card("Services", services | length, icon=layers()) }}
|
||||
{{ stat_card("Running", running_count, "success", circle_check()) }}
|
||||
{{ stat_card("Stopped", stopped_count, icon=circle_x()) }}
|
||||
</div>
|
||||
{{ stat_card("Hosts", hosts | length, icon=server()) }}
|
||||
{{ stat_card("Services", services | length, icon=layers()) }}
|
||||
{{ stat_card("Running", running_count, "success", circle_check()) }}
|
||||
{{ stat_card("Stopped", stopped_count, icon=circle_x()) }}
|
||||
|
||||
@@ -195,6 +195,27 @@ class TestHTMXSidebarLoading:
|
||||
assert "radarr" in content
|
||||
assert "jellyfin" in content
|
||||
|
||||
def test_dashboard_content_persists_after_sidebar_loads(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Dashboard content must remain visible after HTMX loads sidebar.
|
||||
|
||||
Regression test: conflicting hx-select attributes on the nav element
|
||||
were causing the dashboard to disappear when sidebar loaded.
|
||||
"""
|
||||
page.goto(server_url)
|
||||
|
||||
# Dashboard content should be visible immediately (server-rendered)
|
||||
stats = page.locator("#stats-cards")
|
||||
assert stats.is_visible()
|
||||
|
||||
# Wait for sidebar to fully load via HTMX
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Dashboard content must STILL be visible after sidebar loads
|
||||
assert stats.is_visible(), "Dashboard disappeared after sidebar loaded"
|
||||
assert page.locator("#stats-cards .card").count() >= 4
|
||||
|
||||
def test_sidebar_shows_running_status(self, page: Page, server_url: str) -> None:
|
||||
"""Sidebar shows running/stopped status indicators for services."""
|
||||
page.goto(server_url)
|
||||
@@ -736,3 +757,146 @@ class TestKeyboardShortcuts:
|
||||
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
|
||||
class TestContentStability:
|
||||
"""Test that HTMX operations don't accidentally destroy other page content.
|
||||
|
||||
These tests verify that when one element updates, other elements remain stable.
|
||||
This catches bugs where HTMX attributes (hx-select, hx-swap-oob, etc.) are
|
||||
misconfigured and cause unintended side effects.
|
||||
"""
|
||||
|
||||
def test_all_dashboard_sections_visible_after_full_load(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""All dashboard sections remain visible after HTMX completes loading."""
|
||||
page.goto(server_url)
|
||||
|
||||
# Wait for all HTMX requests to complete
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# All major dashboard sections must be visible
|
||||
assert page.locator("#stats-cards").is_visible(), "Stats cards missing"
|
||||
assert page.locator("#stats-cards .card").count() >= 4, "Stats incomplete"
|
||||
assert page.locator("#pending-operations").is_visible(), "Pending ops missing"
|
||||
assert page.locator("#services-by-host").is_visible(), "Services by host missing"
|
||||
assert page.locator("#sidebar-services").is_visible(), "Sidebar missing"
|
||||
|
||||
def test_sidebar_persists_after_navigation_and_back(self, page: Page, server_url: str) -> None:
|
||||
"""Sidebar content persists through navigation cycle."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Remember sidebar state
|
||||
initial_count = page.locator("#sidebar-services li").count()
|
||||
assert initial_count == 4
|
||||
|
||||
# Navigate away
|
||||
page.locator("#sidebar-services a", has_text="plex").click()
|
||||
page.wait_for_url("**/service/plex", timeout=5000)
|
||||
|
||||
# Sidebar should still be there with same content
|
||||
assert page.locator("#sidebar-services").is_visible()
|
||||
assert page.locator("#sidebar-services li").count() == initial_count
|
||||
|
||||
# Navigate back
|
||||
page.go_back()
|
||||
page.wait_for_url(server_url, timeout=5000)
|
||||
|
||||
# Sidebar still intact
|
||||
assert page.locator("#sidebar-services").is_visible()
|
||||
assert page.locator("#sidebar-services li").count() == initial_count
|
||||
|
||||
def test_dashboard_sections_persist_after_save(self, page: Page, server_url: str) -> None:
|
||||
"""Dashboard sections remain after save triggers cf:refresh event."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Capture initial state - all must be visible
|
||||
assert page.locator("#stats-cards").is_visible()
|
||||
assert page.locator("#pending-operations").is_visible()
|
||||
assert page.locator("#services-by-host").is_visible()
|
||||
|
||||
# Trigger save (which dispatches cf:refresh)
|
||||
page.locator("#save-config-btn").click()
|
||||
page.wait_for_function(
|
||||
"document.querySelector('#save-config-btn')?.textContent?.includes('Saved')",
|
||||
timeout=5000,
|
||||
)
|
||||
|
||||
# Wait for refresh requests to complete
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# All sections must still be visible
|
||||
assert page.locator("#stats-cards").is_visible(), "Stats disappeared after save"
|
||||
assert page.locator("#pending-operations").is_visible(), "Pending disappeared"
|
||||
assert page.locator("#services-by-host").is_visible(), "Services disappeared"
|
||||
assert page.locator("#sidebar-services").is_visible(), "Sidebar disappeared"
|
||||
|
||||
def test_filter_state_not_affected_by_other_htmx_requests(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Sidebar filter state persists during other HTMX activity."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Apply a filter
|
||||
filter_input = page.locator("#sidebar-filter")
|
||||
filter_input.fill("plex")
|
||||
filter_input.dispatch_event("keyup")
|
||||
|
||||
# Verify filter is applied
|
||||
assert page.locator("#sidebar-services li:not([hidden])").count() == 1
|
||||
|
||||
# Trigger a save (causes cf:refresh on multiple elements)
|
||||
page.locator("#save-config-btn").click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Filter input should still have our text
|
||||
# (Note: sidebar reloads so filter clears - this tests the sidebar reload works)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
assert page.locator("#sidebar-services").is_visible()
|
||||
|
||||
def test_main_content_not_affected_by_sidebar_refresh(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Main content area stays intact when sidebar refreshes."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Get main content text
|
||||
main_content = page.locator("#main-content")
|
||||
initial_text = main_content.inner_text()
|
||||
assert "Compose Farm" in initial_text
|
||||
|
||||
# Trigger cf:refresh (which refreshes sidebar)
|
||||
page.evaluate("document.body.dispatchEvent(new CustomEvent('cf:refresh'))")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Main content should be unchanged (same page, just refreshed partials)
|
||||
assert "Compose Farm" in main_content.inner_text()
|
||||
assert page.locator("#stats-cards").is_visible()
|
||||
|
||||
def test_no_duplicate_elements_after_multiple_refreshes(
|
||||
self, page: Page, server_url: str
|
||||
) -> None:
|
||||
"""Multiple refresh cycles don't create duplicate elements."""
|
||||
page.goto(server_url)
|
||||
page.wait_for_selector("#sidebar-services", timeout=5000)
|
||||
|
||||
# Count initial elements
|
||||
initial_stat_count = page.locator("#stats-cards .card").count()
|
||||
initial_service_count = page.locator("#sidebar-services li").count()
|
||||
|
||||
# Trigger multiple refreshes
|
||||
for _ in range(3):
|
||||
page.evaluate("document.body.dispatchEvent(new CustomEvent('cf:refresh'))")
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Counts should be same (no duplicates created)
|
||||
assert page.locator("#stats-cards .card").count() == initial_stat_count
|
||||
assert page.locator("#sidebar-services li").count() == initial_service_count
|
||||
|
||||
Reference in New Issue
Block a user