Services tab: systemd unit list/filter, detail with start/stop/restart/reload/enable/disable, live journal tail over WS, unit file viewer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
cd3979efce
commit
bc891b1042
+2
-1
@@ -22,6 +22,7 @@ import (
|
|||||||
"hamlab/internal/auth"
|
"hamlab/internal/auth"
|
||||||
"hamlab/internal/config"
|
"hamlab/internal/config"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
|
"hamlab/internal/services"
|
||||||
"hamlab/internal/store"
|
"hamlab/internal/store"
|
||||||
hterm "hamlab/internal/term"
|
hterm "hamlab/internal/term"
|
||||||
"hamlab/internal/web"
|
"hamlab/internal/web"
|
||||||
@@ -141,7 +142,7 @@ func serve(cfg *config.Config) error {
|
|||||||
col := metrics.NewCollector()
|
col := metrics.NewCollector()
|
||||||
go col.Run(context.Background())
|
go col.Run(context.Background())
|
||||||
|
|
||||||
srv := api.New(cfg, sessions, mgr, col, st, web.FS(), version)
|
srv := api.New(cfg, sessions, mgr, col, st, services.New(), web.FS(), version)
|
||||||
hs := &http.Server{
|
hs := &http.Server{
|
||||||
Addr: cfg.Listen,
|
Addr: cfg.Listen,
|
||||||
Handler: srv.Handler(),
|
Handler: srv.Handler(),
|
||||||
|
|||||||
+119
-2
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"hamlab/internal/auth"
|
"hamlab/internal/auth"
|
||||||
"hamlab/internal/config"
|
"hamlab/internal/config"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
|
"hamlab/internal/services"
|
||||||
"hamlab/internal/store"
|
"hamlab/internal/store"
|
||||||
"hamlab/internal/term"
|
"hamlab/internal/term"
|
||||||
)
|
)
|
||||||
@@ -32,6 +34,7 @@ type Server struct {
|
|||||||
mgr *term.Manager
|
mgr *term.Manager
|
||||||
metrics *metrics.Collector
|
metrics *metrics.Collector
|
||||||
store *store.Store
|
store *store.Store
|
||||||
|
svc *services.Manager
|
||||||
version string
|
version string
|
||||||
started time.Time
|
started time.Time
|
||||||
user string
|
user string
|
||||||
@@ -40,9 +43,9 @@ type Server struct {
|
|||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, ui fs.FS, version string) *Server {
|
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, ui fs.FS, version string) *Server {
|
||||||
host, _ := os.Hostname()
|
host, _ := os.Hostname()
|
||||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, ui: ui, version: version,
|
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, ui: ui, version: version,
|
||||||
started: time.Now(), host: host, user: os.Getenv("USER")}
|
started: time.Now(), host: host, user: os.Getenv("USER")}
|
||||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||||
s.user = u.Username
|
s.user = u.Username
|
||||||
@@ -67,6 +70,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
|
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
|
||||||
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
|
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
|
||||||
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
|
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
|
||||||
|
m.HandleFunc("GET /api/services", s.auth(s.listServices))
|
||||||
|
m.HandleFunc("GET /api/services/{unit}", s.auth(s.showService))
|
||||||
|
m.HandleFunc("GET /api/services/{unit}/file", s.auth(s.serviceFile))
|
||||||
|
m.HandleFunc("GET /api/services/{unit}/logs", s.auth(s.serviceLogs))
|
||||||
|
m.HandleFunc("GET /api/services/{unit}/logs/tail", s.auth(s.serviceTail))
|
||||||
|
m.HandleFunc("POST /api/services/{unit}/{action}", s.auth(s.serviceAction))
|
||||||
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
||||||
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
||||||
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
||||||
@@ -194,6 +203,114 @@ func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) {
|
|||||||
jsonOK(w, 200, s.metrics.History())
|
jsonOK(w, 200, s.metrics.History())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- services (systemd) ----
|
||||||
|
|
||||||
|
func (s *Server) listServices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := s.svc.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{"units": list, "sudo": s.svc.Sudo()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) showService(w http.ResponseWriter, r *http.Request) {
|
||||||
|
d, err := s.svc.Show(r.Context(), r.PathValue("unit"))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serviceFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
out, err := s.svc.Cat(r.Context(), r.PathValue("unit"))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]string{"content": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func logCount(r *http.Request) int {
|
||||||
|
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
|
||||||
|
if n <= 0 {
|
||||||
|
n = 200
|
||||||
|
}
|
||||||
|
if n > 5000 {
|
||||||
|
n = 5000
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serviceLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := s.svc.Journal(r.Context(), r.PathValue("unit"), logCount(r))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceTail streams journal entries as JSON text frames until the client goes away.
|
||||||
|
func (s *Server) serviceTail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
unit := r.PathValue("unit")
|
||||||
|
if !services.Valid(unit) {
|
||||||
|
jsonErr(w, 400, "invalid unit name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
go func() { // reader: only to notice the close (and keep pings flowing)
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.Read(ctx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(25 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pctx)
|
||||||
|
pc()
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = s.svc.Tail(ctx, unit, logCount(r), func(e services.Entry) {
|
||||||
|
b, _ := json.Marshal(e)
|
||||||
|
if err := conn.Write(ctx, websocket.MessageText, b); err != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serviceAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
unit, action := r.PathValue("unit"), r.PathValue("action")
|
||||||
|
if err := s.svc.Action(r.Context(), action, unit); err != nil {
|
||||||
|
log.Printf("systemctl %s %s: %v", action, unit, err)
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("systemctl %s %s ok (from %s)", action, unit, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
||||||
|
|
||||||
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// Package services wraps systemctl and journalctl for the Services tab: list units,
|
||||||
|
// show one, read its unit file, read and tail its journal, and start/stop/etc.
|
||||||
|
//
|
||||||
|
// Reads run as the daemon's user (systemd exposes state to everyone; the journal needs
|
||||||
|
// the systemd-journal or adm group, or sudo). Actions go through passwordless sudo when
|
||||||
|
// it's available, otherwise plain systemctl so polkit gets its say.
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Unit struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Load string `json:"load"`
|
||||||
|
Active string `json:"active"`
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Enabled string `json:"enabled"` // enabled | disabled | static | masked | … | "" (unknown)
|
||||||
|
Failed bool `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Detail struct {
|
||||||
|
Unit
|
||||||
|
Props map[string]string `json:"props"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
T int64 `json:"t"` // unix ms
|
||||||
|
Priority int `json:"p"` // syslog priority 0-7
|
||||||
|
Ident string `json:"ident"`
|
||||||
|
PID string `json:"pid,omitempty"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var unitRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.service$`)
|
||||||
|
|
||||||
|
func Valid(unit string) bool { return unitRe.MatchString(unit) }
|
||||||
|
|
||||||
|
var Actions = map[string]bool{"start": true, "stop": true, "restart": true, "reload": true, "enable": true, "disable": true, "reset-failed": true}
|
||||||
|
|
||||||
|
// Properties shown on the detail page, in display order.
|
||||||
|
var showProps = []string{"Id", "Description", "LoadState", "ActiveState", "SubState", "UnitFileState", "FragmentPath",
|
||||||
|
"MainPID", "ExecMainStartTimestamp", "ActiveEnterTimestamp", "InactiveEnterTimestamp", "NRestarts", "Result",
|
||||||
|
"MemoryCurrent", "MemoryPeak", "CPUUsageNSec", "TasksCurrent", "ExecStart", "User", "Restart"}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
sudo bool // actions via sudo -n
|
||||||
|
journalSudo bool // journal needs sudo (user isn't in systemd-journal/adm)
|
||||||
|
mu sync.Mutex
|
||||||
|
cache []Unit
|
||||||
|
cacheAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Manager {
|
||||||
|
m := &Manager{}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
m.sudo = exec.CommandContext(ctx, "sudo", "-n", "true").Run() == nil
|
||||||
|
// Prefer reading the journal as ourselves: a plain child can be killed cleanly when a
|
||||||
|
// tail ends, whereas a root journalctl under sudo can only be reached with sudo kill.
|
||||||
|
var errb bytes.Buffer
|
||||||
|
probe := exec.CommandContext(ctx, "journalctl", "--system", "-n", "1", "-q", "-o", "cat")
|
||||||
|
probe.Stderr = &errb
|
||||||
|
if err := probe.Run(); err != nil || strings.Contains(strings.ToLower(errb.String()), "permission") {
|
||||||
|
m.journalSudo = m.sudo
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sudo reports whether actions will run through passwordless sudo.
|
||||||
|
func (m *Manager) Sudo() bool { return m.sudo }
|
||||||
|
|
||||||
|
func run(ctx context.Context, args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
msg := strings.TrimSpace(errb.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return out.String(), errors.New(msg)
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List merges `list-units --all` (runtime state) with `list-unit-files` (enablement).
|
||||||
|
// Cached for a few seconds because the UI polls it.
|
||||||
|
func (m *Manager) List(ctx context.Context) ([]Unit, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if time.Since(m.cacheAt) < 4*time.Second && m.cache != nil {
|
||||||
|
c := m.cache
|
||||||
|
m.mu.Unlock()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
out, err := run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var units []struct{ Unit, Load, Active, Sub, Description string }
|
||||||
|
if err := json.Unmarshal([]byte(out), &units); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse list-units: %w", err)
|
||||||
|
}
|
||||||
|
files, _ := run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager")
|
||||||
|
var uf []struct {
|
||||||
|
UnitFile string `json:"unit_file"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(files), &uf)
|
||||||
|
enabled := map[string]string{}
|
||||||
|
for _, f := range uf {
|
||||||
|
enabled[f.UnitFile] = f.State
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
list := make([]Unit, 0, len(units)+len(uf))
|
||||||
|
for _, u := range units {
|
||||||
|
if strings.Contains(u.Unit, "@.") { // uninstantiated template
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[u.Unit] = true
|
||||||
|
list = append(list, Unit{Name: u.Unit, Description: u.Description, Load: u.Load, Active: u.Active, Sub: u.Sub,
|
||||||
|
Enabled: enabled[u.Unit], Failed: u.Active == "failed"})
|
||||||
|
}
|
||||||
|
// Unit files that exist but were never loaded this boot (disabled and never started).
|
||||||
|
for _, f := range uf {
|
||||||
|
if seen[f.UnitFile] || strings.Contains(f.UnitFile, "@.") || f.State == "masked" || f.State == "alias" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, Unit{Name: f.UnitFile, Load: "not-loaded", Active: "inactive", Sub: "dead", Enabled: f.State})
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.cache, m.cacheAt = list, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) invalidate() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.cacheAt = time.Time{}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Show(ctx context.Context, unit string) (Detail, error) {
|
||||||
|
if !Valid(unit) {
|
||||||
|
return Detail{}, errors.New("invalid unit name")
|
||||||
|
}
|
||||||
|
out, err := run(ctx, "systemctl", "show", unit, "-p", strings.Join(showProps, ","), "--no-pager")
|
||||||
|
if err != nil {
|
||||||
|
return Detail{}, err
|
||||||
|
}
|
||||||
|
props := map[string]string{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, "="); ok && v != "" && v != "[not set]" && v != "infinity" {
|
||||||
|
props[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if props["LoadState"] == "not-found" {
|
||||||
|
return Detail{}, errors.New("no such unit")
|
||||||
|
}
|
||||||
|
d := Detail{Props: props}
|
||||||
|
d.Name, d.Description = unit, props["Description"]
|
||||||
|
d.Load, d.Active, d.Sub, d.Enabled = props["LoadState"], props["ActiveState"], props["SubState"], props["UnitFileState"]
|
||||||
|
d.Failed = d.Active == "failed"
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Cat(ctx context.Context, unit string) (string, error) {
|
||||||
|
if !Valid(unit) {
|
||||||
|
return "", errors.New("invalid unit name")
|
||||||
|
}
|
||||||
|
return run(ctx, "systemctl", "cat", unit, "--no-pager")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEntry(line []byte) (Entry, bool) {
|
||||||
|
var raw map[string]any
|
||||||
|
if json.Unmarshal(line, &raw) != nil {
|
||||||
|
return Entry{}, false
|
||||||
|
}
|
||||||
|
e := Entry{Priority: 6}
|
||||||
|
switch v := raw["MESSAGE"].(type) {
|
||||||
|
case string:
|
||||||
|
e.Msg = v
|
||||||
|
case []any: // non-UTF-8 payload comes as a byte array
|
||||||
|
b := make([]byte, 0, len(v))
|
||||||
|
for _, x := range v {
|
||||||
|
if f, ok := x.(float64); ok {
|
||||||
|
b = append(b, byte(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.Msg = string(b)
|
||||||
|
default:
|
||||||
|
e.Msg = ""
|
||||||
|
}
|
||||||
|
if p, ok := raw["PRIORITY"].(string); ok {
|
||||||
|
e.Priority, _ = strconv.Atoi(p)
|
||||||
|
}
|
||||||
|
if ts, ok := raw["__REALTIME_TIMESTAMP"].(string); ok {
|
||||||
|
us, _ := strconv.ParseInt(ts, 10, 64)
|
||||||
|
e.T = us / 1000
|
||||||
|
}
|
||||||
|
if id, ok := raw["SYSLOG_IDENTIFIER"].(string); ok {
|
||||||
|
e.Ident = id
|
||||||
|
} else if c, ok := raw["_COMM"].(string); ok {
|
||||||
|
e.Ident = c
|
||||||
|
}
|
||||||
|
if pid, ok := raw["_PID"].(string); ok {
|
||||||
|
e.PID = pid
|
||||||
|
}
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) journalArgs(unit string, n int) []string {
|
||||||
|
args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"}
|
||||||
|
if m.journalSudo {
|
||||||
|
args = append([]string{"sudo", "-n"}, args...)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journal returns the last n entries.
|
||||||
|
func (m *Manager) Journal(ctx context.Context, unit string, n int) ([]Entry, error) {
|
||||||
|
if !Valid(unit) {
|
||||||
|
return nil, errors.New("invalid unit name")
|
||||||
|
}
|
||||||
|
out, err := run(ctx, m.journalArgs(unit, n)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := []Entry{}
|
||||||
|
for _, line := range bytes.Split([]byte(out), []byte("\n")) {
|
||||||
|
if e, ok := parseEntry(line); ok {
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail streams entries (last n, then follow) to fn until ctx is cancelled.
|
||||||
|
func (m *Manager) Tail(ctx context.Context, unit string, n int, fn func(Entry)) error {
|
||||||
|
if !Valid(unit) {
|
||||||
|
return errors.New("invalid unit name")
|
||||||
|
}
|
||||||
|
args := append(m.journalArgs(unit, n), "-f")
|
||||||
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||||
|
// Own process group so the kill reaches journalctl itself, not just a sudo wrapper.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
pg := "-" + strconv.Itoa(cmd.Process.Pid)
|
||||||
|
if m.journalSudo { // children are root's; only sudo can signal them
|
||||||
|
return exec.Command("sudo", "-n", "kill", "-9", "--", pg).Run()
|
||||||
|
}
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(stdout)
|
||||||
|
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
if e, ok := parseEntry(sc.Bytes()); ok {
|
||||||
|
fn(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cmd.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action runs start/stop/restart/reload/enable/disable/reset-failed.
|
||||||
|
func (m *Manager) Action(ctx context.Context, action, unit string) error {
|
||||||
|
if !Valid(unit) {
|
||||||
|
return errors.New("invalid unit name")
|
||||||
|
}
|
||||||
|
if !Actions[action] {
|
||||||
|
return errors.New("unknown action")
|
||||||
|
}
|
||||||
|
args := []string{"systemctl", action, unit, "--no-pager"}
|
||||||
|
if m.sudo {
|
||||||
|
args = append([]string{"sudo", "-n"}, args...)
|
||||||
|
}
|
||||||
|
_, err := run(ctx, args...)
|
||||||
|
m.invalidate()
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
|
||||||
|
|
||||||
|
type Filter = 'all' | 'running' | 'failed' | 'enabled'
|
||||||
|
|
||||||
|
function fmtBytes(b: number): string {
|
||||||
|
const u = ['B', 'KiB', 'MiB', 'GiB']
|
||||||
|
let i = 0
|
||||||
|
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
||||||
|
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
||||||
|
}
|
||||||
|
function fmtCPU(ns: number): string {
|
||||||
|
const s = ns / 1e9
|
||||||
|
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s`
|
||||||
|
}
|
||||||
|
function since(ts?: string): string {
|
||||||
|
if (!ts) return ''
|
||||||
|
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
|
||||||
|
if (isNaN(t)) return ts
|
||||||
|
const s = Math.max(0, (Date.now() - t) / 1000)
|
||||||
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||||
|
return (d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${Math.floor(s)}s`) + ' ago'
|
||||||
|
}
|
||||||
|
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString() }
|
||||||
|
|
||||||
|
function stateClass(u: ServiceUnit): string {
|
||||||
|
if (u.failed) return 'bad'
|
||||||
|
if (u.active === 'active' && u.sub === 'running') return 'ok'
|
||||||
|
if (u.active === 'active') return 'exited'
|
||||||
|
if (u.active === 'activating' || u.active === 'deactivating') return 'warn'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- log viewer: seeded from the REST fetch, then followed over a WebSocket ----
|
||||||
|
function Logs({ unit }: { unit: string }) {
|
||||||
|
const [lines, setLines] = useState<LogEntry[]>([])
|
||||||
|
const [live, setLive] = useState(true)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const box = useRef<HTMLDivElement>(null)
|
||||||
|
const pausedRef = useRef(false)
|
||||||
|
pausedRef.current = paused
|
||||||
|
const pending = useRef<LogEntry[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLines([]); setErr(''); pending.current = []
|
||||||
|
let closed = false
|
||||||
|
let sock: WebSocket | null = null
|
||||||
|
let retry: number | undefined
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return
|
||||||
|
const s = new WebSocket(wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`))
|
||||||
|
sock = s
|
||||||
|
s.onopen = () => { setLive(true); setErr('') }
|
||||||
|
s.onmessage = (ev) => {
|
||||||
|
const e = JSON.parse(ev.data) as LogEntry
|
||||||
|
if (pausedRef.current) { pending.current.push(e); return }
|
||||||
|
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), e] : [...prev, e]))
|
||||||
|
}
|
||||||
|
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, 3000) }
|
||||||
|
s.onerror = () => { setErr('log stream disconnected'); s.close() }
|
||||||
|
}
|
||||||
|
connect()
|
||||||
|
return () => { closed = true; clearTimeout(retry); sock?.close() }
|
||||||
|
}, [unit])
|
||||||
|
|
||||||
|
// Resume: flush what arrived while paused.
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || pending.current.length === 0) return
|
||||||
|
const p = pending.current; pending.current = []
|
||||||
|
setLines((prev) => [...prev, ...p].slice(-2000))
|
||||||
|
}, [paused])
|
||||||
|
|
||||||
|
useEffect(() => { if (!paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, paused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="logs">
|
||||||
|
<div className="logs-bar">
|
||||||
|
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'}{err && ` · ${err}`}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||||||
|
<button className="btn" onClick={() => setLines([])}>Clear</button>
|
||||||
|
</div>
|
||||||
|
<div className="log-lines" ref={box}>
|
||||||
|
{lines.length === 0 && <div className="muted">No journal entries.</div>}
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<div key={i} className={'log' + (l.p <= 3 ? ' err' : l.p === 4 ? ' warn' : l.p >= 7 ? ' debug' : '')}>
|
||||||
|
<span className="lt">{fmtTime(l.t)}</span>
|
||||||
|
<span className="li">{l.ident}{l.pid ? `[${l.pid}]` : ''}</span>
|
||||||
|
<span className="lm">{l.msg}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- detail pane ----
|
||||||
|
function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => void; onBack: () => void }) {
|
||||||
|
const [d, setD] = useState<ServiceDetail | null>(null)
|
||||||
|
const [tab, setTab] = useState<'logs' | 'file'>('logs')
|
||||||
|
const [file, setFile] = useState<string>('')
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setD(await api.service(name)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [name])
|
||||||
|
useEffect(() => { setD(null); setFile(''); setTab('logs'); load() }, [load])
|
||||||
|
useEffect(() => { const t = setInterval(load, 5000); return () => clearInterval(t) }, [load])
|
||||||
|
useEffect(() => { if (tab === 'file' && !file) api.serviceFile(name).then((r) => setFile(r.content)).catch((e) => setFile(String(e))) }, [tab, file, name])
|
||||||
|
|
||||||
|
async function act(action: string) {
|
||||||
|
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
|
||||||
|
if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return
|
||||||
|
setBusy(action); setErr('')
|
||||||
|
try { await api.serviceAction(name, action); await load(); onChanged() }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
finally { setBusy('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button><div className="muted">{err || '…'}</div></div>
|
||||||
|
const p = d.props
|
||||||
|
const running = d.active === 'active'
|
||||||
|
const enabled = d.enabled === 'enabled'
|
||||||
|
const Btn = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a ? '…' : label}</button>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="svc-detail">
|
||||||
|
<button className="btn back" onClick={onBack}>‹ Services</button>
|
||||||
|
<div className="svc-head">
|
||||||
|
<span className={'sdot ' + stateClass(d)} />
|
||||||
|
<h2>{d.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="muted">{d.description}</div>
|
||||||
|
<div className="svc-actions">
|
||||||
|
{running ? <Btn a="stop" label="Stop" /> : <Btn a="start" label="Start" />}
|
||||||
|
<Btn a="restart" label="Restart" />
|
||||||
|
{p.ExecReload || d.sub === 'running' ? <Btn a="reload" label="Reload" /> : null}
|
||||||
|
{enabled ? <Btn a="disable" label="Disable" /> : d.enabled === 'static' || d.enabled === 'masked' ? null : <Btn a="enable" label="Enable" />}
|
||||||
|
{d.failed && <Btn a="reset-failed" label="Reset failed" />}
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">State</span><span>{d.active} ({d.sub}){d.failed && p.Result ? ` · ${p.Result}` : ''}</span></div>
|
||||||
|
<div><span className="k">Enabled</span><span>{d.enabled || '—'}</span></div>
|
||||||
|
<div><span className="k">Since</span><span>{running ? since(p.ActiveEnterTimestamp) : since(p.InactiveEnterTimestamp)}</span></div>
|
||||||
|
{p.MainPID && p.MainPID !== '0' && <div><span className="k">PID</span><span>{p.MainPID}</span></div>}
|
||||||
|
{p.MemoryCurrent && <div><span className="k">Memory</span><span>{fmtBytes(+p.MemoryCurrent)}{p.MemoryPeak ? ` (peak ${fmtBytes(+p.MemoryPeak)})` : ''}</span></div>}
|
||||||
|
{p.CPUUsageNSec && <div><span className="k">CPU time</span><span>{fmtCPU(+p.CPUUsageNSec)}</span></div>}
|
||||||
|
{p.TasksCurrent && <div><span className="k">Tasks</span><span>{p.TasksCurrent}</span></div>}
|
||||||
|
{p.NRestarts && p.NRestarts !== '0' && <div><span className="k">Restarts</span><span>{p.NRestarts}</span></div>}
|
||||||
|
{p.User && <div><span className="k">User</span><span>{p.User}</span></div>}
|
||||||
|
{p.FragmentPath && <div><span className="k">Unit file</span><span className="mono">{p.FragmentPath}</span></div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
|
||||||
|
<button className={tab === 'file' ? 'on' : ''} onClick={() => setTab('file')}>Unit file</button>
|
||||||
|
</div>
|
||||||
|
{tab === 'logs' ? <Logs unit={name} /> : <pre className="unit-file">{file || '…'}</pre>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- page ----
|
||||||
|
export default function Services() {
|
||||||
|
const [units, setUnits] = useState<ServiceUnit[]>([])
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [filter, setFilter] = useState<Filter>('all')
|
||||||
|
const [sel, setSel] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 10000); return () => clearInterval(t) }, [load])
|
||||||
|
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const shown = units.filter((u) => {
|
||||||
|
if (filter === 'running' && !(u.active === 'active' && u.sub === 'running')) return false
|
||||||
|
if (filter === 'failed' && !u.failed) return false
|
||||||
|
if (filter === 'enabled' && u.enabled !== 'enabled') return false
|
||||||
|
return !needle || u.name.toLowerCase().includes(needle) || u.description.toLowerCase().includes(needle)
|
||||||
|
})
|
||||||
|
const counts = {
|
||||||
|
running: units.filter((u) => u.active === 'active' && u.sub === 'running').length,
|
||||||
|
failed: units.filter((u) => u.failed).length,
|
||||||
|
enabled: units.filter((u) => u.enabled === 'enabled').length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'svc' + (sel ? ' has-sel' : '')}>
|
||||||
|
<div className="svc-list">
|
||||||
|
<div className="svc-tools">
|
||||||
|
<input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
|
<div className="chips">
|
||||||
|
{([['all', `All ${units.length}`], ['running', `Running ${counts.running}`], ['failed', `Failed ${counts.failed}`], ['enabled', `Enabled ${counts.enabled}`]] as [Filter, string][]).map(([f, label]) => (
|
||||||
|
<button key={f} className={'chip' + (filter === f ? ' on' : '') + (f === 'failed' && counts.failed ? ' bad' : '')} onClick={() => setFilter(f)}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-rows">
|
||||||
|
{shown.map((u) => (
|
||||||
|
<div key={u.name} className={'svc-row' + (u.name === sel ? ' active' : '')} onClick={() => setSel(u.name)}>
|
||||||
|
<span className={'sdot ' + stateClass(u)} />
|
||||||
|
<span className="svc-name">
|
||||||
|
<span className="n">{u.name.replace(/\.service$/, '')}</span>
|
||||||
|
<span className="d">{u.description}</span>
|
||||||
|
</span>
|
||||||
|
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+5
-3
@@ -4,16 +4,17 @@ import Rail, { Group } from './Rail'
|
|||||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||||
import KeyBar from './KeyBar'
|
import KeyBar from './KeyBar'
|
||||||
import Dashboard from './Dashboard'
|
import Dashboard from './Dashboard'
|
||||||
|
import Services from './Services'
|
||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
{ id: 'services', label: 'Services', icon: '⚙', soon: true },
|
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
||||||
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
|
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
|
||||||
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
|
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'dashboard'
|
type View = 'terminal' | 'dashboard' | 'services'
|
||||||
|
|
||||||
function fmtUptime(s: number): string {
|
function fmtUptime(s: number): string {
|
||||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||||
@@ -79,6 +80,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
|||||||
|
|
||||||
<main className="main">
|
<main className="main">
|
||||||
{view === 'dashboard' && <Dashboard />}
|
{view === 'dashboard' && <Dashboard />}
|
||||||
|
{view === 'services' && <Services />}
|
||||||
{termView && terms.length === 0 && (
|
{termView && terms.length === 0 && (
|
||||||
<div className="center muted">
|
<div className="center muted">
|
||||||
<div>No terminals.</div>
|
<div>No terminals.</div>
|
||||||
@@ -101,7 +103,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
|||||||
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+12
-2
@@ -42,7 +42,16 @@ export interface Series {
|
|||||||
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
|
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
|
||||||
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number }
|
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number }
|
||||||
|
|
||||||
|
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
|
||||||
|
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
|
||||||
|
export interface LogEntry { t: number; p: number; ident: string; pid?: string; msg: string }
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units,
|
||||||
|
service: (unit: string) => req<ServiceDetail>('GET', `/api/services/${encodeURIComponent(unit)}`),
|
||||||
|
serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`),
|
||||||
|
serviceLogs: (unit: string, n = 200) => req<LogEntry[]>('GET', `/api/services/${encodeURIComponent(unit)}/logs?n=${n}`),
|
||||||
|
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
|
||||||
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
||||||
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
||||||
metrics: () => req<Sample>('GET', '/api/metrics'),
|
metrics: () => req<Sample>('GET', '/api/metrics'),
|
||||||
@@ -56,7 +65,8 @@ export const api = {
|
|||||||
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wsURL(id: string): string {
|
export function wsURLPath(path: string): string {
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
return `${proto}//${location.host}/api/terminals/${id}/attach`
|
return `${proto}//${location.host}${path}`
|
||||||
}
|
}
|
||||||
|
export const wsURL = (id: string) => wsURLPath(`/api/terminals/${id}/attach`)
|
||||||
|
|||||||
@@ -123,3 +123,40 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|||||||
.chip { padding: 3px 9px; background: var(--bg3); border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); }
|
.chip { padding: 3px 9px; background: var(--bg3); border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); }
|
||||||
.chip:hover { color: var(--fg); border-color: var(--accent2); }
|
.chip:hover { color: var(--fg); border-color: var(--accent2); }
|
||||||
.chips.inline { margin-top: 6px; }
|
.chips.inline { margin-top: 6px; }
|
||||||
|
|
||||||
|
/* services */
|
||||||
|
.svc { position: absolute; inset: 0; display: flex; background: var(--bg); }
|
||||||
|
.svc-list { width: 360px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--bg2); min-height: 0; }
|
||||||
|
.svc-tools { padding: 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
.svc-tools input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||||
|
.chip.on { color: var(--fg); border-color: var(--accent); } .chip.bad { color: var(--bad); }
|
||||||
|
.svc-rows { flex: 1; overflow-y: auto; }
|
||||||
|
.svc-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||||
|
.svc-row:hover { background: var(--bg3); } .svc-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.svc-name { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.svc-name .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.svc-name .d { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.svc-state { font-size: 11px; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; }
|
||||||
|
.sdot.ok { background: var(--ok); } .sdot.bad { background: var(--bad); } .sdot.warn { background: var(--s2); } .sdot.exited { background: var(--accent2); }
|
||||||
|
.svc-empty { flex: 1; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.svc-detail { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 12px; overflow-y: auto; }
|
||||||
|
.svc-detail .back { display: none; align-self: flex-start; }
|
||||||
|
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
|
||||||
|
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }
|
||||||
|
.props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; }
|
||||||
|
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
|
||||||
|
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||||
|
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
||||||
|
.unit-file { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.logs { flex: 1; min-height: 240px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: #0d1117; overflow: hidden; }
|
||||||
|
.logs-bar { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 12px; background: var(--bg2); }
|
||||||
|
.log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.log .lt { color: var(--muted); flex-shrink: 0; } .log .li { color: var(--accent2); flex-shrink: 0; }
|
||||||
|
.log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
|
||||||
|
.svc-detail .back { display: inline-block; } .props { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user