diff --git a/containers/libreportal/frontend/css/services.css b/containers/libreportal/frontend/css/services.css
index 17dc8db..4836a97 100644
--- a/containers/libreportal/frontend/css/services.css
+++ b/containers/libreportal/frontend/css/services.css
@@ -242,6 +242,24 @@
}
body:not(.lp-ui--advanced) .service-rich { display: none; }
+/* "Show logs" / "Hide logs" toggle sitting at the very bottom of the
+ open .task-details panel. Logs are off by default — the user opts in
+ per service so a row of expanded services doesn't open one SSE stream
+ each. Centred and given a little top breathing room so it reads as
+ a footer action rather than another inline button. */
+.service-logs-toggle {
+ display: flex;
+ justify-content: center;
+ margin-top: 8px;
+ margin-bottom: 4px;
+}
+.service-logs-toggle .service-show-logs {
+ padding: 6px 14px;
+}
+.service-item.logs-shown .service-logs-toggle {
+ margin-bottom: 8px;
+}
+
/* ============================================================
Beginner / Advanced toggle in the Services tab title row.
Project-wide visual; same component will be reused wherever
diff --git a/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js b/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js
index c4046a7..f6fe575 100755
--- a/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js
+++ b/containers/libreportal/frontend/js/components/app/app-tabbed-manager.js
@@ -942,10 +942,13 @@ class AppTabbedManager {
if (button.classList.contains('tab-button')) {
return;
}
- // Logs toggle buttons stay clickable while a task runs — viewing
- // log output is read-only and the whole point during a long task.
- if (button.dataset.action === 'toggle-logs' ||
- button.classList.contains('service-logs') ||
+ // Details + log-stream toggles stay clickable while a task runs —
+ // viewing service details and tailing logs is read-only and the
+ // whole point during a long task.
+ if (button.dataset.action === 'toggle-details' ||
+ button.dataset.action === 'toggle-log-stream' ||
+ button.classList.contains('service-details') ||
+ button.classList.contains('service-show-logs') ||
button.classList.contains('toggle-details')) {
return;
}
diff --git a/containers/libreportal/frontend/js/components/app/services-manager.js b/containers/libreportal/frontend/js/components/app/services-manager.js
index 088754f..796eeda 100644
--- a/containers/libreportal/frontend/js/components/app/services-manager.js
+++ b/containers/libreportal/frontend/js/components/app/services-manager.js
@@ -428,7 +428,7 @@ class ServicesManager {
return `
-
-
+ ${this._renderRichDetail(info)}
+
+
@@ -472,7 +479,6 @@ class ServicesManager {
- ${this._renderRichDetail(info)}
`;
}
@@ -494,8 +500,10 @@ class ServicesManager {
if (action === 'restart') {
await this._restartService(serviceName, btn);
- } else if (action === 'toggle-logs') {
- this._toggleLogs(item, serviceName);
+ } else if (action === 'toggle-details') {
+ this._toggleDetails(item, serviceName);
+ } else if (action === 'toggle-log-stream') {
+ this._toggleLogStream(item, serviceName);
} else if (action === 'resume-logs') {
this._resumeLogs(item, serviceName);
}
@@ -593,26 +601,61 @@ class ServicesManager {
}
}
- _toggleLogs(item, serviceName) {
- // The task-list uses a .task-details-open class (not the `hidden`
- // attribute) because .task-details has `display: none` baked in.
+ // Toggle the .task-details panel (meta + rich detail + log toggle).
+ // Logs are NOT auto-opened here — the user has to click "Show logs"
+ // explicitly. Closing the panel also tears down any open log stream
+ // and resets the inline log block back to its hidden state.
+ _toggleDetails(item, serviceName) {
const details = item.querySelector('.task-details');
- const output = item.querySelector('.service-log-output');
- if (!details || !output) return;
+ if (!details) return;
const isOpen = details.classList.contains('task-details-open');
if (isOpen) {
details.classList.remove('task-details-open');
- this._closeLogStream(serviceName);
- this._hideLogOverlay(output);
+ this._resetLogBlock(item, serviceName);
+ return;
+ }
+ details.classList.add('task-details-open');
+ }
+
+ // Show / hide the log block inside the open details panel. Opening
+ // starts the SSE stream so logs auto-update; closing tears it down.
+ _toggleLogStream(item, serviceName) {
+ const logsBlock = item.querySelector('.task-logs');
+ const output = item.querySelector('.service-log-output');
+ if (!logsBlock || !output) return;
+
+ const showing = item.classList.contains('logs-shown');
+ if (showing) {
+ this._resetLogBlock(item, serviceName);
return;
}
- details.classList.add('task-details-open');
+ logsBlock.style.display = '';
output.textContent = '';
this._hideLogOverlay(output);
output.dataset.stream = 'connecting';
this._openLogStream(serviceName, output);
+ item.classList.add('logs-shown');
+ this._setLogToggleLabel(item, 'Hide logs');
+ }
+
+ // Tear down the log stream and put the block back to its closed state.
+ // Used both when the user clicks "Hide logs" and when the parent
+ // details panel collapses (so reopening starts in the clean state).
+ _resetLogBlock(item, serviceName) {
+ const logsBlock = item.querySelector('.task-logs');
+ const output = item.querySelector('.service-log-output');
+ if (output) this._hideLogOverlay(output);
+ if (logsBlock) logsBlock.style.display = 'none';
+ this._closeLogStream(serviceName);
+ item.classList.remove('logs-shown');
+ this._setLogToggleLabel(item, 'Show logs');
+ }
+
+ _setLogToggleLabel(item, text) {
+ const lbl = item.querySelector('.service-show-logs .task-btn-label');
+ if (lbl) lbl.textContent = text;
}
_openLogStream(serviceName, outputEl) {
diff --git a/containers/libreportal/frontend/themes/nebula/theme.css b/containers/libreportal/frontend/themes/nebula/theme.css
index 86d0efa..fc759b3 100644
--- a/containers/libreportal/frontend/themes/nebula/theme.css
+++ b/containers/libreportal/frontend/themes/nebula/theme.css
@@ -247,7 +247,8 @@
[data-theme="nebula"] .uninstall-btn,
[data-theme="nebula"] .btn-uninstall,
[data-theme="nebula"] .btn-danger,
-[data-theme="nebula"] .backup-danger-btn {
+[data-theme="nebula"] .backup-danger-btn,
+[data-theme="nebula"] .tool-run-btn.destructive {
background: rgba(var(--status-danger-rgb), 0.35) !important;
color: var(--text-primary) !important;
border: 1px solid rgba(var(--status-danger-rgb), 0.65) !important;
@@ -259,12 +260,34 @@
[data-theme="nebula"] .uninstall-btn:hover:not(:disabled),
[data-theme="nebula"] .btn-uninstall:hover:not(:disabled),
[data-theme="nebula"] .btn-danger:hover:not(:disabled),
-[data-theme="nebula"] .backup-danger-btn:hover:not(:disabled) {
+[data-theme="nebula"] .backup-danger-btn:hover:not(:disabled),
+[data-theme="nebula"] .tool-run-btn.destructive:hover:not(:disabled) {
background: rgba(var(--status-danger-rgb), 0.50) !important;
border-color: rgba(var(--status-danger-rgb), 0.85) !important;
transform: translateY(-1px);
}
+/* Tools-page Run buttons — same translucent recipe as service-trigger
+ (the small "Open" pill on installed app cards). The 0.12/0.30 alphas
+ from tools.css read as muddy green/red against Nebula's cosmic
+ gradient, especially with --status-success/danger text — bump to
+ 0.35/0.65 with neutral text so they pop. .destructive picks up the
+ danger recipe above via the chained selector. */
+[data-theme="nebula"] .tool-run-btn {
+ background: rgba(var(--status-success-rgb), 0.35) !important;
+ color: var(--text-primary) !important;
+ border: 1px solid rgba(var(--status-success-rgb), 0.65) !important;
+ text-shadow: none;
+ font-weight: 600 !important;
+ transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease !important;
+}
+
+[data-theme="nebula"] .tool-run-btn:hover:not(:disabled):not(.destructive) {
+ background: rgba(var(--status-success-rgb), 0.50) !important;
+ border-color: rgba(var(--status-success-rgb), 0.85) !important;
+ transform: translateY(-1px);
+}
+
[data-theme="nebula"] .manage-btn,
[data-theme="nebula"] .btn-manage,
[data-theme="nebula"] .btn-primary,