Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/prop4n/proxmops/internal/auth"
"github.com/prop4n/proxmops/internal/config"
"github.com/prop4n/proxmops/internal/crypt"
"github.com/prop4n/proxmops/internal/logbuf"
"github.com/prop4n/proxmops/internal/manifest"
"github.com/prop4n/proxmops/internal/proxmox"
"github.com/prop4n/proxmops/internal/reconcile"
Expand Down Expand Up @@ -190,6 +191,11 @@ func deleteManaged(ctx context.Context, desired []manifest.Resource, del cluster
}

func (a *App) Run(ctx context.Context, addr string) error {
// Tee the daemon's logs into an in-memory ring buffer so the web UI can show
// a live tail; lines still go to the original handler (stdout).
logs := logbuf.New(1000)
a.log = slog.New(logs.Handler(a.log.Handler()))

st, err := store.Open(a.cfg.Server.DatabasePath)
if err != nil {
return fmt.Errorf("open store: %w", err)
Expand Down Expand Up @@ -217,6 +223,7 @@ func (a *App) Run(ctx context.Context, addr string) error {
Deleter: a,
Detailer: a,
Events: st,
Logs: logs,
CookieSecure: a.cfg.Server.CookieSecure,
}, a.log)

Expand Down
120 changes: 120 additions & 0 deletions internal/logbuf/logbuf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Package logbuf keeps the daemon's most recent log lines in memory and fans
// new ones out to subscribers, so the web UI can show a live tail. It wraps an
// existing slog.Handler rather than replacing it: lines still go to stdout.
package logbuf

import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
)

// Entry is one captured log line.
type Entry struct {
Time time.Time `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
}

// Buffer is a bounded ring of recent log entries with live subscribers. It is
// safe for concurrent use.
type Buffer struct {
mu sync.Mutex
entries []Entry
cap int

subsMu sync.Mutex
subs map[chan Entry]struct{}
}

// New returns a Buffer retaining up to capacity entries.
func New(capacity int) *Buffer {
if capacity <= 0 {
capacity = 1000
}
return &Buffer{
entries: make([]Entry, 0, capacity),
cap: capacity,
subs: make(map[chan Entry]struct{}),
}
}

// Snapshot returns a copy of the retained entries, oldest first.
func (b *Buffer) Snapshot() []Entry {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]Entry, len(b.entries))
copy(out, b.entries)
return out
}

// Subscribe registers a listener for new entries. The channel is buffered; a
// slow consumer drops entries rather than blocking the daemon. The returned
// func unregisters it.
func (b *Buffer) Subscribe() (<-chan Entry, func()) {
ch := make(chan Entry, 64)
b.subsMu.Lock()
b.subs[ch] = struct{}{}
b.subsMu.Unlock()
return ch, func() {
b.subsMu.Lock()
delete(b.subs, ch)
b.subsMu.Unlock()
}
}

// add appends an entry, dropping the oldest past capacity, and broadcasts it.
func (b *Buffer) add(e Entry) {
b.mu.Lock()
if len(b.entries) == b.cap {
b.entries = append(b.entries[:0], b.entries[1:]...)
}
b.entries = append(b.entries, e)
b.mu.Unlock()

b.subsMu.Lock()
for ch := range b.subs {
select {
case ch <- e:
default: // drop for a slow consumer
}
}
b.subsMu.Unlock()
}

// Handler wraps inner so every record is also captured in the buffer.
func (b *Buffer) Handler(inner slog.Handler) slog.Handler {
return &handler{inner: inner, buf: b}
}

// handler tees slog records into the buffer, then delegates to inner.
type handler struct {
inner slog.Handler
buf *Buffer
}

func (h *handler) Enabled(ctx context.Context, l slog.Level) bool {
return h.inner.Enabled(ctx, l)
}

func (h *handler) Handle(ctx context.Context, r slog.Record) error {
var sb strings.Builder
sb.WriteString(r.Message)
r.Attrs(func(a slog.Attr) bool {
fmt.Fprintf(&sb, " %s=%v", a.Key, a.Value)
return true
})
h.buf.add(Entry{Time: r.Time, Level: r.Level.String(), Message: sb.String()})
return h.inner.Handle(ctx, r)
}

func (h *handler) WithAttrs(as []slog.Attr) slog.Handler {
return &handler{inner: h.inner.WithAttrs(as), buf: h.buf}
}

func (h *handler) WithGroup(name string) slog.Handler {
return &handler{inner: h.inner.WithGroup(name), buf: h.buf}
}
56 changes: 56 additions & 0 deletions internal/logbuf/logbuf_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package logbuf

import (
"context"
"io"
"log/slog"
"testing"
"time"
)

func TestBufferCapAndSnapshot(t *testing.T) {
b := New(3)
log := slog.New(b.Handler(slog.NewTextHandler(io.Discard, nil)))
for _, m := range []string{"a", "b", "c", "d"} {
log.Info(m)
}
snap := b.Snapshot()
if len(snap) != 3 {
t.Fatalf("len = %d, want 3 (capped)", len(snap))
}
// Oldest dropped; order preserved.
if snap[0].Message != "b" || snap[2].Message != "d" {
t.Fatalf("snapshot = %v, want [b c d]", []string{snap[0].Message, snap[1].Message, snap[2].Message})
}
}

func TestHandlerCapturesAttrs(t *testing.T) {
b := New(10)
log := slog.New(b.Handler(slog.NewTextHandler(io.Discard, nil)))
log.Info("applied", "action", "create", "vmid", 101)
snap := b.Snapshot()
if len(snap) != 1 {
t.Fatalf("len = %d, want 1", len(snap))
}
if snap[0].Level != "INFO" || snap[0].Message != "applied action=create vmid=101" {
t.Fatalf("entry = %+v", snap[0])
}
}

func TestSubscribeReceivesNewEntries(t *testing.T) {
b := New(10)
log := slog.New(b.Handler(slog.NewTextHandler(io.Discard, nil)))
ch, cancel := b.Subscribe()
defer cancel()

log.Info("live")
select {
case e := <-ch:
if e.Message != "live" {
t.Fatalf("got %q, want live", e.Message)
}
case <-time.After(time.Second):
t.Fatal("subscriber did not receive the entry")
}
_ = context.Background()
}
79 changes: 79 additions & 0 deletions internal/server/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package server

import (
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/prop4n/proxmops/internal/logbuf"
)

// LogSource provides the daemon's recent log lines and a live feed.
type LogSource interface {
Snapshot() []logbuf.Entry
Subscribe() (<-chan logbuf.Entry, func())
}

// handleLogs returns the retained log lines, oldest first.
func (s *Server) handleLogs(w http.ResponseWriter, _ *http.Request) {
if s.logs == nil {
writeJSON(w, http.StatusOK, []logbuf.Entry{})
return
}
writeJSON(w, http.StatusOK, s.logs.Snapshot())
}

// handleLogStream streams new log lines over Server-Sent Events.
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
if s.logs == nil {
writeError(w, http.StatusServiceUnavailable, "logs unavailable")
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()

entries, cancel := s.logs.Subscribe()
defer cancel()

emit := func(e logbuf.Entry) bool {
data, err := json.Marshal(e)
if err != nil {
return true
}
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(sseWriteTimeout))
if _, err := fmt.Fprintf(w, "event: log\ndata: %s\n\n", data); err != nil {
return false
}
flusher.Flush()
return true
}

keepAlive := time.NewTicker(sseKeepAlive)
defer keepAlive.Stop()

for {
select {
case <-r.Context().Done():
return
case e := <-entries:
if !emit(e) {
return
}
case <-keepAlive.C:
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(sseWriteTimeout))
if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
2 changes: 2 additions & 0 deletions internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func (s *Server) routes() http.Handler {
r.Delete("/resources/{kind}/{name}", s.handleDeleteResource)
r.Get("/resources/{kind}/{name}/events", s.handleResourceEvents)
r.Get("/events", s.handleEvents)
r.Get("/logs", s.handleLogs)
r.Get("/logs/stream", s.handleLogStream)
r.Get("/settings", s.handleGetSettings)
r.Put("/settings", s.handlePutSettings)
r.Post("/settings/test", s.handleTestSettings)
Expand Down
3 changes: 3 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Server struct {
deleter ResourceDeleter
detailer ResourceDetailer
events EventsReader
logs LogSource
cookieSecure bool
}

Expand All @@ -36,6 +37,7 @@ type Options struct {
Deleter ResourceDeleter
Detailer ResourceDetailer
Events EventsReader
Logs LogSource
CookieSecure bool
}

Expand All @@ -49,6 +51,7 @@ func New(opts Options, log *slog.Logger) *Server {
deleter: opts.Deleter,
detailer: opts.Detailer,
events: opts.Events,
logs: opts.Logs,
cookieSecure: opts.CookieSecure,
}
s.http = &http.Server{
Expand Down
3 changes: 2 additions & 1 deletion web/ui/src/components/layout/AppShell.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import { LayoutDashboard, LogOut, Settings2 } from 'lucide-vue-next'
import { LayoutDashboard, LogOut, ScrollText, Settings2 } from 'lucide-vue-next'
import ThemeToggle from '@/components/ThemeToggle.vue'
import StatusLed from '@/components/StatusLed.vue'
import ResourceDetail from '@/components/ResourceDetail.vue'
Expand All @@ -24,6 +24,7 @@ onUnmounted(() => sync.disconnect())

const nav = [
{ name: 'dashboard', label: 'Overview', icon: LayoutDashboard, to: '/' },
{ name: 'logs', label: 'Logs', icon: ScrollText, to: '/logs' },
{ name: 'settings', label: 'Settings', icon: Settings2, to: '/settings' },
]

Expand Down
7 changes: 7 additions & 0 deletions web/ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export interface Account {
username: string
}

export interface LogEntry {
time: string
level: string
message: string
}

export type SyncState = 'Synced' | 'OutOfSync'

export interface ObservedResource {
Expand Down Expand Up @@ -134,6 +140,7 @@ export const api = {
logout: () => request<null>('/logout', { method: 'POST' }),
me: () => request<Account>('/me'),
resources: () => request<StatusSnapshot>('/resources'),
logs: () => request<LogEntry[]>('/logs'),
deleteResource: (kind: string, name: string) =>
request<null>(`/resources/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`, { method: 'DELETE' }),
resourceDetail: (kind: string, name: string) =>
Expand Down
Loading
Loading