Skip to content

Commit 6d6efbd

Browse files
committed
add basic log viewer
1 parent f5f6936 commit 6d6efbd

File tree

5 files changed

+381
-0
lines changed

5 files changed

+381
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package api
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"net/http"
7+
"os/exec"
8+
9+
"github.com/go-chi/chi/v5"
10+
)
11+
12+
// handleAppLogs streams app logs via SSE using journalctl
13+
func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) {
14+
name := chi.URLParam(r, "name")
15+
16+
// Verify app exists
17+
app, err := s.appStore.GetByName(name)
18+
if err != nil || app == nil {
19+
respondError(w, http.StatusNotFound, "App not found")
20+
return
21+
}
22+
23+
// Set headers for SSE
24+
w.Header().Set("Content-Type", "text/event-stream")
25+
w.Header().Set("Cache-Control", "no-cache")
26+
w.Header().Set("Connection", "keep-alive")
27+
w.Header().Set("Access-Control-Allow-Origin", "*")
28+
29+
flusher, ok := w.(http.Flusher)
30+
if !ok {
31+
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
32+
return
33+
}
34+
35+
s.logger.Info("SSE client connected for app logs", "app", name)
36+
37+
// Start journalctl with context for cleanup on client disconnect
38+
ctx := r.Context()
39+
serviceName := fmt.Sprintf("podman-%s.service", name)
40+
cmd := exec.CommandContext(ctx, "journalctl", "--user",
41+
"-u", serviceName,
42+
"-f", // follow (stream new entries)
43+
"-n", "100", // show last 100 lines initially
44+
"--no-pager",
45+
"-o", "short-iso", // timestamp format
46+
)
47+
48+
stdout, err := cmd.StdoutPipe()
49+
if err != nil {
50+
s.logger.Error("failed to create stdout pipe", "error", err)
51+
respondError(w, http.StatusInternalServerError, "Failed to start log stream")
52+
return
53+
}
54+
55+
if err := cmd.Start(); err != nil {
56+
s.logger.Error("failed to start journalctl", "error", err)
57+
respondError(w, http.StatusInternalServerError, "Failed to start log stream")
58+
return
59+
}
60+
61+
// Stream stdout line by line
62+
scanner := bufio.NewScanner(stdout)
63+
for scanner.Scan() {
64+
line := scanner.Text()
65+
fmt.Fprintf(w, "data: %s\n\n", line)
66+
flusher.Flush()
67+
68+
// Check if client disconnected
69+
select {
70+
case <-ctx.Done():
71+
s.logger.Info("SSE client disconnected from app logs", "app", name)
72+
return
73+
default:
74+
}
75+
}
76+
77+
if err := scanner.Err(); err != nil {
78+
s.logger.Error("scanner error reading logs", "error", err)
79+
}
80+
81+
cmd.Wait()
82+
s.logger.Info("log stream ended", "app", name)
83+
}

services/host-agent/internal/api/routes.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ func (s *Server) setupRoutes() {
3838
r.Post("/{name}/uninstall", s.handleUninstall)
3939
r.Post("/{name}/clear-data", s.handleClearData)
4040

41+
// Logs streaming
42+
r.Get("/{name}/logs", s.handleAppLogs)
43+
4144
// Static assets
4245
r.Get("/{name}/icon", s.handleAppIcon)
4346
})
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
<script lang="ts">
2+
import Modal from './Modal.svelte';
3+
import CloseButton from './CloseButton.svelte';
4+
5+
interface Props {
6+
appName: string | null;
7+
displayName: string;
8+
onclose: () => void;
9+
}
10+
11+
let { appName, displayName, onclose }: Props = $props();
12+
13+
let logs = $state<string[]>([]);
14+
let eventSource: EventSource | null = null;
15+
let autoScroll = $state(true);
16+
let logContainer: HTMLElement | undefined = $state();
17+
let connected = $state(false);
18+
let error = $state<string | null>(null);
19+
20+
$effect(() => {
21+
if (appName) {
22+
// Reset state
23+
logs = [];
24+
error = null;
25+
connected = false;
26+
27+
// Connect to SSE
28+
eventSource = new EventSource(`/api/apps/${appName}/logs`);
29+
30+
eventSource.onopen = () => {
31+
connected = true;
32+
};
33+
34+
eventSource.onmessage = (e) => {
35+
logs.push(e.data);
36+
// Keep logs buffer manageable
37+
if (logs.length > 1000) {
38+
logs = logs.slice(-500);
39+
}
40+
};
41+
42+
eventSource.onerror = () => {
43+
if (eventSource?.readyState === EventSource.CLOSED) {
44+
error = 'Connection closed';
45+
}
46+
};
47+
}
48+
49+
return () => {
50+
eventSource?.close();
51+
eventSource = null;
52+
};
53+
});
54+
55+
// Auto-scroll when new logs arrive
56+
$effect(() => {
57+
if (autoScroll && logContainer && logs.length > 0) {
58+
// Access logs.length to track changes
59+
logs.length;
60+
requestAnimationFrame(() => {
61+
if (logContainer) {
62+
logContainer.scrollTop = logContainer.scrollHeight;
63+
}
64+
});
65+
}
66+
});
67+
68+
function handleScroll() {
69+
if (!logContainer) return;
70+
const { scrollTop, scrollHeight, clientHeight } = logContainer;
71+
// Disable auto-scroll if user scrolls up
72+
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
73+
autoScroll = isAtBottom;
74+
}
75+
76+
function handleClose() {
77+
onclose();
78+
}
79+
80+
function clearLogs() {
81+
logs = [];
82+
}
83+
</script>
84+
85+
<Modal open={!!appName} onclose={handleClose} size="lg">
86+
<header class="modal-header">
87+
<div class="header-title">
88+
<h2>Logs</h2>
89+
<span class="app-name">{displayName}</span>
90+
</div>
91+
<div class="header-actions">
92+
<span class="status" class:connected>
93+
{connected ? 'Live' : 'Connecting...'}
94+
</span>
95+
<CloseButton onclick={handleClose} />
96+
</div>
97+
</header>
98+
99+
<div class="modal-body">
100+
{#if error}
101+
<div class="error-message">{error}</div>
102+
{/if}
103+
<div
104+
class="log-container"
105+
bind:this={logContainer}
106+
onscroll={handleScroll}
107+
>
108+
{#if logs.length === 0}
109+
<div class="empty-logs">Waiting for logs...</div>
110+
{:else}
111+
{#each logs as line}
112+
<div class="log-line">{line}</div>
113+
{/each}
114+
{/if}
115+
</div>
116+
</div>
117+
118+
<footer class="modal-footer">
119+
<div class="footer-left">
120+
<label class="auto-scroll-toggle">
121+
<input type="checkbox" bind:checked={autoScroll} />
122+
Auto-scroll
123+
</label>
124+
</div>
125+
<div class="footer-right">
126+
<button class="btn btn-secondary" onclick={clearLogs}>Clear</button>
127+
<button class="btn btn-secondary" onclick={handleClose}>Close</button>
128+
</div>
129+
</footer>
130+
</Modal>
131+
132+
<style>
133+
.modal-header {
134+
display: flex;
135+
justify-content: space-between;
136+
align-items: center;
137+
padding: var(--space-md) var(--space-lg);
138+
border-bottom: 1px solid var(--color-border);
139+
}
140+
141+
.header-title {
142+
display: flex;
143+
align-items: baseline;
144+
gap: var(--space-sm);
145+
}
146+
147+
.header-title h2 {
148+
margin: 0;
149+
font-size: 1.125rem;
150+
}
151+
152+
.app-name {
153+
color: var(--color-text-muted);
154+
font-size: 0.875rem;
155+
}
156+
157+
.header-actions {
158+
display: flex;
159+
align-items: center;
160+
gap: var(--space-md);
161+
}
162+
163+
.status {
164+
font-size: 0.75rem;
165+
color: var(--color-text-muted);
166+
padding: 2px 8px;
167+
border-radius: var(--radius-sm);
168+
background: var(--color-bg-subtle);
169+
}
170+
171+
.status.connected {
172+
color: var(--color-success);
173+
background: rgba(34, 197, 94, 0.1);
174+
}
175+
176+
.modal-body {
177+
padding: 0;
178+
display: flex;
179+
flex-direction: column;
180+
}
181+
182+
.log-container {
183+
height: 400px;
184+
overflow-y: auto;
185+
background: var(--color-bg);
186+
font-family: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', monospace;
187+
font-size: 0.75rem;
188+
line-height: 1.5;
189+
padding: var(--space-sm);
190+
}
191+
192+
.log-line {
193+
padding: 1px var(--space-sm);
194+
white-space: pre-wrap;
195+
word-break: break-all;
196+
}
197+
198+
.log-line:hover {
199+
background: var(--color-bg-subtle);
200+
}
201+
202+
.empty-logs {
203+
color: var(--color-text-muted);
204+
text-align: center;
205+
padding: var(--space-xl);
206+
font-family: var(--font-serif);
207+
font-size: 0.875rem;
208+
}
209+
210+
.error-message {
211+
padding: var(--space-sm) var(--space-md);
212+
background: rgba(185, 28, 28, 0.1);
213+
color: var(--color-error);
214+
font-size: 0.875rem;
215+
}
216+
217+
.modal-footer {
218+
display: flex;
219+
justify-content: space-between;
220+
align-items: center;
221+
padding: var(--space-sm) var(--space-lg);
222+
border-top: 1px solid var(--color-border);
223+
}
224+
225+
.footer-left {
226+
display: flex;
227+
align-items: center;
228+
}
229+
230+
.footer-right {
231+
display: flex;
232+
gap: var(--space-sm);
233+
}
234+
235+
.auto-scroll-toggle {
236+
display: flex;
237+
align-items: center;
238+
gap: var(--space-xs);
239+
font-size: 0.8125rem;
240+
color: var(--color-text-secondary);
241+
cursor: pointer;
242+
}
243+
244+
.auto-scroll-toggle input {
245+
cursor: pointer;
246+
}
247+
248+
.btn {
249+
display: inline-flex;
250+
align-items: center;
251+
gap: var(--space-sm);
252+
padding: var(--space-xs) var(--space-md);
253+
border-radius: var(--radius-md);
254+
font-size: 0.8125rem;
255+
font-family: var(--font-serif);
256+
cursor: pointer;
257+
border: 1px solid transparent;
258+
transition: all 0.15s ease;
259+
}
260+
261+
.btn-secondary {
262+
background: var(--color-bg-elevated);
263+
color: var(--color-text);
264+
border-color: var(--color-border);
265+
}
266+
267+
.btn-secondary:hover:not(:disabled) {
268+
background: var(--color-bg-subtle);
269+
}
270+
</style>

0 commit comments

Comments
 (0)