Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0464c540b2 | ||
|
|
45178d455e | ||
|
|
f28ab1a741 | ||
|
|
df6f8b6f62 | ||
|
|
8f3a27100f | ||
|
|
69c7a352f6 | ||
|
|
a2bfa98a2d | ||
|
|
57151826ae | ||
|
|
e413009faa | ||
|
|
3ec9f1b35f | ||
|
|
e019493087 | ||
|
|
ca2c05db14 | ||
|
|
1850f352a2 | ||
|
|
850ffbafe1 | ||
|
|
a28157dcf8 | ||
|
|
03e2c3c50d | ||
|
|
fb1a1292ec | ||
|
|
ec201a23a2 | ||
|
|
d9a33b0672 | ||
|
|
2b7ef98dff | ||
|
|
b5f30bc7c8 | ||
|
|
3a0116248e | ||
|
|
6af0a88841 | ||
|
|
e4c3fc24d3 | ||
|
|
e46d0edbf2 | ||
|
|
a4c4a72dbc | ||
|
|
67d729b360 | ||
|
|
da6d825f45 | ||
|
|
93423e32e6 | ||
|
|
d0442291f5 | ||
|
|
6c5472760b | ||
|
|
7c4a676742 | ||
|
|
fbda26a188 | ||
|
|
90ce7af769 | ||
|
|
b543cd1b3d | ||
|
|
15c9da1b01 | ||
|
|
baa7bb239d | ||
|
|
7342c46d99 | ||
|
|
813f9e6fef | ||
|
|
434f14ae3a | ||
|
|
8398fd2279 | ||
|
|
56f06b9eaf | ||
|
|
d9d241f83b | ||
|
|
aee910c1f8 | ||
|
|
bea545e873 | ||
|
|
82d7dde5f8 | ||
|
|
397016ad68 | ||
|
|
39348c9491 | ||
|
|
63dadf6239 | ||
|
|
d905c99d32 | ||
|
|
85e1baf59a | ||
|
|
351ad59dd8 | ||
|
|
dcc901b0d2 | ||
|
|
99bf093f00 | ||
|
|
619ccd28cb | ||
|
|
f22f0a4729 | ||
|
|
78194daf5f | ||
|
|
f141767fc2 | ||
|
|
05cd8e154b | ||
|
|
004cc03ba6 | ||
|
|
236e89989f |
@@ -0,0 +1,226 @@
|
||||
// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform
|
||||
// Result. It has no dependency on models or pb so it can be duplicated verbatim
|
||||
// into the agent module (agent-run monitors) — callers map their own monitor
|
||||
// representation onto Spec.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check types (mirror models.Monitor* constants).
|
||||
const (
|
||||
TypeHTTP = "http"
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
)
|
||||
|
||||
// Spec is a self-contained description of a single check.
|
||||
type Spec struct {
|
||||
Type string
|
||||
URL string
|
||||
Host string
|
||||
Port int
|
||||
Method string
|
||||
ExpectedStatus int
|
||||
Keyword string
|
||||
TLSWarnDays int
|
||||
Insecure bool // skip TLS certificate verification (HTTP checks)
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
// Result is the uniform outcome of running a check.
|
||||
type Result struct {
|
||||
Up bool
|
||||
LatencyMs int
|
||||
Message string
|
||||
CertExpiry *time.Time
|
||||
}
|
||||
|
||||
func (s Spec) timeout() time.Duration {
|
||||
t := s.TimeoutSec
|
||||
if t <= 0 || t > 10 {
|
||||
t = 10
|
||||
}
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
// Run executes the check described by s.
|
||||
func Run(ctx context.Context, s Spec) Result {
|
||||
switch s.Type {
|
||||
case TypeHTTP:
|
||||
return runHTTP(ctx, s)
|
||||
case TypeTCP:
|
||||
return runTCP(ctx, s)
|
||||
case TypeICMP:
|
||||
return runICMP(ctx, s)
|
||||
case TypeTLS:
|
||||
return runTLS(ctx, s)
|
||||
default:
|
||||
return Result{Message: "unknown check type: " + s.Type}
|
||||
}
|
||||
}
|
||||
|
||||
func runHTTP(ctx context.Context, s Spec) Result {
|
||||
method := s.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
expect := s.ExpectedStatus
|
||||
if expect == 0 {
|
||||
expect = 200
|
||||
}
|
||||
client := &http.Client{Timeout: s.timeout()}
|
||||
if s.Insecure {
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor
|
||||
}
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
res := Result{LatencyMs: msSince(start), Up: true}
|
||||
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||
exp := resp.TLS.PeerCertificates[0].NotAfter
|
||||
res.CertExpiry = &exp
|
||||
}
|
||||
if resp.StatusCode != expect {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
|
||||
}
|
||||
if s.Keyword != "" {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if !strings.Contains(string(body), s.Keyword) {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func runTCP(ctx context.Context, s Spec) Result {
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
conn.Close()
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
|
||||
func runTLS(ctx context.Context, s Spec) Result {
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
|
||||
}
|
||||
exp := certs[0].NotAfter
|
||||
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
|
||||
warn := s.TLSWarnDays
|
||||
if warn <= 0 {
|
||||
warn = 14
|
||||
}
|
||||
remaining := time.Until(exp)
|
||||
if remaining <= 0 {
|
||||
res.Message = "certificate expired"
|
||||
return res
|
||||
}
|
||||
if remaining <= time.Duration(warn)*24*time.Hour {
|
||||
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
|
||||
return res
|
||||
}
|
||||
res.Up = true
|
||||
return res
|
||||
}
|
||||
|
||||
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
|
||||
|
||||
// runICMP sends a single ICMP echo request and waits for the reply. Requires
|
||||
// raw-socket privileges (the agent and server run as root). Returns down with a
|
||||
// descriptive message when the socket cannot be opened or no reply arrives.
|
||||
func runICMP(ctx context.Context, s Spec) Result {
|
||||
dst, err := net.ResolveIPAddr("ip4", s.Host)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err != nil {
|
||||
return Result{Message: "icmp socket: " + err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
id := os.Getpid() & 0xffff
|
||||
pkt := icmpEcho(id, 1)
|
||||
deadline := time.Now().Add(s.timeout())
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.WriteTo(pkt, dst); err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
reply := make([]byte, 1500)
|
||||
for {
|
||||
n, peer, err := conn.ReadFrom(reply)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: "no reply"}
|
||||
}
|
||||
// Skip the IPv4 header (20 bytes) to reach the ICMP message.
|
||||
if n < 28 || peer.String() != dst.String() {
|
||||
continue
|
||||
}
|
||||
if reply[20] == 0 { // ICMP echo reply type
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func icmpEcho(id, seq int) []byte {
|
||||
// Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload.
|
||||
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
|
||||
cs := icmpChecksum(b)
|
||||
b[2] = byte(cs >> 8)
|
||||
b[3] = byte(cs)
|
||||
return b
|
||||
}
|
||||
|
||||
func icmpChecksum(b []byte) uint16 {
|
||||
var sum uint32
|
||||
for i := 0; i < len(b)-1; i += 2 {
|
||||
sum += uint32(b[i])<<8 | uint32(b[i+1])
|
||||
}
|
||||
if len(b)%2 == 1 {
|
||||
sum += uint32(b[len(b)-1]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
@@ -35,10 +35,21 @@ func (w *streamWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// WorkspacePath returns the per-run working directory for a workspace id. The
|
||||
// same id always maps to the same path so RunStep and the cleanup command agree.
|
||||
func WorkspacePath(workspaceID string) string {
|
||||
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
|
||||
}
|
||||
|
||||
// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for
|
||||
// the script to append KEY=value output to, executes it under the requested
|
||||
// interpreter, and streams output via emit, returning the terminal result
|
||||
// with empty stdout/stderr but populated exit_code/output_env.
|
||||
//
|
||||
// When the command carries a WorkspaceId the step runs with that per-run working
|
||||
// directory as its cwd (created here if missing); the server removes it once the
|
||||
// run finishes. The script and env files always live in a private temp dir so
|
||||
// they never leak into the shared workspace.
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
|
||||
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
|
||||
|
||||
@@ -50,6 +61,16 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
workDir := ""
|
||||
if cmd.WorkspaceId != "" {
|
||||
workDir = WorkspacePath(cmd.WorkspaceId)
|
||||
if err := os.MkdirAll(workDir, 0700); err != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "create workspace: " + err.Error()
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
envFile := filepath.Join(dir, "workflow_env")
|
||||
if err := os.WriteFile(envFile, nil, 0600); err != nil {
|
||||
res.ExitCode = 1
|
||||
@@ -91,6 +112,10 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
|
||||
c = exec.CommandContext(ctx, "bash", scriptPath)
|
||||
}
|
||||
|
||||
if workDir != "" {
|
||||
c.Dir = workDir
|
||||
}
|
||||
|
||||
c.Env = append(os.Environ(), "WORKFLOW_ENV="+envFile)
|
||||
for k, v := range cmd.Env {
|
||||
c.Env = append(c.Env, k+"="+v)
|
||||
|
||||
@@ -126,6 +126,30 @@ func (c *Client) ReportUpdates(serverID, agentToken string, updates []pb.Package
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := c.client.ReportInventory(ctx, report)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SyncMonitors(serverID, agentToken string) ([]pb.MonitorSpec, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
resp, err := c.client.SyncMonitors(ctx, &pb.SyncMonitorsRequest{ServerId: serverID, AgentToken: agentToken})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Monitors, nil
|
||||
}
|
||||
|
||||
func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckResult) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := c.client.ReportChecks(ctx, &pb.ReportChecksRequest{ServerId: serverID, AgentToken: agentToken, Results: results})
|
||||
return err
|
||||
}
|
||||
|
||||
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
|
||||
// The caller controls the stream lifetime via ctx.
|
||||
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
|
||||
|
||||
@@ -60,15 +60,91 @@ type ReportUpdatesRequest struct {
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
// Inventory report message types
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
// Monitor sync / check report message types
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ExpectedStatus int `json:"expected_status,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
TLSWarnDays int `json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
IntervalSec int `json:"interval_sec"`
|
||||
Retries int `json:"retries"`
|
||||
}
|
||||
type SyncMonitorsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Results []CheckResult `json:"results,omitempty"`
|
||||
}
|
||||
type ReportChecksResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -110,6 +186,9 @@ type RunStepCmd struct {
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
// WorkspaceId names the per-run working directory the agent creates and uses
|
||||
// as the step's cwd. Empty means run in the agent's default directory.
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
@@ -180,6 +259,9 @@ type VantageClient interface {
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
@@ -235,6 +317,30 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
|
||||
out := new(SyncMonitorsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
|
||||
out := new(ReportChecksResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
|
||||
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//go:build linux
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {
|
||||
r.CPU.UsagePct = cpuUsage()
|
||||
r.CPU.Load1 = load1()
|
||||
memTotal, memAvail, swapTotal, swapFree := meminfo()
|
||||
if memTotal > memAvail {
|
||||
r.Memory.UsedBytes = memTotal - memAvail
|
||||
}
|
||||
if swapTotal > swapFree {
|
||||
r.SwapUsed = swapTotal - swapFree
|
||||
}
|
||||
if includeStatic {
|
||||
r.Memory.TotalBytes = memTotal
|
||||
r.SwapTotal = swapTotal
|
||||
r.CPU.Model, r.CPU.Cores = cpuStatic()
|
||||
r.Kernel = kernel()
|
||||
r.Partitions = partitions()
|
||||
}
|
||||
}
|
||||
|
||||
func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
|
||||
|
||||
func cpuSample() (idle, total uint64) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
if sc.Scan() {
|
||||
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
|
||||
for i, v := range fields[1:] {
|
||||
n, _ := strconv.ParseUint(v, 10, 64)
|
||||
total += n
|
||||
if i == 3 { // idle
|
||||
idle = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuUsage() float64 {
|
||||
i1, t1 := cpuSample()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
i2, t2 := cpuSample()
|
||||
dt := float64(t2 - t1)
|
||||
if dt <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (1 - float64(i2-i1)/dt) * 100
|
||||
}
|
||||
|
||||
func load1() float64 {
|
||||
fields := strings.Fields(readProc("/proc/loadavg"))
|
||||
if len(fields) > 0 {
|
||||
v, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func meminfo() (total, avail, swapTotal, swapFree uint64) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
kb, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
b := kb * 1024
|
||||
switch strings.TrimSuffix(fields[0], ":") {
|
||||
case "MemTotal":
|
||||
total = b
|
||||
case "MemAvailable":
|
||||
avail = b
|
||||
case "SwapTotal":
|
||||
swapTotal = b
|
||||
case "SwapFree":
|
||||
swapFree = b
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cpuStatic() (model string, cores int) {
|
||||
f, err := os.Open("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "processor") {
|
||||
cores++
|
||||
} else if strings.HasPrefix(line, "model name") && model == "" {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
model = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func kernel() string {
|
||||
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
|
||||
}
|
||||
|
||||
func partitions() []pb.PartitionReport {
|
||||
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
var out []pb.PartitionReport
|
||||
seen := map[string]bool{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
|
||||
continue
|
||||
}
|
||||
seen[fields[1]] = true
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs(fields[1], &st) != nil {
|
||||
continue
|
||||
}
|
||||
total := st.Blocks * uint64(st.Bsize)
|
||||
free := st.Bavail * uint64(st.Bsize)
|
||||
out = append(out, pb.PartitionReport{
|
||||
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
|
||||
TotalBytes: total, UsedBytes: total - free,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// collect is a no-op best-effort stub on non-Linux platforms.
|
||||
func collect(r *pb.InventoryReport, includeStatic bool) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package inventory
|
||||
|
||||
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
|
||||
// Collect gathers metrics always and static hardware info when includeStatic.
|
||||
// Platform specifics are provided by collect_linux.go / collect_other.go.
|
||||
func Collect(includeStatic bool) *pb.InventoryReport {
|
||||
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
|
||||
collect(r, includeStatic)
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package monitors runs agent-side service checks. It polls the server for the
|
||||
// monitors assigned to this agent (SyncMonitors), runs each on its own interval
|
||||
// using the local checker package, and reports results back (ReportChecks).
|
||||
package monitors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/agent/internal/checker"
|
||||
"github.com/mrhid6/vantage/agent/internal/config"
|
||||
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
)
|
||||
|
||||
// syncInterval controls how often the agent re-fetches its assigned monitors.
|
||||
const syncInterval = 30 * time.Second
|
||||
|
||||
type runner struct {
|
||||
intervalSec int
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Run starts the agent monitor loop and blocks until ctx is cancelled.
|
||||
func Run(ctx context.Context, cfg *config.Config) {
|
||||
active := map[string]*runner{}
|
||||
var mu sync.Mutex
|
||||
|
||||
// results is a shared channel every check writes to; a single reporter
|
||||
// goroutine batches and ships them so we make one ReportChecks call per tick.
|
||||
results := make(chan pb.CheckResult, 64)
|
||||
go reporter(ctx, cfg, results)
|
||||
|
||||
syncOnce := func() {
|
||||
specs, err := fetchSpecs(cfg)
|
||||
if err != nil {
|
||||
log.Printf("monitors: sync: %v", err)
|
||||
return
|
||||
}
|
||||
want := map[string]pb.MonitorSpec{}
|
||||
for _, s := range specs {
|
||||
want[s.MonitorId] = s
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for id, r := range active {
|
||||
s, ok := want[id]
|
||||
if !ok || s.IntervalSec != r.intervalSec {
|
||||
r.cancel()
|
||||
delete(active, id)
|
||||
}
|
||||
}
|
||||
for id, s := range want {
|
||||
if _, ok := active[id]; ok {
|
||||
continue
|
||||
}
|
||||
rctx, cancel := context.WithCancel(ctx)
|
||||
active[id] = &runner{intervalSec: s.IntervalSec, cancel: cancel}
|
||||
go runSpec(rctx, s, results)
|
||||
}
|
||||
}
|
||||
|
||||
syncOnce()
|
||||
t := time.NewTicker(syncInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
syncOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSpecs(cfg *config.Config) ([]pb.MonitorSpec, error) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
return client.SyncMonitors(cfg.ServerID, cfg.AgentToken)
|
||||
}
|
||||
|
||||
func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
|
||||
interval := time.Duration(s.IntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 60 * time.Second
|
||||
}
|
||||
spec := checker.Spec{
|
||||
Type: s.Type,
|
||||
URL: s.URL,
|
||||
Host: s.Host,
|
||||
Port: s.Port,
|
||||
Method: s.Method,
|
||||
ExpectedStatus: s.ExpectedStatus,
|
||||
Keyword: s.Keyword,
|
||||
TLSWarnDays: s.TLSWarnDays,
|
||||
Insecure: s.Insecure,
|
||||
TimeoutSec: s.IntervalSec,
|
||||
}
|
||||
|
||||
run := func() {
|
||||
res := checker.Run(ctx, spec)
|
||||
cr := pb.CheckResult{MonitorId: s.MonitorId, Up: res.Up, LatencyMs: res.LatencyMs, Message: res.Message}
|
||||
if res.CertExpiry != nil {
|
||||
cr.CertExpiryUnix = res.CertExpiry.Unix()
|
||||
}
|
||||
select {
|
||||
case out <- cr:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reporter batches results on a short interval and ships each batch in one call.
|
||||
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
var batch []pb.CheckResult
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("monitors: report dial: %v", err)
|
||||
batch = nil
|
||||
return
|
||||
}
|
||||
if err := client.ReportChecks(cfg.ServerID, cfg.AgentToken, batch); err != nil {
|
||||
log.Printf("monitors: report: %v", err)
|
||||
}
|
||||
client.Close()
|
||||
batch = nil
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
flush()
|
||||
return
|
||||
case r := <-in:
|
||||
batch = append(batch, r)
|
||||
if len(batch) >= 32 {
|
||||
flush()
|
||||
}
|
||||
case <-t.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
agentexec "github.com/mrhid6/vantage/agent/internal/exec"
|
||||
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
|
||||
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/agent/internal/inventory"
|
||||
"github.com/mrhid6/vantage/agent/internal/keys"
|
||||
"github.com/mrhid6/vantage/agent/internal/monitors"
|
||||
"github.com/mrhid6/vantage/agent/internal/updates"
|
||||
)
|
||||
|
||||
@@ -68,6 +70,12 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
|
||||
// Check for OS updates on startup and then hourly.
|
||||
go runUpdateCheck(ctx, cfg)
|
||||
|
||||
// Report host inventory: metrics every 30s, full static snapshot every 15 min.
|
||||
go runInventory(ctx, cfg)
|
||||
|
||||
// Run agent-side service monitors assigned to this server.
|
||||
go monitors.Run(ctx, cfg)
|
||||
|
||||
ticker := time.NewTicker(cfg.PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -198,6 +206,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
|
||||
if cmd.ApplyUpdates != nil {
|
||||
go handleApplyUpdates(cfg, cmd)
|
||||
}
|
||||
if cmd.CleanupWorkspace != nil {
|
||||
go handleCleanupWorkspace(cmd)
|
||||
}
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
@@ -269,6 +280,40 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// runInventory reports host metrics every 30s and a full static snapshot every
|
||||
// 15 min (and once immediately on startup so static fields populate without delay).
|
||||
func runInventory(ctx context.Context, cfg *config.Config) {
|
||||
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
|
||||
if err != nil {
|
||||
log.Printf("inventory dial error: %v", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
report := func(static bool) {
|
||||
r := inventory.Collect(static)
|
||||
r.ServerId = cfg.ServerID
|
||||
r.AgentToken = cfg.AgentToken
|
||||
if err := client.ReportInventory(r); err != nil {
|
||||
log.Printf("report inventory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
report(true) // full snapshot on startup
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
tick := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
tick++
|
||||
report(tick%30 == 0) // every 30th tick = 15 min → include static
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId)
|
||||
if err := updates.ApplyAll(); err != nil {
|
||||
@@ -286,6 +331,16 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
|
||||
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
|
||||
}
|
||||
|
||||
func handleCleanupWorkspace(cmd *pb.ServerCommand) {
|
||||
id := cmd.CleanupWorkspace.WorkspaceId
|
||||
dir := agentexec.WorkspacePath(id)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
log.Printf("cleanup workspace %s failed (cmd=%s): %v", dir, cmd.CommandId, err)
|
||||
return
|
||||
}
|
||||
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
|
||||
}
|
||||
|
||||
func handleDeleteKey(cmd *pb.ServerCommand) {
|
||||
label := cmd.DeleteKey.Label
|
||||
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
|
||||
|
||||
@@ -36,10 +36,13 @@ services:
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
|
||||
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
|
||||
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
|
||||
GUACD_ADDR: guacd:4822
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./data:/data
|
||||
web:
|
||||
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
|
||||
restart: unless-stopped
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -645,9 +645,222 @@ git commit -m "fix: fleet inventory verification fixes"
|
||||
|
||||
---
|
||||
|
||||
# Service Monitoring (uptime-kuma replacement)
|
||||
|
||||
Extends the fleet work: in-app service monitors replacing uptime-kuma. Monitors (HTTP/TCP/ICMP/TLS) run **server-side** (public endpoints) or **agent-side** (agent probes its own host). Both runners feed one server-side ingest pipeline: state → incidents → rollups → notifications.
|
||||
|
||||
**Design:** validated in brainstorm 2026-07-21. Hybrid runners, all 4 check types, latest+incidents+rollups history, multi-channel notify (webhook/SMTP/Discord/Slack/Telegram), dedicated `SyncMonitors`/`ReportChecks` RPCs.
|
||||
|
||||
**Build order — 3 phases, each shippable:**
|
||||
- **P1 (Tasks 7–10):** data model, checker pkg, server scheduler, ingest pipeline, `/monitors` UI. Server-run only. No agent, no notify.
|
||||
- **P2 (Tasks 11–12):** `SyncMonitors` + `ReportChecks` RPCs, agent checker + scheduler, agent-run monitors bound to a server.
|
||||
- **P3 (Tasks 13–14):** notification channels + dispatch + settings UI.
|
||||
|
||||
## Monitoring Global Constraints
|
||||
|
||||
- Same as fleet: no tests this iteration; verify with `go build ./...`, `go vet ./...`, `npm run build`. JSON-codec gRPC — edit both pb files identically, mirror `ReportUpdates` wiring. Separate Go modules, so the checker pkg is **duplicated** in `server/` and `agent/` (same convention as pb files).
|
||||
- Reuse existing patterns: REST handlers like `server/internal/api`, services like `server/internal/services/servers.go`, `db.Col(...)`, react-query + Tailwind UI like `web/app/servers`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Monitoring data model + checker package (server)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/models/monitor.go`
|
||||
- Create: `server/internal/checker/checker.go` (+ `http.go`, `tcp.go`, `icmp.go`, `tls.go`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.Monitor` (+ `MonitorState`, `MonitorTarget`), `models.Incident`, `models.Rollup`. `checker.Run(ctx, models.Monitor) checker.Result` where `Result{Up bool; LatencyMs int; Message string; CertExpiry *time.Time}`.
|
||||
|
||||
- [ ] **Step 1: Model**
|
||||
|
||||
```go
|
||||
type MonitorTarget struct {
|
||||
URL string `bson:"url,omitempty" json:"url,omitempty"`
|
||||
Host string `bson:"host,omitempty" json:"host,omitempty"`
|
||||
Port int `bson:"port,omitempty" json:"port,omitempty"`
|
||||
Method string `bson:"method,omitempty" json:"method,omitempty"`
|
||||
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
|
||||
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
|
||||
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
|
||||
}
|
||||
type MonitorState struct {
|
||||
Status string `bson:"status" json:"status"` // up|down|pending
|
||||
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
|
||||
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
|
||||
Message string `bson:"message,omitempty" json:"message,omitempty"`
|
||||
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
|
||||
Fails int `bson:"fails" json:"fails"` // consecutive failures
|
||||
}
|
||||
type Monitor struct {
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
|
||||
Target MonitorTarget `bson:"target" json:"target"`
|
||||
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
|
||||
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
|
||||
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
|
||||
State MonitorState `bson:"state" json:"state"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
type Incident struct {
|
||||
IncidentID string `bson:"incident_id" json:"incident_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
|
||||
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
|
||||
}
|
||||
type Rollup struct {
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
|
||||
Checks int `bson:"checks" json:"checks"`
|
||||
UpCount int `bson:"up_count" json:"up_count"`
|
||||
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Checker package** — `Run(ctx, m)` switches on `m.Type`:
|
||||
- **http**: `http.Client` GET/HEAD `m.Target.URL`, assert status == ExpectedStatus (default 200), optional `Keyword` body contains; capture TLS peer cert expiry when https.
|
||||
- **tcp**: `net.DialTimeout("tcp", host:port)`, latency = dial time.
|
||||
- **icmp**: raw ICMP echo (agent/server run as root). Fall back to `net.Dial("ip4:icmp")`; on permission error return down with message.
|
||||
- **tls**: `tls.Dial`, read `ConnectionState().PeerCertificates[0].NotAfter` → `CertExpiry`; down if within `TLSWarnDays` or expired.
|
||||
- All: wrap with per-check timeout (min(IntervalSec, 10s)); `Result.Message` = short reason on failure.
|
||||
|
||||
- [ ] **Step 3: Verify build** — `cd server && go build ./... && go vet ./...`
|
||||
|
||||
- [ ] **Step 4: Commit** — `feat(server): monitor model + checker package`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Ingest pipeline + rollups service
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/services/monitors.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `IngestResult(monitorID string, res checker.Result) error` — the single entry both runners use. `ListMonitors`, `GetMonitor`, `CreateMonitor`, `UpdateMonitor`, `DeleteMonitor`, `ListIncidents(monitorID)`, `UptimeRollups(monitorID, since)`.
|
||||
|
||||
- [ ] **Step 1: `IngestResult`** — load monitor; compute new status with `Retries` threshold (increment `state.Fails` on failure, flip to `down` only when `Fails >= Retries`; reset + flip `up` on success). On **transition**: open incident (`down`) or resolve open incident (`up`), and enqueue notification (P3 — leave a `// TODO(P3): dispatch` hook now). Always `$set` state fields. Upsert current-hour `Rollup` (`$inc` checks/up_count/sum_latency). Use `db.Col("monitors")`, `db.Col("incidents")`, `db.Col("monitor_rollups")`, `context.WithTimeout`.
|
||||
|
||||
- [ ] **Step 2: CRUD + queries** — standard service funcs mirroring `services/servers.go`. `UptimeRollups` aggregates buckets since a cutoff → uptime % + avg latency series.
|
||||
|
||||
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
|
||||
|
||||
- [ ] **Step 4: Commit** — `feat(server): monitor ingest pipeline, incidents, rollups`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Server scheduler + REST API
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/monitorsched/scheduler.go`
|
||||
- Create: `server/internal/api/monitors.go`
|
||||
- Modify: server bootstrap (wherever services/gRPC start) to launch the scheduler; router registration where `api` routes are mounted.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a scheduler that ticks enabled `runner=="server"` monitors on their `IntervalSec` and calls `checker.Run` → `services.IngestResult`. REST: `GET/POST /api/monitors`, `GET/PUT/DELETE /api/monitors/:id`, `GET /api/monitors/:id/incidents`, `GET /api/monitors/:id/uptime`.
|
||||
|
||||
- [ ] **Step 1: Scheduler** — on boot load monitors; per-monitor goroutine or a min-heap wheel keyed on next-run. Only `runner=="server"`. Reload on CRUD (simplest: re-read every N sec, or a reload channel fired by the service). Skip disabled.
|
||||
|
||||
- [ ] **Step 2: REST handlers** — mirror an existing `server/internal/api` handler file for style + auth middleware. JSON in/out of `models.Monitor`.
|
||||
|
||||
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
|
||||
|
||||
- [ ] **Step 4: Commit** — `feat(server): server-run monitor scheduler + REST API`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend — monitors UI (P1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts` (Monitor types + bindings)
|
||||
- Create: `web/app/monitors/page.tsx` (list), `web/app/monitors/[id]/page.tsx` (detail), `web/app/monitors/new/page.tsx` (create/edit form)
|
||||
- Modify: main nav to add **Monitors** (same place Steps was added)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/monitors*` (T9).
|
||||
|
||||
- [ ] **Step 1: Types + api bindings** — `Monitor`, `MonitorState`, `Incident`, uptime series; `api.monitors.list/get/create/update/remove/incidents/uptime`.
|
||||
- [ ] **Step 2: List page** — table: name, type, status badge (up/down/pending), uptime % (24h), latency, last check. `refetchInterval: 30000`.
|
||||
- [ ] **Step 3: Detail page** — status header, heartbeat/uptime bars (24h + 30d from rollups), latency chart, incident timeline, cert expiry, assigned channels (read-only until P3).
|
||||
- [ ] **Step 4: Create/edit form** — type-dependent fields (URL vs host/port), interval, retries, runner select (`server` or a registered server for agent-run — server option only wired in P2), enabled.
|
||||
- [ ] **Step 5: Verify build** — `cd web && npm run build`
|
||||
- [ ] **Step 6: Commit** — `feat(web): monitors list/detail/form UI`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: SyncMonitors + ReportChecks RPCs (P2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `proto/vantage/v1/vantage.proto`, `server/internal/grpc/pb/vantage.pb.go`, `agent/internal/grpc/pb/vantage.pb.go`, `server/internal/grpc/server.go`, `agent/internal/grpc/client.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `SyncMonitors(server_id, agent_token) -> repeated MonitorSpec`; `ReportChecks(server_id, agent_token, repeated CheckResult) -> ReportChecksResponse`. `MonitorSpec{monitor_id, type, target fields, interval_sec, retries}`. `CheckResult{monitor_id, up, latency_ms, message, cert_expiry_unix}`.
|
||||
|
||||
- [ ] **Step 1: pb structs + proto** — add messages to both pb files + proto doc.
|
||||
- [ ] **Step 2: Wire both RPCs** — mirror `ReportUpdates` plumbing (interface, Unimplemented stub, client method, `Vantage_ServiceDesc.Methods`, `_Vantage_*_Handler`) in both pb files. Server handlers on `vantageServer` (after `ReportUpdates` at server.go:78): `SyncMonitors` returns monitors where `runner==req.ServerId && enabled`; `ReportChecks` validates token then loops `services.IngestResult`. Client methods on `*Client` in client.go (after `ReportUpdates` at client.go:117).
|
||||
- [ ] **Step 3: Verify build** — both modules `go build ./... && go vet ./...`
|
||||
- [ ] **Step 4: Commit** — `feat(proto): SyncMonitors + ReportChecks RPCs`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Agent checker + scheduler (P2)
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/internal/checker/` (duplicate of server checker pkg)
|
||||
- Create: `agent/internal/monitors/monitors.go` (poll + run + report loop)
|
||||
- Modify: agent main loop to start it (alongside the sync loop in `agent/internal/sync` / the inventory ticker from Task 4)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `client.SyncMonitors`, `client.ReportChecks`, agent `checker`.
|
||||
|
||||
- [ ] **Step 1: Duplicate checker pkg** into agent module (identical logic; imports agent pb).
|
||||
- [ ] **Step 2: Monitor loop** — poll `SyncMonitors` every 30s for assigned specs; per-spec ticker on `IntervalSec` runs `checker.Run`; batch `CheckResult`s and `ReportChecks`. `serverID`/`agentToken`/`*Client` in scope from the existing loop.
|
||||
- [ ] **Step 3: Verify build** — `cd agent && go build ./... && go vet ./...` (+ `GOOS=windows go build ./...`; icmp may no-op on Windows).
|
||||
- [ ] **Step 4: Commit** — `feat(agent): agent-run monitor scheduler`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Notification channels + dispatch (P3)
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/models/channel.go`, `server/internal/services/channels.go`, `server/internal/notify/` (`dispatch.go`, `webhook.go`, `smtp.go`, `discord.go`, `slack.go`, `telegram.go`), `server/internal/api/channels.go`
|
||||
- Modify: `server/internal/services/monitors.go` (replace the P2 `// TODO(P3): dispatch` hook)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `models.NotificationChannel{channel_id, name, type, config map, enabled}`. `notify.Dispatch(channel, event)` where `event` = monitor + old/new status + message. `notify.Test(channel)`.
|
||||
|
||||
- [ ] **Step 1: Model + CRUD service + REST** (`/api/channels*`, incl. `POST /api/channels/:id/test`).
|
||||
- [ ] **Step 2: Dispatch abstraction** — webhook/discord/slack/telegram are HTTP POST with per-type JSON payload; SMTP via `net/smtp`. Per-monitor routing via `monitor.ChannelIDs`; resend interval so an ongoing `down` re-alerts at most every N min (track `last_notified_at` on monitor state).
|
||||
- [ ] **Step 3: Fire on transition** — in `IngestResult`, on up/down flip resolve channels and `notify.Dispatch` each (goroutine, best-effort, log failures).
|
||||
- [ ] **Step 4: Verify build** — `go build ./... && go vet ./...`
|
||||
- [ ] **Step 5: Commit** — `feat(server): multi-channel monitor notifications`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Frontend — notification settings (P3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts` (channel types + bindings), `web/app/settings/` (add notifications section/page)
|
||||
- Modify: monitor create/edit form (Task 10) to select channels
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/channels*`.
|
||||
|
||||
- [ ] **Step 1: Channel types + api bindings.**
|
||||
- [ ] **Step 2: Settings UI** — list/add/edit channels, type-dependent config fields, **Test** button hitting `/api/channels/:id/test`.
|
||||
- [ ] **Step 3: Wire channel multi-select** into the monitor form.
|
||||
- [ ] **Step 4: Verify build** — `cd web && npm run build`
|
||||
- [ ] **Step 5: Commit** — `feat(web): notification channel settings UI`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints.
|
||||
- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min.
|
||||
- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`).
|
||||
- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges.
|
||||
- **Monitoring (Tasks 7–14):** hybrid runner service-monitor replacing uptime-kuma, added 2026-07-21. 3 phases — P1 server-run engine+UI (T7–10), P2 agent-run RPCs (T11–12), P3 multi-channel notify (T13–14). Single `IngestResult` pipeline for both runners; checker pkg duplicated per module (pb convention). Design: brainstorm 2026-07-21. Follow-ups out of scope: status pages, maintenance windows, per-check auth headers, ICMP on Windows.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,887 +0,0 @@
|
||||
# Workflow Log Streaming Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stream workflow step output live from agents to per-server-run log files on the server, tail them live in the UI over SSE, and auto-expire them on a configurable retention period.
|
||||
|
||||
**Architecture:** Agent streams interleaved stdout/stderr chunks over the existing `CommandStream` (`AgentMessage.StepOutput`). Server appends secret-masked chunks to `<logdir>/<run_id>/<server_id>.log` via a per-command log-writer registry, records a per-step byte offset, and stops persisting log bodies in Mongo. UI tails via an SSE endpoint while running and fetches the whole file after. An hourly sweeper deletes run-log dirs older than the retention setting.
|
||||
|
||||
**Tech Stack:** Go (gin, mongo-driver v2), hand-written JSON-codec gRPC structs (no protoc), Next.js 16 app-router + react-query + EventSource, MongoDB, local filesystem for logs.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **No tests this iteration** — do not write `*_test.go` or frontend tests. Verify each task with `go build ./...`, `go vet ./...`, and (frontend) `npm run build`.
|
||||
- gRPC uses a **JSON codec** — proto messages are hand-written Go structs in **two** files that must stay identical: `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`. No codegen. Also update `proto/vantage/v1/vantage.proto` as documentation.
|
||||
- Mongo access pattern: `db.Col("collection_name")` with `context.WithTimeout`. Follow `server/internal/services/workflows.go`.
|
||||
- Secret values must never be written into log files unmasked — mask by literal `***` replacement at write time, boundary-safe via a carry buffer.
|
||||
- Interpreter values are the literals `"bash"` and `"powershell"`.
|
||||
- Go module path: `github.com/mrhid6/vantage`.
|
||||
- Log dir from env `VANTAGE_WORKFLOW_LOG_DIR`, default `<data>/workflow-logs`; files `0600`, dirs `0700`.
|
||||
- Retention default **30** days, stored `settings.workflow_log_retention_days`; `0`/negative = keep forever.
|
||||
- The agent's stream `Send` is only safe through the existing per-connection mutex-guarded `send()` closure in `connectAndHandleStream` — all `StepOutput`/`StepResult` sends MUST go through it.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Proto/pb — StepOutputChunk
|
||||
|
||||
**Files:**
|
||||
- Modify: `proto/vantage/v1/vantage.proto`
|
||||
- Modify: `server/internal/grpc/pb/vantage.pb.go`
|
||||
- Modify: `agent/internal/grpc/pb/vantage.pb.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `pb.StepOutputChunk{CommandId string, Seq uint64, Data []byte, Eof bool}`; `pb.AgentMessage` gains `StepOutput *StepOutputChunk`.
|
||||
|
||||
- [ ] **Step 1: Document in the proto file**
|
||||
|
||||
In `proto/vantage/v1/vantage.proto`, add to the `AgentMessage` oneof: `StepOutputChunk step_output = 6;` and add the message:
|
||||
|
||||
```protobuf
|
||||
message StepOutputChunk {
|
||||
string command_id = 1;
|
||||
uint64 seq = 2;
|
||||
bytes data = 3;
|
||||
bool eof = 4;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add struct + field to server pb file**
|
||||
|
||||
In `server/internal/grpc/pb/vantage.pb.go`, add to `type AgentMessage struct { ... }`:
|
||||
|
||||
```go
|
||||
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
|
||||
```
|
||||
|
||||
and add the new struct:
|
||||
|
||||
```go
|
||||
type StepOutputChunk struct {
|
||||
CommandId string `json:"command_id"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Eof bool `json:"eof,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Mirror identical additions into the agent pb file**
|
||||
|
||||
Apply the identical `AgentMessage.StepOutput` field and `StepOutputChunk` struct to `agent/internal/grpc/pb/vantage.pb.go`.
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && cd ../agent && go build ./...`
|
||||
Expected: both succeed.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
|
||||
git commit -m "feat(proto): add StepOutputChunk streaming message"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Agent — stream step output
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/internal/exec/exec.go`
|
||||
- Modify: `agent/internal/sync/sync.go` (the `cmd.RunStep != nil` goroutine)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pb.RunStepCmd`, `pb.StepResult`, `pb.StepOutputChunk` (Task 1).
|
||||
- Produces: `exec.RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult` — streams output via `emit`, returns terminal result with empty stdout/stderr but populated exit_code/output_env.
|
||||
|
||||
- [ ] **Step 1: Rework `exec.RunStep` to stream**
|
||||
|
||||
In `agent/internal/exec/exec.go`, change the signature and replace the two `bytes.Buffer`s with a single mutex-guarded streaming writer. Full new body of the run/capture section (keep the existing temp-dir, env-file, interpreter-selection, timeout, and `parseEnvFile` logic exactly as-is):
|
||||
|
||||
Add this type at package scope:
|
||||
|
||||
```go
|
||||
// streamWriter forwards every write to emit() as an ordered chunk. Used as both
|
||||
// Stdout and Stderr so output interleaves in real execution order. The mutex
|
||||
// ensures a single stdout/stderr write is not interleaved mid-slice with another.
|
||||
type streamWriter struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
emit func(seq uint64, data []byte)
|
||||
}
|
||||
|
||||
func (w *streamWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.emit != nil {
|
||||
buf := make([]byte, len(p))
|
||||
copy(buf, p)
|
||||
w.emit(w.seq, buf)
|
||||
w.seq++
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
```
|
||||
|
||||
Add `"sync"` to the imports. Change the signature to:
|
||||
|
||||
```go
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
|
||||
```
|
||||
|
||||
Replace the block that currently declares `var stdout, stderr bytes.Buffer`, assigns `c.Stdout`/`c.Stderr`, and sets `res.Stdout`/`res.Stderr` from them, with:
|
||||
|
||||
```go
|
||||
sw := &streamWriter{emit: emit}
|
||||
c.Stdout = sw
|
||||
c.Stderr = sw
|
||||
runErr := c.Run()
|
||||
|
||||
// stdout/stderr are streamed via emit, not returned in the result.
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.ExitCode = 124
|
||||
res.Stderr = "[vantage] step timed out"
|
||||
} else if ee, ok := runErr.(*exec.ExitError); ok {
|
||||
res.ExitCode = ee.ExitCode()
|
||||
} else if runErr != nil {
|
||||
res.ExitCode = 1
|
||||
res.Stderr = "[vantage] " + runErr.Error()
|
||||
}
|
||||
|
||||
res.OutputEnv = parseEnvFile(envFile)
|
||||
return res
|
||||
```
|
||||
|
||||
Remove the now-unused `"bytes"` and `"bufio"` imports **only if** they are no longer referenced (`parseEnvFile` uses `bufio` + `os` — keep `bufio`; `bytes` is likely now unused — remove it if so). Verify with `go build`.
|
||||
|
||||
- [ ] **Step 2: Wire streaming into the agent loop**
|
||||
|
||||
In `agent/internal/sync/sync.go`, the `cmd.RunStep != nil` goroutine currently calls `agentexec.RunStep(rc)` and sends one `StepResult` via `send()`. Change it to pass an `emit` closure that streams chunks, then send an eof chunk, then the terminal result — all through the existing mutex-guarded `send()`:
|
||||
|
||||
```go
|
||||
if cmd.RunStep != nil {
|
||||
go func(rc *pb.RunStepCmd, cid string) {
|
||||
emit := func(seq uint64, data []byte) {
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepOutput: &pb.StepOutputChunk{CommandId: cid, Seq: seq, Data: data},
|
||||
})
|
||||
}
|
||||
res := agentexec.RunStep(rc, emit)
|
||||
res.CommandId = cid
|
||||
// Final eof marker so the server closes the log file.
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepOutput: &pb.StepOutputChunk{CommandId: cid, Eof: true},
|
||||
})
|
||||
_ = send(&pb.AgentMessage{
|
||||
ServerId: cfg.ServerID,
|
||||
AgentToken: cfg.AgentToken,
|
||||
StepResult: res,
|
||||
})
|
||||
}(cmd.RunStep, cmd.CommandId)
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
(Match the exact field names already used by the existing `send()` calls in this function — `cfg.ServerID`, `cfg.AgentToken`, and the `send` closure. If the existing RunStep branch used different local names, keep those.)
|
||||
|
||||
- [ ] **Step 3: Verify build**
|
||||
|
||||
Run: `cd agent && go build ./... && go vet ./...`
|
||||
Expected: success. Resolve any leftover unused-import error from Step 1.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add agent/internal/exec/exec.go agent/internal/sync/sync.go
|
||||
git commit -m "feat(agent): stream step output chunks over CommandStream"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Server log-writer registry + retention sweeper
|
||||
|
||||
**Files:**
|
||||
- Create: `server/internal/services/steplogs.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `settings` service (retention), `db.Col("workflow_runs")` (sweeper), env `VANTAGE_WORKFLOW_LOG_DIR`.
|
||||
- Produces:
|
||||
- `WorkflowLogDir() string` — resolved base dir (env or default), created on first call.
|
||||
- `ServerRunLogPath(runID, serverID string) string` — `<logdir>/<runID>/<serverID>.log`.
|
||||
- `AppendMarker(runID, serverID, line string) (int64, error)` — appends a marker line, returns the byte offset **before** the write (the step's `log_offset`).
|
||||
- `var StepLogs *stepLogRegistry` with `Open(commandID, path string, secrets []string) error`, `Append(commandID string, data []byte)`, `Close(commandID string)`.
|
||||
- `StartLogSweeper()` — launches the hourly retention goroutine; also sweeps once immediately.
|
||||
|
||||
- [ ] **Step 1: Write the registry, paths, and sweeper**
|
||||
|
||||
```go
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// WorkflowLogDir returns the base directory for workflow step logs, creating it.
|
||||
func WorkflowLogDir() string {
|
||||
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "workflow-logs")
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
return dir
|
||||
}
|
||||
|
||||
// ServerRunLogPath is the per-server-run log file path.
|
||||
func ServerRunLogPath(runID, serverID string) string {
|
||||
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
|
||||
}
|
||||
|
||||
// AppendMarker appends a line to the server-run log and returns the byte offset
|
||||
// at which the write began (used as a step's log_offset).
|
||||
func AppendMarker(runID, serverID, line string) (int64, error) {
|
||||
path := ServerRunLogPath(runID, serverID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
off, _ := f.Seek(0, 2) // current end = offset before write
|
||||
if _, err := f.WriteString(line); err != nil {
|
||||
return off, err
|
||||
}
|
||||
return off, nil
|
||||
}
|
||||
|
||||
// ---- streamed chunk writer, boundary-safe secret masking ----
|
||||
|
||||
type stepLogWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
carry []byte
|
||||
secrets []string
|
||||
maxSecret int
|
||||
}
|
||||
|
||||
type stepLogRegistry struct {
|
||||
mu sync.Mutex
|
||||
writers map[string]*stepLogWriter
|
||||
}
|
||||
|
||||
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
|
||||
|
||||
// Open opens (append) the server-run file for a step's streamed chunks.
|
||||
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
max := 0
|
||||
for _, s := range secrets {
|
||||
if len(s) > max {
|
||||
max = len(s)
|
||||
}
|
||||
}
|
||||
w := &stepLogWriter{f: f, secrets: secrets, maxSecret: max}
|
||||
r.mu.Lock()
|
||||
r.writers[commandID] = w
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.writers[commandID]
|
||||
}
|
||||
|
||||
// Append masks and writes a chunk, holding back the last maxSecret-1 bytes so a
|
||||
// secret split across a chunk boundary is still masked on the next append/close.
|
||||
func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
w := r.get(commandID)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.secrets) == 0 || w.maxSecret <= 1 {
|
||||
_, _ = w.f.Write(data)
|
||||
return
|
||||
}
|
||||
buf := append(w.carry, data...)
|
||||
hold := w.maxSecret - 1
|
||||
if len(buf) <= hold {
|
||||
w.carry = buf
|
||||
return
|
||||
}
|
||||
flush := buf[:len(buf)-hold]
|
||||
w.carry = append([]byte{}, buf[len(buf)-hold:]...)
|
||||
_, _ = w.f.Write(maskBytes(flush, w.secrets))
|
||||
}
|
||||
|
||||
// Close flushes the carry (masked) and closes the file.
|
||||
func (r *stepLogRegistry) Close(commandID string) {
|
||||
r.mu.Lock()
|
||||
w := r.writers[commandID]
|
||||
delete(r.writers, commandID)
|
||||
r.mu.Unlock()
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.carry) > 0 {
|
||||
_, _ = w.f.Write(maskBytes(w.carry, w.secrets))
|
||||
w.carry = nil
|
||||
}
|
||||
_ = w.f.Close()
|
||||
}
|
||||
|
||||
func maskBytes(b []byte, secrets []string) []byte {
|
||||
s := string(b)
|
||||
for _, v := range secrets {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
s = strings.ReplaceAll(s, v, "***")
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// ---- retention sweeper ----
|
||||
|
||||
// StartLogSweeper sweeps expired run-log dirs hourly (and once now).
|
||||
func StartLogSweeper() {
|
||||
go func() {
|
||||
sweepLogs()
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
sweepLogs()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func sweepLogs() {
|
||||
days := retentionDays()
|
||||
if days <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
base := WorkflowLogDir()
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
runID := e.Name()
|
||||
dir := filepath.Join(base, runID)
|
||||
if runExpired(runID, dir, cutoff) {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runExpired is true when the run finished before cutoff (falling back to dir
|
||||
// mtime when the run doc is gone).
|
||||
func runExpired(runID, dir string, cutoff time.Time) bool {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var run struct {
|
||||
FinishedAt *time.Time `bson:"finished_at"`
|
||||
}
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
|
||||
if err == nil {
|
||||
if run.FinishedAt == nil {
|
||||
return false // still running / never finished — keep
|
||||
}
|
||||
return run.FinishedAt.Before(cutoff)
|
||||
}
|
||||
// run doc gone: use dir mtime
|
||||
if fi, e := os.Stat(dir); e == nil {
|
||||
return fi.ModTime().Before(cutoff)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func retentionDays() int {
|
||||
if v, err := GetWorkflowLogRetentionDays(); err == nil {
|
||||
return v
|
||||
}
|
||||
return 30
|
||||
}
|
||||
```
|
||||
|
||||
Note: `wfCtx` is defined in `workflows.go` (same package) — reuse it. `GetWorkflowLogRetentionDays` is added in Task 4; this file references it (same package, compiles together).
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: FAIL — `GetWorkflowLogRetentionDays` undefined until Task 4. This is expected; proceed to commit the file so Task 4 completes it. (If you prefer a green build, do Task 4's settings accessor first, then return — but committing here is fine since Task 4 immediately follows.)
|
||||
|
||||
Actually to keep every commit buildable: **temporarily** add a local stub at the bottom of this file and remove it in Task 4:
|
||||
|
||||
```go
|
||||
// TEMP stub, replaced in Task 4.
|
||||
func GetWorkflowLogRetentionDays() (int, error) { return 30, nil }
|
||||
```
|
||||
|
||||
Then `cd server && go build ./... && go vet ./...` must succeed.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/steplogs.go
|
||||
git commit -m "feat(server): workflow log-writer registry, paths, retention sweeper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Settings — retention accessor + startup wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/services/settings.go` (or wherever settings get/set lives — search `settings` collection usage)
|
||||
- Modify: `server/internal/services/steplogs.go` (remove the temp stub)
|
||||
- Modify: `server/cmd/main.go` (start the sweeper)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GetWorkflowLogRetentionDays() (int, error)` (default 30 when unset), `SetWorkflowLogRetentionDays(int) error`. If settings are exposed as a single document/struct, add the field there and derive these accessors.
|
||||
|
||||
- [ ] **Step 1: Inspect the settings service**
|
||||
|
||||
Read the existing settings service (search for the `settings` collection: `grep -rn "\"settings\"" server/internal/services`). Determine whether settings are a typed struct document or key/value. Match that pattern.
|
||||
|
||||
- [ ] **Step 2: Add the retention accessor**
|
||||
|
||||
If settings are a **typed document** (e.g. a `GetSettings()/UpdateSettings()`), add a field `WorkflowLogRetentionDays int `bson:"workflow_log_retention_days" json:"workflow_log_retention_days"`` to the settings struct and implement:
|
||||
|
||||
```go
|
||||
func GetWorkflowLogRetentionDays() (int, error) {
|
||||
s, err := GetSettings() // use the real accessor name
|
||||
if err != nil {
|
||||
return 30, err
|
||||
}
|
||||
if s.WorkflowLogRetentionDays == 0 && /* unset sentinel */ !s.WorkflowLogRetentionSet {
|
||||
return 30, nil
|
||||
}
|
||||
return s.WorkflowLogRetentionDays, nil
|
||||
}
|
||||
```
|
||||
|
||||
Simplify to match reality: if the settings doc uses zero-value-means-unset and you cannot distinguish "0 = keep forever" from "unset", store the retention as a pointer `*int` or default at read: **treat a missing field as 30, an explicit 0 as keep-forever.** Prefer `*int` in the struct so the three states (unset→30, 0→forever, N→N) are representable. Implement `GetWorkflowLogRetentionDays` to return 30 when the pointer is nil, else its value. `SetWorkflowLogRetentionDays(n int)` sets the pointer.
|
||||
|
||||
If settings are **key/value**, implement both accessors against that store with the same nil→30 / 0→forever semantics (store empty/absent = 30).
|
||||
|
||||
- [ ] **Step 3: Remove the temp stub from `steplogs.go`**
|
||||
|
||||
Delete the `// TEMP stub` `GetWorkflowLogRetentionDays` added in Task 3 so the real one is used.
|
||||
|
||||
- [ ] **Step 4: Start the sweeper at boot**
|
||||
|
||||
In `server/cmd/main.go`, next to `EnsureWorkflowIndexes()`, add `services.StartLogSweeper()`.
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success (real accessor now resolves the reference from Task 3).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/services/settings.go server/internal/services/steplogs.go server/cmd/main.go
|
||||
git commit -m "feat(server): workflow log retention setting + sweeper startup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Runner + model — write to files, drop log bodies from Mongo
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/models/workflow.go` (`StepRun`)
|
||||
- Modify: `server/internal/services/workflow_runner.go`
|
||||
- Modify: `server/internal/grpc/server.go` (stream delivery of `StepOutput`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `StepLogs`, `AppendMarker`, `ServerRunLogPath` (Task 3), `pb.StepOutputChunk` (Task 1).
|
||||
- Produces: runner writes markers + streams chunks to files; `StepRun.LogOffset` persisted; `StepRun.Stdout/Stderr` removed.
|
||||
|
||||
- [ ] **Step 1: Update the `StepRun` model**
|
||||
|
||||
In `server/internal/models/workflow.go`, in `type StepRun struct`:
|
||||
- Remove the `Stdout` and `Stderr` fields.
|
||||
- Add: `LogOffset int64 `bson:"log_offset" json:"log_offset"``
|
||||
|
||||
- [ ] **Step 2: Deliver StepOutput chunks in the gRPC receive loop**
|
||||
|
||||
In `server/internal/grpc/server.go`, after the existing `if m.StepResult != nil { services.StepResults.Deliver(m.StepResult) }` block, add:
|
||||
|
||||
```go
|
||||
if m.StepOutput != nil {
|
||||
if m.StepOutput.Eof {
|
||||
services.StepLogs.Close(m.StepOutput.CommandId)
|
||||
} else {
|
||||
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Rework `runServer` to open logs + write markers, drop persisted bodies**
|
||||
|
||||
In `server/internal/services/workflow_runner.go`, `runServer`:
|
||||
|
||||
Inside the per-step loop, **before** `dispatchAndWait`, add marker + open (compute `secretVals` first, which already exists in the loop):
|
||||
|
||||
```go
|
||||
// Write the step marker and remember the offset for later slicing.
|
||||
marker := fmt.Sprintf("\n===== step %d: %s =====\n", step.Order, step.Name)
|
||||
offset, _ := AppendMarker(runID, serverID, marker)
|
||||
logPath := ServerRunLogPath(runID, serverID)
|
||||
_ = StepLogs.Open(commandID_placeholder, logPath, secretsSlice(secretVals))
|
||||
```
|
||||
|
||||
There is a chicken-and-egg with `commandID`: today `dispatchAndWait` generates the `commandID` internally. Refactor so the runner owns the `commandID`:
|
||||
|
||||
1. Change `dispatchAndWait(serverID string, cmd *pb.RunStepCmd)` to `dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd)` and remove its internal `commandID := uuid.New().String()` (use the passed one).
|
||||
2. In `runServer`, generate `commandID := uuid.New().String()` at the top of each attempt-group (before the marker/open), open the log with it, then call `dispatchAndWait(serverID, commandID, cmd)`.
|
||||
3. After the step completes (result received), call `StepLogs.Close(commandID)` defensively (idempotent — the agent's eof usually closed it already; Close on a missing key is a no-op).
|
||||
|
||||
Add a helper to convert the `secretVals map[string]string` to a `[]string` of values:
|
||||
|
||||
```go
|
||||
func secretsSlice(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for _, v := range m {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Update `finishStep(...)` call + signature: **remove** the `stdout, stderr string` params and the `output_env` masking stays. Persist `log_offset` instead. New `finishStep`:
|
||||
|
||||
```go
|
||||
func finishStep(runID, serverID string, order int, status string, attempts, exit int, logOffset int64, outEnv map[string]string) {
|
||||
now := time.Now()
|
||||
updateStep(runID, serverID, order, bson.M{
|
||||
"server_runs.$[s].steps.$[t].status": status,
|
||||
"server_runs.$[s].steps.$[t].attempts": attempts,
|
||||
"server_runs.$[s].steps.$[t].exit_code": exit,
|
||||
"server_runs.$[s].steps.$[t].log_offset": logOffset,
|
||||
"server_runs.$[s].steps.$[t].output_env": outEnv,
|
||||
"server_runs.$[s].steps.$[t].finished_at": now,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
In the loop, after receiving `res`, drop the `stdout, stderr := ...` masking of `res.Stdout/res.Stderr` (those are now streamed to file). Keep the `outEnv` build **with existing masking** (`maskSecrets(v, allSecrets)` per the merged secret-leak fix) — `output_env`/`run_env` masking is unchanged. Call:
|
||||
|
||||
```go
|
||||
finishStep(runID, serverID, i, status, attempts, exit, offset, outEnv)
|
||||
```
|
||||
|
||||
where `offset` is the marker offset captured before dispatch. If `res == nil`, still write a short note to the file so failures are visible:
|
||||
|
||||
```go
|
||||
if res == nil {
|
||||
_, _ = AppendMarker(runID, serverID, "[vantage] agent did not return a result\n")
|
||||
}
|
||||
```
|
||||
|
||||
Remove the initial `StepRun{... Status:"queued"}` `Stdout/Stderr` references if any (the model no longer has them — the queued StepRun in `TriggerWorkflow` set only `Order/Name/Status/OutputEnv`, so no change needed there; verify).
|
||||
|
||||
Ensure `fmt` is imported (it already is).
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success. Fix any remaining references to the removed `Stdout`/`Stderr` fields or the old `finishStep`/`dispatchAndWait` signatures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/models/workflow.go server/internal/services/workflow_runner.go server/internal/grpc/server.go
|
||||
git commit -m "feat(server): stream step logs to files, drop log bodies from run docs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: REST — log fetch + SSE stream endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/internal/api/workflows.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ServerRunLogPath`, `GetRun` (existing).
|
||||
- Produces: `GET /api/runs/:runId/servers/:serverId/logs` and `GET /api/runs/:runId/servers/:serverId/logs/stream` (SSE).
|
||||
|
||||
- [ ] **Step 1: Add the two handlers + routes**
|
||||
|
||||
In `registerWorkflowRoutes`, add:
|
||||
|
||||
```go
|
||||
g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
|
||||
```
|
||||
|
||||
Add a UUID-ish validator and the handlers:
|
||||
|
||||
```go
|
||||
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
|
||||
|
||||
func getServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", b)
|
||||
}
|
||||
|
||||
func streamServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "stream unsupported"})
|
||||
return
|
||||
}
|
||||
|
||||
var offset int64
|
||||
sendNew := func() bool {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return true // file may not exist yet; keep waiting
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
return true
|
||||
}
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, _ := f.Read(buf)
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
offset += int64(n)
|
||||
// SSE data frame; split on newlines to keep frames well-formed.
|
||||
for _, line := range splitSSE(buf[:n]) {
|
||||
_, _ = c.Writer.WriteString("data: " + line + "\n")
|
||||
}
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
sendNew()
|
||||
if serverRunTerminal(runID, serverID) {
|
||||
sendNew() // final drain
|
||||
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverRunTerminal reports whether the given server-run has reached a terminal status.
|
||||
func serverRunTerminal(runID, serverID string) bool {
|
||||
r, err := services.GetRun(runID)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, sr := range r.ServerRuns {
|
||||
if sr.ServerID == serverID {
|
||||
switch sr.Status {
|
||||
case "success", "failed", "skipped", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitSSE turns a raw byte slice into SSE-safe payload lines (newlines become
|
||||
// separate data lines; carriage returns stripped).
|
||||
func splitSSE(b []byte) []string {
|
||||
s := strings.ReplaceAll(string(b), "\r", "")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
```
|
||||
|
||||
Add imports: `"os"`, `"regexp"`, `"strings"`, `"time"`, `"net/http"` (already present). Confirm `services.GetRun` and `ServerRun.Status`/`ServerID` fields exist (they do from the Workflows feature).
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./...`
|
||||
Expected: success.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/internal/api/workflows.go
|
||||
git commit -m "feat(api): server-run log fetch and SSE stream endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend — live SSE tail + retention setting
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/lib/api.ts`
|
||||
- Modify: `web/app/workflows/[id]/runs/[runId]/page.tsx`
|
||||
- Modify: `web/app/settings/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: SSE endpoint, logs endpoint, settings mutation.
|
||||
|
||||
- [ ] **Step 1: Update API types + helpers**
|
||||
|
||||
In `web/lib/api.ts`:
|
||||
- In `StepRun`, remove `stdout` and `stderr`; add `log_offset: number`.
|
||||
- Add: `getServerRunLog: (runId: string, serverId: string) => request<string>(...)` — but the logs endpoint returns `text/plain`, so add a dedicated fetch that reads text. If `request<T>` assumes JSON, add a sibling:
|
||||
|
||||
```ts
|
||||
async getServerRunLog(runId: string, serverId: string): Promise<string> {
|
||||
const res = await fetch(`${API_BASE}/api/runs/${runId}/servers/${serverId}/logs`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error("no logs");
|
||||
return res.text();
|
||||
},
|
||||
```
|
||||
|
||||
(Use the file's real base-URL constant / credentials pattern — inspect how `request` builds URLs and mirror it. If the app is same-origin with a rewrite, a relative `/api/...` fetch is fine.)
|
||||
- Export a helper to build the SSE URL: `serverRunLogStreamUrl(runId, serverId)` returning the `/api/runs/:runId/servers/:serverId/logs/stream` URL against the same base.
|
||||
- In the Settings type, add `workflow_log_retention_days?: number | null`.
|
||||
|
||||
- [ ] **Step 2: Live tail in the run detail page**
|
||||
|
||||
In `web/app/workflows/[id]/runs/[runId]/page.tsx`:
|
||||
- Remove all use of `st.stdout` / `st.stderr` (fields gone). Step `<details>` now show status/exit/attempts pills only.
|
||||
- Add a per-server live terminal. For each `server_run`, render a `<pre>` and, while `sr.status === "running"`, subscribe via `EventSource`:
|
||||
|
||||
```tsx
|
||||
function ServerLog({ runId, serverId, status }: { runId: string; serverId: string; status: string }) {
|
||||
const [text, setText] = useState("");
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const running = status === "running";
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), { withCredentials: true });
|
||||
es.onmessage = (e) => setText((t) => t + e.data + "\n");
|
||||
es.addEventListener("done", () => es.close());
|
||||
es.onerror = () => es.close();
|
||||
return () => es.close();
|
||||
}
|
||||
// terminal: fetch the whole file once
|
||||
api.getServerRunLog(runId, serverId).then(setText).catch(() => setText(""));
|
||||
}, [running, runId, serverId]);
|
||||
|
||||
useEffect(() => { preRef.current?.scrollTo(0, preRef.current.scrollHeight); }, [text]);
|
||||
|
||||
return (
|
||||
<pre ref={preRef} className="mt-2 max-h-80 overflow-auto rounded bg-black/40 p-2 font-mono text-xs text-text-secondary whitespace-pre-wrap">
|
||||
{text || (running ? "Waiting for output…" : "No output.")}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Render `<ServerLog runId={run.run_id} serverId={sr.server_id} status={sr.status} />` inside each server card, below the step pills. Keep the existing react-query `refetchInterval` on the run (drives status pills); the SSE handles live text.
|
||||
|
||||
- [ ] **Step 3: Retention field in Settings**
|
||||
|
||||
In `web/app/settings/page.tsx`, add a "Workflow log retention (days)" number input bound to `workflow_log_retention_days`, saved through the existing settings save mutation. Add helper text: "0 = keep forever." Match the page's existing input styling.
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: type-checks and builds. Fix any lingering `st.stdout`/`st.stderr` references.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/lib/api.ts web/app/workflows/[id]/runs/[runId]/page.tsx web/app/settings/page.tsx
|
||||
git commit -m "feat(web): live SSE log tail and log retention setting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end verification
|
||||
|
||||
**Files:** none (verification only).
|
||||
|
||||
- [ ] **Step 1: Build everything**
|
||||
|
||||
Run: `cd server && go build ./... && go vet ./... && cd ../agent && go build ./... && go vet ./... && cd ../web && npm run build`
|
||||
Expected: all succeed.
|
||||
|
||||
- [ ] **Step 2: Manual smoke (documented, run if an environment is available)**
|
||||
|
||||
With server + MongoDB + a connected agent:
|
||||
1. Run a workflow with a step that emits output slowly (e.g. `for i in $(seq 1 10); do echo "line $i"; sleep 1; done`). Open the run detail page while running; confirm lines appear live (SSE), not only at the end.
|
||||
2. Confirm `<logdir>/<run_id>/<server_id>.log` exists on the server with step markers and the output.
|
||||
3. Confirm `workflow_runs` doc no longer stores stdout/stderr bodies; `steps[].log_offset` is set.
|
||||
4. Add a secret ref and echo it; confirm the file shows `***`, including when the secret would straddle a chunk boundary.
|
||||
5. Set retention to 0 in Settings → confirm sweeper keeps files; set to a small value and backdate a run's `finished_at` → confirm the dir is removed within the hour (or call `sweepLogs` path manually).
|
||||
|
||||
- [ ] **Step 3: Commit any fixes found**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: workflow log streaming e2e fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** §3 proto → T1; §4 agent streaming → T2; §5.1 registry + §7 sweeper → T3; §7.1 setting + startup → T4; §5.3/§5.4 runner+model → T5; §6 REST/SSE → T6; §8 frontend → T7. Tests omitted per Global Constraints.
|
||||
- **Masking** boundary-safe carry buffer in `StepLogs.Append`, flushed in `Close` (T3); `output_env`/`run_env` masking unchanged (T5 keeps the merged fix).
|
||||
- **commandID ownership** moved to the runner so the log file can be opened before dispatch (T5) — mirrors the `StepResults.Await`-before-dispatch ordering.
|
||||
- **Buildable commits:** T3 adds a temp stub for `GetWorkflowLogRetentionDays`, removed in T4.
|
||||
- **Removed fields** `StepRun.Stdout/Stderr` — every reader updated in T5 (runner) and T7 (frontend).
|
||||
- **Open follow-ups (out of scope):** per-step SSE channels, log download/zip, compression, pre-existing runs have no files.
|
||||
```
|
||||
@@ -1,241 +0,0 @@
|
||||
# Vantage Web Console (Guacamole Replacement) — Design
|
||||
|
||||
**Date:** 2026-07-17
|
||||
**Status:** Approved design, pre-implementation
|
||||
|
||||
## Goal
|
||||
|
||||
Add a browser-based remote-access console to Vantage — SSH, RDP, and VNC into
|
||||
managed servers — as a self-hosted Guacamole replacement. Users select an SSH
|
||||
key to connect over SSH. RDP targets are reachable from a new Windows agent that
|
||||
registers the host and reports status. Windows agent ships as an MSI installer
|
||||
produced by CI.
|
||||
|
||||
## Non-Goals (YAGNI)
|
||||
|
||||
- Session recording / replay (may be added later).
|
||||
- Native Go RDP implementation (guacd handles protocol translation).
|
||||
- Per-user Linux/Windows account management from the agent.
|
||||
- Tunneling console traffic through the agent (direct network path assumed).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (guacamole-common-js, vendored — no CDN)
|
||||
│ Guacamole protocol over WebSocket
|
||||
▼
|
||||
Go server: /api/console/tunnel (github.com/wwt/guac)
|
||||
│ Guacamole protocol over TCP :4822
|
||||
▼
|
||||
guacd container (Apache Guacamole daemon)
|
||||
│ SSH :22 / RDP :3389 / VNC :5900 — direct to target IP
|
||||
▼
|
||||
Target host (LAN / VPN line-of-sight from server)
|
||||
```
|
||||
|
||||
- **Browser:** loads vendored `guacamole-common-js`, renders RDP/VNC display and
|
||||
SSH terminal. No external CDN (matches existing infra rules).
|
||||
- **Go server:** exposes a WebSocket tunnel endpoint using `github.com/wwt/guac`
|
||||
(Go Guacamole tunnel library). No Java `guacamole-client` required.
|
||||
- **guacd:** new container in `deploy/docker-compose.yml`, bound to the internal
|
||||
docker network only, reachable by the server on `:4822`.
|
||||
- **Network path:** guacd connects **directly** to the target IP. Requires the
|
||||
central server to have network line-of-sight to hosts (homelab LAN / VPN). The
|
||||
agent's outbound-only guarantee is unchanged — the console path is
|
||||
server→target, not agent-mediated.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### `keys` — extend to hold private material
|
||||
|
||||
```json
|
||||
{
|
||||
"key_id": "uuid",
|
||||
"label": "dom-macbook",
|
||||
"public_key": "ssh-ed25519 AAAA...",
|
||||
"private_key_enc": "<AES-256-GCM ciphertext | null>",
|
||||
"has_private": true,
|
||||
"passphrase_enc": "<AES-256-GCM ciphertext | null>",
|
||||
"fingerprint": "SHA256:...",
|
||||
"source": "uploaded|generated",
|
||||
"created_at": "ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
- A key may be created from an uploaded **private+public** pair, upload of a
|
||||
public key only, or agent generation.
|
||||
- Agent key generation now also uploads `private_key_enc` (reuses the existing
|
||||
AES-256 key used for at-rest encryption). Private key no longer stays local
|
||||
only — it is stored encrypted so the console can reuse it.
|
||||
- Optional `passphrase_enc` for passphrase-protected private keys.
|
||||
- Console lists only keys where `has_private = true`.
|
||||
|
||||
### `servers` — extend with console metadata
|
||||
|
||||
```json
|
||||
{
|
||||
"...": "...existing fields...",
|
||||
"os_type": "linux|windows",
|
||||
"console_protocols": ["ssh"],
|
||||
"ssh_port": 22,
|
||||
"rdp_port": 3389
|
||||
}
|
||||
```
|
||||
|
||||
- `os_type` set at registration from the agent.
|
||||
- `console_protocols` lists enabled protocols per server (`ssh`, `rdp`, `vnc`).
|
||||
- Port fields default to standard ports, overridable in the UI.
|
||||
|
||||
### `console_sessions` — new collection (audit)
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"server_id": "uuid",
|
||||
"protocol": "ssh|rdp|vnc",
|
||||
"key_id": "uuid | null",
|
||||
"user": "who opened it",
|
||||
"started_at": "ISODate",
|
||||
"ended_at": "ISODate | null",
|
||||
"client_ip": "string"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Broker + Connection Flow
|
||||
|
||||
New service: `server/internal/services/console.go`.
|
||||
|
||||
1. Browser `POST /api/console/connect`
|
||||
`{ server_id, protocol, key_id?, rdp_username?, rdp_password? }`.
|
||||
2. Broker validates request, loads the server (host IP, port for protocol),
|
||||
loads the key and **decrypts `private_key_enc` in memory only**.
|
||||
3. Builds the guacd connection parameter map:
|
||||
- **SSH:** `hostname`, `port`, `username`, `private-key` (decrypted),
|
||||
`passphrase` (if any).
|
||||
- **RDP:** `hostname`, `port`, `username`, `password`, `security=any`,
|
||||
`ignore-cert=true`.
|
||||
- **VNC:** `hostname`, `port`, `password`.
|
||||
4. Creates a `console_sessions` document, returns a short-lived signed session
|
||||
token.
|
||||
5. Browser opens WebSocket `/api/console/tunnel?token=…`. The `wwt/guac` handler
|
||||
validates the token, dials guacd `:4822`, and pipes bytes in both directions.
|
||||
6. On socket close, the broker sets `ended_at` on the session doc.
|
||||
|
||||
### Security
|
||||
|
||||
- Decrypted private keys and RDP passwords are **never persisted, never logged,
|
||||
never sent to the browser** — passed only to guacd.
|
||||
- Session token: short TTL (~60s to open the WebSocket), single-use,
|
||||
HMAC-signed, bound to the authenticated user.
|
||||
- guacd is bound to the internal docker network only; not exposed publicly.
|
||||
- At-rest encryption (`private_key_enc`, `passphrase_enc`) reuses the existing
|
||||
AES-256 key already used for agent-generated private keys.
|
||||
|
||||
---
|
||||
|
||||
## Windows Agent
|
||||
|
||||
Same Go codebase as the Linux agent, with a reduced role: **register +
|
||||
heartbeat + status only**. No `authorized_keys` management (meaningless on
|
||||
Windows).
|
||||
|
||||
- Build target: `GOOS=windows GOARCH=amd64` → `vantage-agent-windows-amd64.exe`.
|
||||
- Agent detects OS at registration and sends `os_type=windows`.
|
||||
- The key-sync loop is disabled on Windows via a runtime OS check (or build tag)
|
||||
— no `authorized_keys` writes are ever attempted.
|
||||
- Config file: `C:\ProgramData\vantage\config.yaml`, locked down via ACL to the
|
||||
equivalent of `0600`.
|
||||
- Runs as a Windows service via **nssm**.
|
||||
|
||||
---
|
||||
|
||||
## Windows Installer (MSI)
|
||||
|
||||
Agent ships as a WiX v4 MSI produced in CI.
|
||||
|
||||
- **WiX v4** chosen because it is a dotnet tool that builds MSIs
|
||||
**cross-platform** — runs on the Linux Gitea act_runner. (Inno Setup is
|
||||
Windows-only and does not fit the runner.)
|
||||
- MSI bundles `vantage-agent.exe`, installs it to `C:\Program Files\Vantage\`,
|
||||
and registers the nssm service (ships nssm or uses a CustomAction).
|
||||
- Accepts install parameters as MSI properties for silent/headless install:
|
||||
```
|
||||
msiexec /i vantage-agent.msi /qn SERVERID=<id> TOKEN=<token> SERVERURL=vantage..:9090
|
||||
```
|
||||
- GUI install (double-click) prompts for server-id / token / server-url via a
|
||||
dialog.
|
||||
|
||||
### Two install paths
|
||||
|
||||
1. **Installer direct** — user downloads `vantage-agent.msi`, double-clicks,
|
||||
fills the dialog. No script required.
|
||||
2. **PowerShell one-liner** — served dynamically (like the existing bash
|
||||
`/install`). Script downloads the `.msi`, verifies SHA-256, then runs
|
||||
`msiexec /qn` with injected `SERVERID` / `TOKEN` / `SERVERURL`. Used by the
|
||||
copy-paste "Add Server" flow.
|
||||
|
||||
The PowerShell script (`/install.ps1`) steps:
|
||||
1. Detect arch.
|
||||
2. Download `vantage-agent.msi` from the latest Gitea `agent/v*` release.
|
||||
3. Verify SHA-256 against `checksums.txt`.
|
||||
4. Run `msiexec /i vantage-agent.msi /qn SERVERID=.. TOKEN=.. SERVERURL=..`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Route | Change |
|
||||
| ------------------------- | ------------------------------------------------------------- |
|
||||
| `/servers` | Show `os_type` badge, enabled console protocols |
|
||||
| `/servers/[id]` | Add **Connect** button(s) per enabled protocol |
|
||||
| `/servers/[id]/console` | New — full-screen console (guacamole-common-js), key picker |
|
||||
| `/servers/new` | Offer Windows (MSI) vs Linux (bash) install instructions |
|
||||
|
||||
Console page: select protocol + SSH key (SSH) or enter RDP credentials, call
|
||||
`/api/console/connect`, open the tunnel WebSocket, mount the Guacamole client.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Changes
|
||||
|
||||
### `agent-release.yml`
|
||||
|
||||
- Add `windows/amd64` build: `vantage-agent-windows-amd64.exe`.
|
||||
- Add WiX v4 MSI build job → `vantage-agent.msi`.
|
||||
- Add both to `checksums.txt` and release assets.
|
||||
|
||||
Release assets become:
|
||||
- `vantage-agent-linux-amd64`
|
||||
- `vantage-agent-linux-arm64`
|
||||
- `vantage-agent-windows-amd64.exe`
|
||||
- `vantage-agent.msi`
|
||||
- `checksums.txt`
|
||||
|
||||
### `server-deploy.yml`
|
||||
|
||||
- Add guacd service to `deploy/docker-compose.yml` (deployed alongside server).
|
||||
|
||||
---
|
||||
|
||||
## New Dependencies
|
||||
|
||||
- **Go:** `github.com/wwt/guac` (Guacamole tunnel/WebSocket in Go).
|
||||
- **Container:** `guacamole/guacd` official image.
|
||||
- **Frontend:** vendored `guacamole-common-js` (no CDN).
|
||||
- **CI:** WiX v4 dotnet tool; nssm binary bundled for the MSI.
|
||||
|
||||
---
|
||||
|
||||
## Open Implementation Notes
|
||||
|
||||
- Confirm `wwt/guac` API surface for connection-parameter passing and token auth
|
||||
binding during implementation.
|
||||
- nssm packaging inside MSI: bundle the nssm binary as a payload + CustomAction,
|
||||
or run `sc.exe`-based service install if nssm proves awkward in WiX.
|
||||
- ACL hardening of `C:\ProgramData\vantage\config.yaml` in the MSI CustomAction.
|
||||
@@ -1,238 +0,0 @@
|
||||
# Server Workflows — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Server Workflows only. Fleet Inventory and SaaS/local-auth are separate sub-projects with their own specs.
|
||||
|
||||
Approved UI mockup: three-pane builder (Step Library · Canvas · Inspector), env vars shown riding the wire between nodes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Let operators compose **reusable shell steps** (Bash or PowerShell) into **workflows** and run them across many managed servers in parallel. Steps pass data to later steps through a `$WORKFLOW_ENV` file (GitHub-Actions style). Every run is recorded with full per-step logs. Steps can reference org secrets, injected as environment variables at runtime.
|
||||
|
||||
Builds directly on the existing `CommandStream` gRPC infrastructure (`dispatch.go`, `ServerCommand` oneof, agent command loop).
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Data passing | Implicit. Every step's `$WORKFLOW_ENV` outputs merge into the run's env and are exposed to **all** later steps as `$KEY`. No explicit port wiring. |
|
||||
| Failure model | Per-step policy: `stop` (default), `continue`, `retry` (with max attempt count). |
|
||||
| Targets | Fan-out. Same step sequence runs on N target servers **in parallel**. Steps within one server run **sequentially**. |
|
||||
| History/logs | Every run persisted: status, timing, per-server per-step stdout/stderr/exit code, captured output env. |
|
||||
| Secrets | Steps declare needed secret keys; resolved from existing `secrets` store and injected as env vars at exec time. Never persisted into run logs. |
|
||||
| Testing | **Skipped** for this iteration per request. No test files written. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model (MongoDB)
|
||||
|
||||
### `workflow_steps` — reusable step library
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"step_id": "uuid",
|
||||
"name": "Restart service",
|
||||
"description": "Restart-Service by name, wait ready",
|
||||
"interpreter": "bash | powershell",
|
||||
"script": "Restart-Service vantage-api\n...",
|
||||
"declared_outputs": ["STARTED_AT"], // documentation/UI hints; not enforced
|
||||
"secret_refs": ["DEPLOY_TOKEN"], // secret keys this step needs injected
|
||||
"org_id": "uuid", // for future multi-tenant; single-org for now
|
||||
"created_at": "ISODate",
|
||||
"updated_at": "ISODate"
|
||||
}
|
||||
```
|
||||
|
||||
### `workflows` — ordered composition
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"workflow_id": "uuid",
|
||||
"name": "Deploy & Restart API",
|
||||
"target_server_ids": ["uuid", "uuid"],
|
||||
"steps": [
|
||||
{
|
||||
"step_id": "uuid", // reference to library step
|
||||
"order": 0,
|
||||
"on_failure": "stop | continue | retry",
|
||||
"max_retries": 0, // used when on_failure = retry
|
||||
"overrides": { // optional local fork of the library step
|
||||
"script": null,
|
||||
"secret_refs": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"created_at": "ISODate",
|
||||
"updated_at": "ISODate"
|
||||
}
|
||||
```
|
||||
Editing a library step from the Inspector writes an `overrides` block on that workflow step (a local fork) rather than mutating the shared step.
|
||||
|
||||
### `workflow_runs` — execution records
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"run_id": "uuid",
|
||||
"workflow_id": "uuid",
|
||||
"workflow_snapshot": { }, // frozen copy of workflow + resolved steps at trigger time
|
||||
"status": "running | success | failed | cancelled",
|
||||
"triggered_by": "user-id",
|
||||
"started_at": "ISODate",
|
||||
"finished_at": "ISODate | null",
|
||||
"server_runs": [
|
||||
{
|
||||
"server_id": "uuid",
|
||||
"status": "queued | running | success | failed | skipped",
|
||||
"started_at": "ISODate | null",
|
||||
"finished_at": "ISODate | null",
|
||||
"run_env": { "VERSION": "a1b9f0" }, // accumulated non-secret output env
|
||||
"steps": [
|
||||
{
|
||||
"order": 0,
|
||||
"name": "Git pull & build",
|
||||
"status": "success | failed | running | queued | skipped",
|
||||
"attempts": 1,
|
||||
"exit_code": 0,
|
||||
"stdout": "…",
|
||||
"stderr": "…",
|
||||
"output_env": { "VERSION": "a1b9f0" },
|
||||
"started_at": "ISODate",
|
||||
"finished_at": "ISODate"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Secret values are never written to `stdout`/`stderr`/`run_env` by us; masking of known secret values in captured output is applied before persistence.
|
||||
|
||||
---
|
||||
|
||||
## 4. gRPC protocol changes (`proto/vantage/v1/vantage.proto`)
|
||||
|
||||
### New command in the `ServerCommand` oneof
|
||||
```protobuf
|
||||
message RunStepCmd {
|
||||
string interpreter = 1; // "bash" | "powershell"
|
||||
string script = 2;
|
||||
map<string, string> env = 3; // inputs = accumulated run env + injected secrets
|
||||
int32 timeout_seconds = 4;
|
||||
}
|
||||
```
|
||||
Add `RunStepCmd run_step = 6;` to the `ServerCommand` oneof.
|
||||
|
||||
### Richer result — new `AgentMessage` payload
|
||||
Current `CommandResult{command_id, success, message}` is too thin. Add a dedicated step result:
|
||||
```protobuf
|
||||
message StepResult {
|
||||
string command_id = 1;
|
||||
int32 exit_code = 2;
|
||||
string stdout = 3;
|
||||
string stderr = 4;
|
||||
map<string, string> output_env = 5; // parsed $WORKFLOW_ENV KEY=value lines
|
||||
}
|
||||
```
|
||||
Add `StepResult step_result = 5;` to the `AgentMessage` oneof (alongside existing `ready` / `result`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Agent execution (`agent/internal/...`)
|
||||
|
||||
New handler for `RunStepCmd` in the agent command loop:
|
||||
|
||||
1. Create a temp dir; create empty `WORKFLOW_ENV` file inside it.
|
||||
2. Write `script` to a temp script file.
|
||||
3. Build the process environment: inherited env + `cmd.env` (run env + secrets) + `WORKFLOW_ENV=<path to env file>`.
|
||||
4. Execute:
|
||||
- `bash` → `bash <script>`
|
||||
- `powershell` → `pwsh -NoProfile -File <script>` (fallback `powershell.exe` on Windows if `pwsh` absent).
|
||||
5. Capture stdout, stderr, exit code. Enforce `timeout_seconds` (kill on exceed → non-zero exit, stderr note).
|
||||
6. Parse the `WORKFLOW_ENV` file: each `KEY=value` line becomes an `output_env` entry (last write wins; supports multi-line via simple `KEY<<EOF` heredoc form, optional for v1 — start with single-line `KEY=value`).
|
||||
7. Reply with `StepResult`. Delete temp dir.
|
||||
|
||||
Agent runs as root (existing), so no privilege change. Script content is trusted operator input.
|
||||
|
||||
---
|
||||
|
||||
## 6. Server orchestration (`server/internal/services/workflows.go`)
|
||||
|
||||
Runner responsibilities:
|
||||
|
||||
1. On trigger: snapshot the workflow (resolve each library step + overrides), create a `workflow_runs` doc with one `server_run` per target, all `queued`.
|
||||
2. Spawn one goroutine **per target server** (parallel fan-out). Each goroutine:
|
||||
- Verifies the agent is connected (`Dispatcher.IsConnected`); if not → `server_run.status = skipped`, reason recorded.
|
||||
- Maintains a `run_env map[string]string`, seeded empty.
|
||||
- For each step in order:
|
||||
- Resolve `secret_refs` from the secrets service → merge into the command env (kept separate from persisted `run_env`).
|
||||
- Dispatch `RunStepCmd{env: run_env + secrets}` via a **correlated** send — needs a way to await the matching `StepResult` by `command_id` (see §7).
|
||||
- On result: persist step record (stdout/stderr/exit, masked); merge `output_env` into `run_env`.
|
||||
- Apply `on_failure` on non-zero exit: `stop` (fail server_run, break), `continue` (mark failed, proceed), `retry` (re-dispatch up to `max_retries`).
|
||||
3. Aggregate: run `status = success` if all server_runs succeeded, else `failed`. Set `finished_at`.
|
||||
|
||||
### Concurrency / queue
|
||||
- One workflow run per workflow at a time (reject or queue concurrent triggers — v1: reject with clear error).
|
||||
- Per-server step dispatch is serial; servers are parallel.
|
||||
|
||||
---
|
||||
|
||||
## 7. Correlated command results
|
||||
|
||||
The existing dispatcher is fire-and-forget; workflows need request/response by `command_id`. Add a small **pending-result registry** alongside `Dispatcher`:
|
||||
|
||||
- `AwaitResult(commandID) <-chan *pb.StepResult` — registers a channel before dispatch.
|
||||
- The `CommandStream` receive loop, on a `StepResult`, looks up the pending channel by `command_id` and delivers it (falls back to existing `CommandResult` handling for other command types).
|
||||
- Timeout guard on the server side (step `timeout_seconds` + grace) so a dead agent can't hang a run.
|
||||
|
||||
This is additive; existing `CommandResult` flow for key/update commands is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 8. REST API (`server/internal/api/workflows.go`)
|
||||
|
||||
| Method + path | Purpose |
|
||||
|---------------|---------|
|
||||
| `GET /api/steps` / `POST` / `PUT /:id` / `DELETE /:id` | Reusable step library CRUD |
|
||||
| `GET /api/workflows` / `POST` / `PUT /:id` / `DELETE /:id` | Workflow CRUD (name, targets, ordered steps) |
|
||||
| `POST /api/workflows/:id/run` | Trigger a run; returns `run_id` |
|
||||
| `GET /api/workflows/:id/runs` | Run history (summary list) |
|
||||
| `GET /api/runs/:run_id` | Full run detail incl. per-server per-step logs |
|
||||
| `POST /api/runs/:run_id/cancel` | Best-effort cancel |
|
||||
|
||||
Secrets are referenced by key only through these APIs; values never returned.
|
||||
|
||||
---
|
||||
|
||||
## 9. Frontend (`web/app/workflows/`)
|
||||
|
||||
- `/workflows` — list workflows, last run status/time, Run button.
|
||||
- `/workflows/[id]` — the three-pane builder from the approved mockup:
|
||||
- **Library** (left): reusable steps, `bash`/`pwsh` badges, search, add.
|
||||
- **Canvas** (center): ordered nodes, env chips on wires, live status pills.
|
||||
- **Inspector** (right): name, command editor, declared inputs/outputs, `secret_refs` picker, `on_failure` + retry count.
|
||||
- `/workflows/[id]/runs/[runId]` — run detail: per-server columns, expandable per-step stdout/stderr, exit codes, timing. Live-updating while `running` (poll, consistent with existing 30s-poll ethos — or reuse whatever the console screen uses).
|
||||
|
||||
Reuse existing web components/styling patterns (there is already `servers`, `secrets`, `audit`, console UI to match).
|
||||
|
||||
---
|
||||
|
||||
## 10. Security notes
|
||||
|
||||
- Scripts are trusted operator input executed as root — same trust level as the existing console feature. No new sandbox in v1.
|
||||
- Secret values injected as env only; masked from all persisted logs (`stdout`/`stderr`/`run_env`) by literal replacement before write.
|
||||
- Run triggering and step/workflow CRUD gated behind existing auth (`server/internal/auth`).
|
||||
- Audit: emit audit-log entries (existing `audit` service) on workflow create/edit/delete and run trigger.
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope (this iteration)
|
||||
|
||||
- Tests (explicitly skipped).
|
||||
- Branching/conditional steps, matrix per-server conditionals (fan-out only).
|
||||
- Scheduled/cron triggers (manual run only for v1).
|
||||
- Multi-org isolation enforcement (schema carries `org_id` for later; single-org behavior now).
|
||||
- Artifact upload/collection beyond env vars.
|
||||
@@ -1,193 +0,0 @@
|
||||
# Workflow Log Streaming — Design
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Status:** Approved (design) — ready for implementation planning
|
||||
**Scope:** Stream step stdout/stderr live from agent to server-side log files, tail them live in the UI, and auto-expire them on a retention period. Enhancement to the already-merged Server Workflows feature. No auth/orgs, no inventory.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Today a workflow step buffers all stdout/stderr in agent RAM, ships it in one terminal `StepResult`, and the server persists the whole body into the `workflow_runs` Mongo document. Long/chatty steps risk: agent memory blow-up, the gRPC 4MB message ceiling, and the Mongo 16MB document cap.
|
||||
|
||||
Change to **live streaming**:
|
||||
|
||||
1. Agent streams output chunks over the existing `CommandStream` as the process runs.
|
||||
2. Server appends chunks (secret-masked) to a **per-server-run log file** on disk — not Mongo.
|
||||
3. UI tails the file live via **SSE** while a server-run is running; slices per-step by byte offset after completion.
|
||||
4. A **retention sweeper** deletes old run-log directories on a configurable period (default 30 days, set in Settings).
|
||||
|
||||
`workflow_runs` documents shrink: they no longer carry `stdout`/`stderr` bodies, only status/exit/attempts/output_env/timestamps plus a per-step `log_offset`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Locked decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Transport | Reuse bidirectional `CommandStream`. New `AgentMessage.StepOutput` chunk message. |
|
||||
| Chunk shape | `{command_id, seq, data, eof}`. Interleaved stdout+stderr in execution order. |
|
||||
| Terminal result | `StepResult` still sent at step end, now carries only `exit_code` + `output_env` (no stdout/stderr). |
|
||||
| Log granularity | **One file per server-run**: `<logdir>/<run_id>/<server_id>.log`, with a marker line before each step. |
|
||||
| Streams | **Interleaved** — single synchronized writer on the agent, terminal-order output. |
|
||||
| Masking | **Server-side** (agent can't tell secret env from normal env). Per-stream carry buffer of `maxSecretLen-1` bytes so a secret split across a chunk boundary still masks; flushed on EOF. |
|
||||
| Live tail | **SSE** at per-server-run granularity: `GET /api/runs/:runId/servers/:serverId/logs/stream`. Post-run whole-file fetch + per-step offset slice. |
|
||||
| Retention | `settings.workflow_log_retention_days`, default **30**, editable in `/settings`. Hourly sweeper deletes `<logdir>/<run_id>/` dirs older than retention by run `finished_at`. |
|
||||
| Log dir | Env `VANTAGE_WORKFLOW_LOG_DIR`, default `<data>/workflow-logs`. Created `0700`. |
|
||||
| Mongo | No log bodies in `workflow_runs`. Disk is the source of truth for output. |
|
||||
|
||||
---
|
||||
|
||||
## 3. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
|
||||
|
||||
Add to the `AgentMessage` oneof: `StepOutputChunk step_output = 6;`
|
||||
|
||||
```protobuf
|
||||
message StepOutputChunk {
|
||||
string command_id = 1;
|
||||
uint64 seq = 2; // monotonic per command_id, 0-based
|
||||
bytes data = 3; // raw interleaved stdout+stderr bytes
|
||||
bool eof = 4; // true on the final (empty) chunk
|
||||
}
|
||||
```
|
||||
|
||||
`StepResult` is unchanged in shape but `stdout`/`stderr` are now left empty by the agent (kept in the message for backward-compat / error notes only — server ignores them for log content). The server still reads `exit_code` and `output_env` from `StepResult`.
|
||||
|
||||
Hand-written JSON-codec struct added to **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, identical. `AgentMessage` gains `StepOutput *StepOutputChunk` in both.
|
||||
|
||||
`data` is `[]byte` in the Go structs (JSON-codec base64-encodes it, which is fine).
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent (`agent/internal/exec/exec.go`)
|
||||
|
||||
`RunStep` signature gains a chunk sink:
|
||||
|
||||
```go
|
||||
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult
|
||||
```
|
||||
|
||||
- Replace the two `bytes.Buffer`s with a single `streamWriter` set as **both** `c.Stdout` and `c.Stderr`. Its `Write` takes a mutex (so stdout+stderr interleave without interleaving *within* a write), assigns the next `seq`, and calls `emit(seq, copyOfBytes)`. Chunks are whatever the OS pipe delivers (typically ≤64KB); no extra buffering/line-assembly.
|
||||
- `StepResult` returns with `Stdout`/`Stderr` empty; `ExitCode` and `OutputEnv` populated as today (env parsing unchanged).
|
||||
- On timeout/exec error, put the short note in `StepResult.Stderr` (terminal, not streamed) so the runner can still surface a failure reason even if nothing streamed.
|
||||
|
||||
Agent loop (`agent/internal/sync/sync.go`, the `cmd.RunStep != nil` goroutine): pass an `emit` closure that sends `AgentMessage{ServerId, AgentToken, StepOutput: &pb.StepOutputChunk{CommandId, Seq, Data}}` through the existing mutex-guarded `send()`. After `RunStep` returns, send a final `StepOutput{eof:true, seq:last+1}` then the terminal `StepResult` (both via `send()`). Ordering: all chunks, then eof, then StepResult.
|
||||
|
||||
---
|
||||
|
||||
## 5. Server write path
|
||||
|
||||
### 5.1 Log writer registry (`server/internal/services/steplogs.go`)
|
||||
|
||||
Parallel to `StepResults`. Keyed by `command_id`:
|
||||
|
||||
```go
|
||||
type stepLogWriter struct {
|
||||
f *os.File
|
||||
mu sync.Mutex
|
||||
carry []byte // held-back tail for boundary-safe masking
|
||||
secrets []string // secret literals to mask
|
||||
maxSecret int
|
||||
}
|
||||
var StepLogs = &stepLogRegistry{ ... }
|
||||
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) (*stepLogWriter, error)
|
||||
func (r *stepLogRegistry) Append(commandID string, data []byte) // masked write
|
||||
func (r *stepLogRegistry) Close(commandID string) // flush carry, close file
|
||||
```
|
||||
|
||||
- `Append` masking: concatenate `carry+data`, mask all secret literals (`ReplaceAll(v,"***")`), then write everything except the last `maxSecret-1` bytes; keep those as the new `carry`. `Close` masks+writes the remaining carry. If `secrets` empty, write straight through (no carry).
|
||||
- The file handle is opened append-only (`O_APPEND|O_CREATE|O_WRONLY`, `0600`); dir `0700`.
|
||||
|
||||
### 5.2 Stream delivery (`server/internal/grpc/server.go`)
|
||||
|
||||
In the receive loop, after the `m.StepResult` block, add:
|
||||
|
||||
```go
|
||||
if m.StepOutput != nil {
|
||||
if m.StepOutput.Eof {
|
||||
services.StepLogs.Close(m.StepOutput.CommandId)
|
||||
} else {
|
||||
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Runner changes (`server/internal/services/workflow_runner.go`)
|
||||
|
||||
- Resolve the log dir + run/server file path once per server-run; ensure `<logdir>/<run_id>/` exists.
|
||||
- Before dispatching each step: write the step marker line to the file (`\n===== step <order>: <name> =====\n`), record the current file byte offset as the step's `log_offset` (persisted on the `StepRun`), and `StepLogs.Open(commandID, path, secretVals)` **before** `DispatchRunStep` (same ordering rule as `StepResults.Await`).
|
||||
- `dispatchAndWait` no longer expects stdout/stderr in the result. On terminal `StepResult`, `StepLogs.Close(commandID)` is driven by the agent's eof; the runner also calls `Close` defensively on timeout/dispatch-failure (idempotent).
|
||||
- **Drop** `stdout`/`stderr` from `finishStep` persistence. Masking of the streamed body is done in `Append`; `run_env`/`output_env` masking (existing, from the merged fix) stays.
|
||||
- The shared server-run file is written by two writers that never overlap in time (steps are serial, and the runner writes each step marker *before* `StepLogs.Open`): (a) the runner writes markers directly to the path, serially between steps; (b) `StepLogs` writes chunks during a step. **Resolved approach:** `Open(commandID, path, secrets)` opens the path fresh with `O_APPEND|O_CREATE|O_WRONLY` for that step and `Close(commandID)` closes it on eof. One handle live at a time per server-run (serial steps guarantee this), so there is no shared-handle race and no ref-counting. The runner's marker write is a separate short `O_APPEND` open/write/close on the same path.
|
||||
|
||||
### 5.4 Data model (`server/internal/models/workflow.go`)
|
||||
|
||||
`StepRun`:
|
||||
- **Remove** `Stdout`, `Stderr` string fields.
|
||||
- **Add** `LogOffset int64 `bson:"log_offset" json:"log_offset"`` — byte offset in the server-run file where this step's marker begins.
|
||||
|
||||
`ServerRun` gains nothing structural (its file path is derivable: `<logdir>/<run_id>/<server_id>.log`).
|
||||
|
||||
---
|
||||
|
||||
## 6. REST API (`server/internal/api/workflows.go`)
|
||||
|
||||
- `GET /api/runs/:runId/servers/:serverId/logs` — returns the whole server-run log file (`text/plain`). 404 if absent. Used post-run and as SSE fallback.
|
||||
- `GET /api/runs/:runId/servers/:serverId/logs/stream` — **SSE**. Opens the file, streams existing content as `data:` events, then polls for appends (~500ms) emitting new bytes, until the server-run status is terminal (success/failed/skipped/cancelled) AND no more bytes, then sends a final `event: done` and closes. Sets `Content-Type: text/event-stream`, disables gin's buffering. Guards against path traversal (runId/serverId are used as literal path segments — validate they are UUIDs / contain no separators).
|
||||
|
||||
Log content served by these endpoints is already masked (masking happens at write time), so no masking needed on read.
|
||||
|
||||
---
|
||||
|
||||
## 7. Retention
|
||||
|
||||
### 7.1 Setting
|
||||
|
||||
`settings` collection gains `workflow_log_retention_days int` (default 30 when unset). Read/write via the existing settings service + surfaced in `/settings` UI as a number input. `0` or negative disables sweeping (keep forever) — document this.
|
||||
|
||||
### 7.2 Sweeper (`server/internal/services/steplogs.go` or `logsweeper.go`)
|
||||
|
||||
- `StartLogSweeper()` launched at server startup (next to index setup): hourly `time.Ticker`.
|
||||
- Each tick: read retention setting; if ≤0 skip. Compute cutoff = `now - retentionDays`. For each `<logdir>/<run_id>/` dir, look up the run's `finished_at` (query `workflow_runs` by run_id); if finished and older than cutoff, `os.RemoveAll` the dir. Fallback to dir mtime if the run doc is gone.
|
||||
- Also run once at startup.
|
||||
|
||||
---
|
||||
|
||||
## 8. Frontend
|
||||
|
||||
### 8.1 API client (`web/lib/api.ts`)
|
||||
|
||||
- `StepRun`: remove `stdout`/`stderr`; add `log_offset: number`.
|
||||
- Add `getServerRunLog(runId, serverId): Promise<string>` (GET .../logs).
|
||||
- SSE consumed directly via `EventSource` in the component (not through the `request` helper), URL built from the same base.
|
||||
- Settings type gains `workflow_log_retention_days`.
|
||||
|
||||
### 8.2 Run detail (`web/app/workflows/[id]/runs/[runId]/page.tsx`)
|
||||
|
||||
- Per-server card: while the server-run is `running`, open an `EventSource` to the stream endpoint and render a live `<pre>` terminal that appends incoming chunks (auto-scroll). Close the source on `event: done`, unmount, or terminal status.
|
||||
- After completion: fetch the whole file once and render it; step `<details>` still list status/exit/attempts pills. (Per-step slicing by `log_offset` is optional polish — v1 may show the whole server log under the card and keep step pills as the status summary.)
|
||||
- Remove reliance on `st.stdout`/`st.stderr` (fields gone).
|
||||
|
||||
### 8.3 Settings (`web/app/settings/page.tsx`)
|
||||
|
||||
- Add a "Workflow log retention (days)" number input bound to `workflow_log_retention_days`, saved via the existing settings mutation. Note that `0` = keep forever.
|
||||
|
||||
---
|
||||
|
||||
## 9. Security
|
||||
|
||||
- Secret masking moves to the streaming write path but remains server-side and boundary-safe (carry buffer). Same `***` replacement.
|
||||
- Log files `0600`, dirs `0700`, under a dedicated log dir.
|
||||
- SSE/read endpoints validate `runId`/`serverId` as UUID-shaped path segments to prevent traversal; they are session-authed (same `apiGroup`).
|
||||
- Terminal `StepResult.Stderr` (error notes only) is still masked before any persistence (it is no longer persisted as log body; if surfaced, mask against secretVals).
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Per-step (rather than per-server) live SSE channels.
|
||||
- Log compression / rotation within a run, remote log storage (S3), download-as-zip.
|
||||
- Full-text search over logs.
|
||||
- Backfilling/migrating already-existing `workflow_runs` stdout/stderr into files (pre-existing runs keep whatever they had; new field just won't be set — acceptable, feature is new).
|
||||
- Tests (skipped, consistent with the Workflows iteration).
|
||||
```
|
||||
@@ -9,6 +9,9 @@ service Vantage {
|
||||
rpc SyncKeys(SyncRequest) returns (SyncResponse);
|
||||
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
|
||||
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
|
||||
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
|
||||
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
|
||||
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
|
||||
// Bidirectional stream: agent sends auth once, server pushes commands.
|
||||
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
|
||||
}
|
||||
@@ -82,6 +85,80 @@ message ReportUpdatesRequest {
|
||||
|
||||
message ReportUpdatesResponse {}
|
||||
|
||||
message CPUReport {
|
||||
string model = 1;
|
||||
int32 cores = 2;
|
||||
double usage_pct = 3;
|
||||
double load1 = 4;
|
||||
}
|
||||
|
||||
message MemReport {
|
||||
uint64 total_bytes = 1;
|
||||
uint64 used_bytes = 2;
|
||||
}
|
||||
|
||||
message PartitionReport {
|
||||
string device = 1;
|
||||
string mountpoint = 2;
|
||||
string fstype = 3;
|
||||
uint64 total_bytes = 4;
|
||||
uint64 used_bytes = 5;
|
||||
}
|
||||
|
||||
message InventoryReport {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
bool include_static = 3;
|
||||
CPUReport cpu = 4;
|
||||
MemReport memory = 5;
|
||||
uint64 swap_total = 6;
|
||||
uint64 swap_used = 7;
|
||||
repeated PartitionReport partitions = 8;
|
||||
string kernel = 9;
|
||||
}
|
||||
|
||||
message InventoryReportResponse {}
|
||||
|
||||
message MonitorSpec {
|
||||
string monitor_id = 1;
|
||||
string type = 2;
|
||||
string url = 3;
|
||||
string host = 4;
|
||||
int32 port = 5;
|
||||
string method = 6;
|
||||
int32 expected_status = 7;
|
||||
string keyword = 8;
|
||||
int32 tls_warn_days = 9;
|
||||
int32 interval_sec = 10;
|
||||
int32 retries = 11;
|
||||
bool insecure = 12;
|
||||
}
|
||||
|
||||
message SyncMonitorsRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
}
|
||||
|
||||
message SyncMonitorsResponse {
|
||||
repeated MonitorSpec monitors = 1;
|
||||
}
|
||||
|
||||
message CheckResult {
|
||||
string monitor_id = 1;
|
||||
bool up = 2;
|
||||
int32 latency_ms = 3;
|
||||
string message = 4;
|
||||
int64 cert_expiry_unix = 5;
|
||||
}
|
||||
|
||||
message ReportChecksRequest {
|
||||
string server_id = 1;
|
||||
string agent_token = 2;
|
||||
repeated CheckResult results = 3;
|
||||
}
|
||||
|
||||
message ReportChecksResponse {}
|
||||
|
||||
message ApplyUpdatesCmd {}
|
||||
|
||||
message ServerCommand {
|
||||
@@ -92,9 +169,16 @@ message ServerCommand {
|
||||
UpdateAgentCmd update_agent = 4;
|
||||
ApplyUpdatesCmd apply_updates = 5;
|
||||
RunStepCmd run_step = 6;
|
||||
CleanupWorkspaceCmd cleanup_workspace = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
message CleanupWorkspaceCmd {
|
||||
string workspace_id = 1;
|
||||
}
|
||||
|
||||
message DeleteKeyCmd {
|
||||
string label = 1;
|
||||
}
|
||||
@@ -117,6 +201,7 @@ message RunStepCmd {
|
||||
string script = 2;
|
||||
map<string, string> env = 3;
|
||||
int32 timeout_seconds = 4;
|
||||
string workspace_id = 5; // per-run working dir the agent creates & uses as cwd
|
||||
}
|
||||
|
||||
message StepResult {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mrhid6/vantage/server/internal/auth"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
grpcserver "github.com/mrhid6/vantage/server/internal/grpc"
|
||||
"github.com/mrhid6/vantage/server/internal/monitorsched"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,14 @@ func main() {
|
||||
log.Printf("warning: failed to ensure workflow indexes: %v", err)
|
||||
}
|
||||
|
||||
if created, updated, err := services.SeedDefaultSteps(); err != nil {
|
||||
log.Printf("warning: failed to seed default steps: %v", err)
|
||||
} else {
|
||||
log.Printf("default steps seeded: %d created, %d updated", created, updated)
|
||||
}
|
||||
|
||||
services.StartLogSweeper()
|
||||
|
||||
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||
if err := auth.InitRedis(redisAddr); err != nil {
|
||||
log.Fatalf("failed to connect to Redis: %v", err)
|
||||
@@ -59,6 +68,9 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the server-side monitor scheduler.
|
||||
monitorsched.Start(context.Background())
|
||||
|
||||
// Start REST server
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func registerChannelRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/channels", listChannels)
|
||||
g.POST("/channels", createChannel)
|
||||
g.PUT("/channels/:id", updateChannel)
|
||||
g.DELETE("/channels/:id", deleteChannel)
|
||||
g.POST("/channels/:id/test", testChannel)
|
||||
}
|
||||
|
||||
func listChannels(c *gin.Context) {
|
||||
channels, err := services.ListChannels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
}
|
||||
|
||||
func createChannel(c *gin.Context) {
|
||||
var ch models.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ch.Name == "" || ch.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateChannel(&ch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func updateChannel(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Config *map[string]string `json:"config"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
upd := bson.M{}
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
if body.Config != nil {
|
||||
upd["config"] = *body.Config
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
upd["enabled"] = *body.Enabled
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateChannel(c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func deleteChannel(c *gin.Context) {
|
||||
if err := services.DeleteChannel(c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func testChannel(c *gin.Context) {
|
||||
if err := services.TestChannel(c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
}
|
||||
@@ -80,6 +80,8 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.GET("/console/tunnel", consoleTunnel)
|
||||
|
||||
registerWorkflowRoutes(apiGroup)
|
||||
registerMonitorRoutes(apiGroup)
|
||||
registerChannelRoutes(apiGroup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,14 +452,15 @@ func getSettings(c *gin.Context) {
|
||||
|
||||
func saveSettings(c *gin.Context) {
|
||||
var body struct {
|
||||
Alerts models.AlertSettings `json:"alerts"`
|
||||
Email models.EmailSettings `json:"email"`
|
||||
Alerts models.AlertSettings `json:"alerts"`
|
||||
Email models.EmailSettings `json:"email"`
|
||||
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := services.SaveSettings(body.Alerts, body.Email); err != nil {
|
||||
if err := services.SaveSettings(body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func registerMonitorRoutes(g *gin.RouterGroup) {
|
||||
g.GET("/monitors", listMonitors)
|
||||
g.POST("/monitors", createMonitor)
|
||||
g.GET("/monitors/:id", getMonitor)
|
||||
g.PUT("/monitors/:id", updateMonitor)
|
||||
g.DELETE("/monitors/:id", deleteMonitor)
|
||||
g.GET("/monitors/:id/incidents", getMonitorIncidents)
|
||||
g.GET("/monitors/:id/uptime", getMonitorUptime)
|
||||
}
|
||||
|
||||
func listMonitors(c *gin.Context) {
|
||||
monitors, err := services.ListMonitors()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, monitors)
|
||||
}
|
||||
|
||||
func createMonitor(c *gin.Context) {
|
||||
var m models.Monitor
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m.Name == "" || m.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateMonitor(&m)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func getMonitor(c *gin.Context) {
|
||||
m, err := services.GetMonitor(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func updateMonitor(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Target *models.MonitorTarget `json:"target"`
|
||||
IntervalSec *int `json:"interval_sec"`
|
||||
Runner *string `json:"runner"`
|
||||
Retries *int `json:"retries"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ChannelIDs *[]string `json:"channel_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
upd := bson.M{}
|
||||
if body.Name != nil {
|
||||
upd["name"] = *body.Name
|
||||
}
|
||||
if body.Type != nil {
|
||||
upd["type"] = *body.Type
|
||||
}
|
||||
if body.Target != nil {
|
||||
upd["target"] = *body.Target
|
||||
}
|
||||
if body.IntervalSec != nil {
|
||||
upd["interval_sec"] = *body.IntervalSec
|
||||
}
|
||||
if body.Runner != nil {
|
||||
upd["runner"] = *body.Runner
|
||||
}
|
||||
if body.Retries != nil {
|
||||
upd["retries"] = *body.Retries
|
||||
}
|
||||
if body.Enabled != nil {
|
||||
upd["enabled"] = *body.Enabled
|
||||
}
|
||||
if body.ChannelIDs != nil {
|
||||
upd["channel_ids"] = *body.ChannelIDs
|
||||
}
|
||||
if len(upd) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
if err := services.UpdateMonitor(c.Param("id"), upd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func deleteMonitor(c *gin.Context) {
|
||||
if err := services.DeleteMonitor(c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func getMonitorIncidents(c *gin.Context) {
|
||||
incidents, err := services.ListIncidents(c.Param("id"), 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, incidents)
|
||||
}
|
||||
|
||||
func getMonitorUptime(c *gin.Context) {
|
||||
since := time.Now().Add(-30 * 24 * time.Hour)
|
||||
rollups, err := services.UptimeRollups(c.Param("id"), since)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rollups)
|
||||
}
|
||||
@@ -2,8 +2,13 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
@@ -15,6 +20,11 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
g.POST("/steps", createStep)
|
||||
g.PUT("/steps/:id", updateStep)
|
||||
g.DELETE("/steps/:id", deleteStep)
|
||||
g.GET("/steps/:id/export", exportStep)
|
||||
g.POST("/steps/import", importStep)
|
||||
g.POST("/steps/seed-defaults", seedDefaults)
|
||||
g.GET("/steps/usage", stepUsage)
|
||||
g.POST("/steps/parse", parseStep)
|
||||
|
||||
g.GET("/workflows", listWorkflows)
|
||||
g.POST("/workflows", createWorkflow)
|
||||
@@ -26,6 +36,114 @@ func registerWorkflowRoutes(g *gin.RouterGroup) {
|
||||
|
||||
g.GET("/runs/:runId", getRun)
|
||||
g.POST("/runs/:runId/cancel", cancelRun)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
|
||||
g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
|
||||
}
|
||||
|
||||
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
|
||||
|
||||
func getServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", b)
|
||||
}
|
||||
|
||||
func streamServerRunLog(c *gin.Context) {
|
||||
runID, serverID := c.Param("runId"), c.Param("serverId")
|
||||
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
path := services.ServerRunLogPath(runID, serverID)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "stream unsupported"})
|
||||
return
|
||||
}
|
||||
|
||||
var offset int64
|
||||
sendNew := func() {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return // file may not exist yet; keep waiting
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Seek(offset, 0); err != nil {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, _ := f.Read(buf)
|
||||
if n <= 0 {
|
||||
break
|
||||
}
|
||||
offset += int64(n)
|
||||
// SSE data frame; split on newlines to keep frames well-formed.
|
||||
for _, line := range splitSSE(buf[:n]) {
|
||||
_, _ = c.Writer.WriteString("data: " + line + "\n")
|
||||
}
|
||||
_, _ = c.Writer.WriteString("\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
sendNew()
|
||||
if serverRunTerminal(runID, serverID) {
|
||||
sendNew() // final drain
|
||||
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverRunTerminal reports whether the given server-run has reached a terminal status.
|
||||
func serverRunTerminal(runID, serverID string) bool {
|
||||
r, err := services.GetRun(runID)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, sr := range r.ServerRuns {
|
||||
if sr.ServerID == serverID {
|
||||
switch sr.Status {
|
||||
case "success", "failed", "skipped", "cancelled":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitSSE turns a raw byte slice into SSE-safe payload lines (newlines become
|
||||
// separate data lines; carriage returns stripped).
|
||||
func splitSSE(b []byte) []string {
|
||||
s := strings.ReplaceAll(string(b), "\r", "")
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func listSteps(c *gin.Context) {
|
||||
@@ -37,6 +155,15 @@ func listSteps(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, steps)
|
||||
}
|
||||
|
||||
func stepUsage(c *gin.Context) {
|
||||
counts, err := services.StepUsageCounts()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
|
||||
func createStep(c *gin.Context) {
|
||||
var s models.WorkflowStep
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
@@ -75,6 +202,61 @@ func deleteStep(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func exportStep(c *gin.Context) {
|
||||
b, err := services.ExportStep(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=step-%s.json", c.Param("id")))
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
func seedDefaults(c *gin.Context) {
|
||||
created, updated, err := services.SeedDefaultSteps()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
|
||||
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
|
||||
}
|
||||
|
||||
const maxStepBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
func importStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := services.ImportStepToLibrary(body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// parseStep validates a step doc and returns the normalized step WITHOUT
|
||||
// persisting — used by the editor to insert an imported ad-hoc (inline) step.
|
||||
func parseStep(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s, err := services.ParseStepDoc(body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func listWorkflows(c *gin.Context) {
|
||||
wfs, err := services.ListWorkflows()
|
||||
if err != nil {
|
||||
@@ -119,7 +301,12 @@ func updateWorkflow(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
updated, err := services.GetWorkflow(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func deleteWorkflow(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform
|
||||
// Result. It has no dependency on models or pb so it can be duplicated verbatim
|
||||
// into the agent module (agent-run monitors) — callers map their own monitor
|
||||
// representation onto Spec.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check types (mirror models.Monitor* constants).
|
||||
const (
|
||||
TypeHTTP = "http"
|
||||
TypeTCP = "tcp"
|
||||
TypeICMP = "icmp"
|
||||
TypeTLS = "tls"
|
||||
)
|
||||
|
||||
// Spec is a self-contained description of a single check.
|
||||
type Spec struct {
|
||||
Type string
|
||||
URL string
|
||||
Host string
|
||||
Port int
|
||||
Method string
|
||||
ExpectedStatus int
|
||||
Keyword string
|
||||
TLSWarnDays int
|
||||
Insecure bool // skip TLS certificate verification (HTTP checks)
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
// Result is the uniform outcome of running a check.
|
||||
type Result struct {
|
||||
Up bool
|
||||
LatencyMs int
|
||||
Message string
|
||||
CertExpiry *time.Time
|
||||
}
|
||||
|
||||
func (s Spec) timeout() time.Duration {
|
||||
t := s.TimeoutSec
|
||||
if t <= 0 || t > 10 {
|
||||
t = 10
|
||||
}
|
||||
return time.Duration(t) * time.Second
|
||||
}
|
||||
|
||||
// Run executes the check described by s.
|
||||
func Run(ctx context.Context, s Spec) Result {
|
||||
switch s.Type {
|
||||
case TypeHTTP:
|
||||
return runHTTP(ctx, s)
|
||||
case TypeTCP:
|
||||
return runTCP(ctx, s)
|
||||
case TypeICMP:
|
||||
return runICMP(ctx, s)
|
||||
case TypeTLS:
|
||||
return runTLS(ctx, s)
|
||||
default:
|
||||
return Result{Message: "unknown check type: " + s.Type}
|
||||
}
|
||||
}
|
||||
|
||||
func runHTTP(ctx context.Context, s Spec) Result {
|
||||
method := s.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
expect := s.ExpectedStatus
|
||||
if expect == 0 {
|
||||
expect = 200
|
||||
}
|
||||
client := &http.Client{Timeout: s.timeout()}
|
||||
if s.Insecure {
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor
|
||||
}
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
res := Result{LatencyMs: msSince(start), Up: true}
|
||||
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||
exp := resp.TLS.PeerCertificates[0].NotAfter
|
||||
res.CertExpiry = &exp
|
||||
}
|
||||
if resp.StatusCode != expect {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
|
||||
}
|
||||
if s.Keyword != "" {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if !strings.Contains(string(body), s.Keyword) {
|
||||
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func runTCP(ctx context.Context, s Spec) Result {
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
conn.Close()
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
|
||||
func runTLS(ctx context.Context, s Spec) Result {
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
|
||||
start := time.Now()
|
||||
d := net.Dialer{Timeout: s.timeout()}
|
||||
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
|
||||
}
|
||||
exp := certs[0].NotAfter
|
||||
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
|
||||
warn := s.TLSWarnDays
|
||||
if warn <= 0 {
|
||||
warn = 14
|
||||
}
|
||||
remaining := time.Until(exp)
|
||||
if remaining <= 0 {
|
||||
res.Message = "certificate expired"
|
||||
return res
|
||||
}
|
||||
if remaining <= time.Duration(warn)*24*time.Hour {
|
||||
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
|
||||
return res
|
||||
}
|
||||
res.Up = true
|
||||
return res
|
||||
}
|
||||
|
||||
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
|
||||
|
||||
// runICMP sends a single ICMP echo request and waits for the reply. Requires
|
||||
// raw-socket privileges (the agent and server run as root). Returns down with a
|
||||
// descriptive message when the socket cannot be opened or no reply arrives.
|
||||
func runICMP(ctx context.Context, s Spec) Result {
|
||||
dst, err := net.ResolveIPAddr("ip4", s.Host)
|
||||
if err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err != nil {
|
||||
return Result{Message: "icmp socket: " + err.Error()}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
id := os.Getpid() & 0xffff
|
||||
pkt := icmpEcho(id, 1)
|
||||
deadline := time.Now().Add(s.timeout())
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.WriteTo(pkt, dst); err != nil {
|
||||
return Result{Message: err.Error()}
|
||||
}
|
||||
reply := make([]byte, 1500)
|
||||
for {
|
||||
n, peer, err := conn.ReadFrom(reply)
|
||||
if err != nil {
|
||||
return Result{LatencyMs: msSince(start), Message: "no reply"}
|
||||
}
|
||||
// Skip the IPv4 header (20 bytes) to reach the ICMP message.
|
||||
if n < 28 || peer.String() != dst.String() {
|
||||
continue
|
||||
}
|
||||
if reply[20] == 0 { // ICMP echo reply type
|
||||
return Result{Up: true, LatencyMs: msSince(start)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func icmpEcho(id, seq int) []byte {
|
||||
// Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload.
|
||||
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
|
||||
cs := icmpChecksum(b)
|
||||
b[2] = byte(cs >> 8)
|
||||
b[3] = byte(cs)
|
||||
return b
|
||||
}
|
||||
|
||||
func icmpChecksum(b []byte) uint16 {
|
||||
var sum uint32
|
||||
for i := 0; i < len(b)-1; i += 2 {
|
||||
sum += uint32(b[i])<<8 | uint32(b[i+1])
|
||||
}
|
||||
if len(b)%2 == 1 {
|
||||
sum += uint32(b[len(b)-1]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
@@ -63,15 +63,91 @@ type ReportUpdatesRequest struct {
|
||||
|
||||
type ReportUpdatesResponse struct{}
|
||||
|
||||
// Inventory report message types
|
||||
|
||||
type CPUReport struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Load1 float64 `json:"load1,omitempty"`
|
||||
}
|
||||
type MemReport struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type PartitionReport struct {
|
||||
Device string `json:"device"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
}
|
||||
type InventoryReport struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
IncludeStatic bool `json:"include_static"`
|
||||
CPU *CPUReport `json:"cpu,omitempty"`
|
||||
Memory *MemReport `json:"memory,omitempty"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
Partitions []PartitionReport `json:"partitions,omitempty"`
|
||||
Kernel string `json:"kernel,omitempty"`
|
||||
}
|
||||
type InventoryReportResponse struct{}
|
||||
|
||||
// Monitor sync / check report message types
|
||||
|
||||
type MonitorSpec struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ExpectedStatus int `json:"expected_status,omitempty"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
TLSWarnDays int `json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `json:"insecure,omitempty"`
|
||||
IntervalSec int `json:"interval_sec"`
|
||||
Retries int `json:"retries"`
|
||||
}
|
||||
type SyncMonitorsRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
}
|
||||
type SyncMonitorsResponse struct {
|
||||
Monitors []MonitorSpec `json:"monitors,omitempty"`
|
||||
}
|
||||
type CheckResult struct {
|
||||
MonitorId string `json:"monitor_id"`
|
||||
Up bool `json:"up"`
|
||||
LatencyMs int `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
|
||||
}
|
||||
type ReportChecksRequest struct {
|
||||
ServerId string `json:"server_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
Results []CheckResult `json:"results,omitempty"`
|
||||
}
|
||||
type ReportChecksResponse struct{}
|
||||
|
||||
type ApplyUpdatesCmd struct{}
|
||||
|
||||
type ServerCommand struct {
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CommandId string `json:"command_id"`
|
||||
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
|
||||
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
|
||||
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
|
||||
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
|
||||
RunStep *RunStepCmd `json:"run_step,omitempty"`
|
||||
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
|
||||
// directory once all steps on that server have finished.
|
||||
type CleanupWorkspaceCmd struct {
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
type DeleteKeyCmd struct {
|
||||
@@ -113,6 +189,9 @@ type RunStepCmd struct {
|
||||
Script string `json:"script"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
|
||||
// WorkspaceId names the per-run working directory the agent creates and uses
|
||||
// as the step's cwd. Empty means run in the agent's default directory.
|
||||
WorkspaceId string `json:"workspace_id,omitempty"`
|
||||
}
|
||||
|
||||
type StepResult struct {
|
||||
@@ -185,6 +264,9 @@ type VantageServer interface {
|
||||
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
|
||||
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
|
||||
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
|
||||
ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)
|
||||
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
|
||||
CommandStream(Vantage_CommandStreamServer) error
|
||||
}
|
||||
|
||||
@@ -206,6 +288,18 @@ func (UnimplementedVantageServer) ReportUpdates(context.Context, *ReportUpdatesR
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportUpdates not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportInventory not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SyncMonitors not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReportChecks not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
|
||||
}
|
||||
@@ -217,6 +311,9 @@ type VantageClient interface {
|
||||
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
|
||||
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
|
||||
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
|
||||
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
|
||||
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
|
||||
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
|
||||
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
|
||||
}
|
||||
|
||||
@@ -260,6 +357,30 @@ func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
|
||||
out := new(InventoryReportResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
|
||||
out := new(SyncMonitorsResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
|
||||
out := new(ReportChecksResponse)
|
||||
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[0], "/vantage.v1.Vantage/CommandStream", opts...)
|
||||
if err != nil {
|
||||
@@ -282,6 +403,9 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
|
||||
{MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler},
|
||||
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
|
||||
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
|
||||
{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler},
|
||||
{MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler},
|
||||
{MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
@@ -354,6 +478,51 @@ func _Vantage_ReportUpdates_Handler(srv interface{}, ctx context.Context, dec fu
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_ReportInventory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(InventoryReport)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).ReportInventory(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportInventory"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).ReportInventory(ctx, req.(*InventoryReport))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_SyncMonitors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SyncMonitorsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).SyncMonitors(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncMonitors"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).SyncMonitors(ctx, req.(*SyncMonitorsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_ReportChecks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ReportChecksRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(VantageServer).ReportChecks(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportChecks"}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(VantageServer).ReportChecks(ctx, req.(*ReportChecksRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Vantage_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(VantageServer).CommandStream(&keyManagerCommandStreamServer{stream})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/checker"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
@@ -95,6 +96,63 @@ func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdates
|
||||
return &pb.ReportUpdatesResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
if err := services.StoreInventory(srv.ServerID, req); err != nil {
|
||||
log.Printf("store inventory for %s: %v", srv.ServerID, err)
|
||||
}
|
||||
return &pb.InventoryReportResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRequest) (*pb.SyncMonitorsResponse, error) {
|
||||
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
monitors, err := services.ListMonitorsForRunner(srv.ServerID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "list monitors")
|
||||
}
|
||||
specs := make([]pb.MonitorSpec, 0, len(monitors))
|
||||
for _, m := range monitors {
|
||||
specs = append(specs, pb.MonitorSpec{
|
||||
MonitorId: m.MonitorID,
|
||||
Type: m.Type,
|
||||
URL: m.Target.URL,
|
||||
Host: m.Target.Host,
|
||||
Port: m.Target.Port,
|
||||
Method: m.Target.Method,
|
||||
ExpectedStatus: m.Target.ExpectedStatus,
|
||||
Keyword: m.Target.Keyword,
|
||||
TLSWarnDays: m.Target.TLSWarnDays,
|
||||
Insecure: m.Target.Insecure,
|
||||
IntervalSec: m.IntervalSec,
|
||||
Retries: m.Retries,
|
||||
})
|
||||
}
|
||||
return &pb.SyncMonitorsResponse{Monitors: specs}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRequest) (*pb.ReportChecksResponse, error) {
|
||||
if _, err := services.ValidateAgentToken(req.ServerId, req.AgentToken); err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
|
||||
}
|
||||
for _, r := range req.Results {
|
||||
res := checker.Result{Up: r.Up, LatencyMs: r.LatencyMs, Message: r.Message}
|
||||
if r.CertExpiryUnix > 0 {
|
||||
t := time.Unix(r.CertExpiryUnix, 0)
|
||||
res.CertExpiry = &t
|
||||
}
|
||||
if err := services.IngestResult(r.MonitorId, res); err != nil {
|
||||
log.Printf("ingest check %s: %v", r.MonitorId, err)
|
||||
}
|
||||
}
|
||||
return &pb.ReportChecksResponse{}, nil
|
||||
}
|
||||
|
||||
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
|
||||
// First message authenticates the agent and signals readiness.
|
||||
msg, err := stream.Recv()
|
||||
@@ -132,6 +190,13 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
|
||||
if m.StepResult != nil {
|
||||
services.StepResults.Deliver(m.StepResult)
|
||||
}
|
||||
if m.StepOutput != nil {
|
||||
if m.StepOutput.Eof {
|
||||
services.StepLogs.Close(m.StepOutput.CommandId)
|
||||
} else {
|
||||
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Notification channel types.
|
||||
const (
|
||||
ChannelWebhook = "webhook"
|
||||
ChannelSMTP = "smtp"
|
||||
ChannelDiscord = "discord"
|
||||
ChannelSlack = "slack"
|
||||
ChannelTelegram = "telegram"
|
||||
)
|
||||
|
||||
// NotificationChannel is an outbound alert destination. Config holds
|
||||
// type-specific settings (e.g. url; or smtp host/port/username/password/from/to;
|
||||
// or telegram token/chat_id).
|
||||
type NotificationChannel struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ChannelID string `bson:"channel_id" json:"channel_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"`
|
||||
Config map[string]string `bson:"config" json:"config"`
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Monitor check types.
|
||||
const (
|
||||
MonitorHTTP = "http"
|
||||
MonitorTCP = "tcp"
|
||||
MonitorICMP = "icmp"
|
||||
MonitorTLS = "tls"
|
||||
)
|
||||
|
||||
// Monitor status values.
|
||||
const (
|
||||
StatusUp = "up"
|
||||
StatusDown = "down"
|
||||
StatusPending = "pending"
|
||||
)
|
||||
|
||||
// RunnerServer is the reserved Runner value for server-run monitors. Any other
|
||||
// value is treated as a server_id whose agent runs the check locally.
|
||||
const RunnerServer = "server"
|
||||
|
||||
type MonitorTarget struct {
|
||||
URL string `bson:"url,omitempty" json:"url,omitempty"`
|
||||
Host string `bson:"host,omitempty" json:"host,omitempty"`
|
||||
Port int `bson:"port,omitempty" json:"port,omitempty"`
|
||||
Method string `bson:"method,omitempty" json:"method,omitempty"`
|
||||
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
|
||||
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
|
||||
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
|
||||
Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"` // skip TLS cert verification (HTTP monitors)
|
||||
}
|
||||
|
||||
type MonitorState struct {
|
||||
Status string `bson:"status" json:"status"` // up|down|pending
|
||||
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
|
||||
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
|
||||
Message string `bson:"message,omitempty" json:"message,omitempty"`
|
||||
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
|
||||
Fails int `bson:"fails" json:"fails"` // consecutive failures
|
||||
LastNotifiedAt *time.Time `bson:"last_notified_at,omitempty" json:"last_notified_at,omitempty"`
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
|
||||
Target MonitorTarget `bson:"target" json:"target"`
|
||||
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
|
||||
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
|
||||
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
|
||||
State MonitorState `bson:"state" json:"state"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
type Incident struct {
|
||||
IncidentID string `bson:"incident_id" json:"incident_id"`
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
StartedAt time.Time `bson:"started_at" json:"started_at"`
|
||||
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
|
||||
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
|
||||
}
|
||||
|
||||
type Rollup struct {
|
||||
MonitorID string `bson:"monitor_id" json:"monitor_id"`
|
||||
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
|
||||
Checks int `bson:"checks" json:"checks"`
|
||||
UpCount int `bson:"up_count" json:"up_count"`
|
||||
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
|
||||
}
|
||||
@@ -12,6 +12,37 @@ type PackageUpdate struct {
|
||||
NewVersion string `bson:"new_version" json:"new_version"`
|
||||
}
|
||||
|
||||
type CPUInfo struct {
|
||||
Model string `bson:"model,omitempty" json:"model,omitempty"`
|
||||
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
|
||||
UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
|
||||
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Partition struct {
|
||||
Device string `bson:"device" json:"device"`
|
||||
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
|
||||
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
|
||||
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
|
||||
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
|
||||
}
|
||||
|
||||
type Inventory struct {
|
||||
CPU CPUInfo `bson:"cpu" json:"cpu"`
|
||||
Memory MemInfo `bson:"memory" json:"memory"`
|
||||
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
|
||||
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
|
||||
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
|
||||
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
|
||||
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
ServerID string `bson:"server_id" json:"server_id"`
|
||||
@@ -30,5 +61,6 @@ type Server struct {
|
||||
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
|
||||
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
|
||||
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
|
||||
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -36,4 +36,6 @@ type Settings struct {
|
||||
Alerts AlertSettings `bson:"alerts" json:"alerts"`
|
||||
Email EmailSettings `bson:"email" json:"email"`
|
||||
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
|
||||
// WorkflowLogRetentionDays: nil = default 30, 0 = keep forever, N = N days.
|
||||
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type InputParam struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Default string `bson:"default" json:"default"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
}
|
||||
|
||||
type WorkflowStep struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
|
||||
StepID string `bson:"step_id" json:"step_id"`
|
||||
@@ -14,17 +20,22 @@ type WorkflowStep struct {
|
||||
Interpreter string `bson:"interpreter" json:"interpreter"` // "bash" | "powershell"
|
||||
Script string `bson:"script" json:"script"`
|
||||
DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"`
|
||||
DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
Source string `bson:"source" json:"source"` // "user" | "default"
|
||||
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkflowStepRef struct {
|
||||
StepID string `bson:"step_id" json:"step_id"`
|
||||
Order int `bson:"order" json:"order"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry"
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"`
|
||||
StepID string `bson:"step_id,omitempty" json:"step_id,omitempty"`
|
||||
Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"`
|
||||
Order int `bson:"order" json:"order"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry"
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"`
|
||||
Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
type StepOverride struct {
|
||||
@@ -48,9 +59,10 @@ type ResolvedStep struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Interpreter string `bson:"interpreter" json:"interpreter"`
|
||||
Script string `bson:"script" json:"script"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
|
||||
OnFailure string `bson:"on_failure" json:"on_failure"`
|
||||
MaxRetries int `bson:"max_retries" json:"max_retries"`
|
||||
Inputs map[string]string `bson:"inputs" json:"inputs"`
|
||||
}
|
||||
|
||||
type StepRun struct {
|
||||
@@ -59,8 +71,7 @@ type StepRun struct {
|
||||
Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped
|
||||
Attempts int `bson:"attempts" json:"attempts"`
|
||||
ExitCode int `bson:"exit_code" json:"exit_code"`
|
||||
Stdout string `bson:"stdout" json:"stdout"`
|
||||
Stderr string `bson:"stderr" json:"stderr"`
|
||||
LogOffset int64 `bson:"log_offset" json:"log_offset"`
|
||||
OutputEnv map[string]string `bson:"output_env" json:"output_env"`
|
||||
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package monitorsched runs server-side monitors on their configured interval
|
||||
// and funnels results through services.IngestResult. Agent-run monitors
|
||||
// (runner != "server") are excluded — those execute on the agent.
|
||||
package monitorsched
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/checker"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/services"
|
||||
)
|
||||
|
||||
// reloadInterval controls how often the scheduler re-reads monitor definitions
|
||||
// so CRUD changes (new/removed/edited monitors) take effect.
|
||||
const reloadInterval = 30 * time.Second
|
||||
|
||||
type runner struct {
|
||||
monitorID string
|
||||
intervalSec int
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Start launches the scheduler loop. It returns immediately; the loop runs until
|
||||
// ctx is cancelled.
|
||||
func Start(ctx context.Context) {
|
||||
go loop(ctx)
|
||||
}
|
||||
|
||||
func loop(ctx context.Context) {
|
||||
active := map[string]*runner{}
|
||||
var mu sync.Mutex
|
||||
|
||||
sync := func() {
|
||||
monitors, err := services.ListMonitorsForRunner(models.RunnerServer)
|
||||
if err != nil {
|
||||
log.Printf("monitorsched: list monitors: %v", err)
|
||||
return
|
||||
}
|
||||
want := map[string]models.Monitor{}
|
||||
for _, m := range monitors {
|
||||
want[m.MonitorID] = m
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
// Stop runners for monitors that vanished or changed interval.
|
||||
for id, r := range active {
|
||||
m, ok := want[id]
|
||||
if !ok || m.IntervalSec != r.intervalSec {
|
||||
r.cancel()
|
||||
delete(active, id)
|
||||
}
|
||||
}
|
||||
// Start runners for new/changed monitors.
|
||||
for id, m := range want {
|
||||
if _, ok := active[id]; ok {
|
||||
continue
|
||||
}
|
||||
rctx, cancel := context.WithCancel(ctx)
|
||||
active[id] = &runner{monitorID: id, intervalSec: m.IntervalSec, cancel: cancel}
|
||||
go runMonitor(rctx, m)
|
||||
}
|
||||
}
|
||||
|
||||
sync()
|
||||
t := time.NewTicker(reloadInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runMonitor(ctx context.Context, m models.Monitor) {
|
||||
interval := time.Duration(m.IntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 60 * time.Second
|
||||
}
|
||||
spec := services.SpecFor(&m)
|
||||
|
||||
run := func() {
|
||||
res := checker.Run(ctx, spec)
|
||||
if err := services.IngestResult(m.MonitorID, res); err != nil {
|
||||
log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err)
|
||||
}
|
||||
}
|
||||
|
||||
run() // check immediately on (re)start
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package notify formats and delivers monitor state-change alerts to
|
||||
// notification channels. It depends only on models so services can call it
|
||||
// without an import cycle.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
// Event describes a monitor state transition worth alerting on.
|
||||
type Event struct {
|
||||
MonitorName string
|
||||
Type string
|
||||
OldStatus string
|
||||
NewStatus string
|
||||
Message string
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// title is a short one-line summary used by the text-based channels.
|
||||
func (e Event) title() string {
|
||||
verb := "recovered"
|
||||
if e.NewStatus == models.StatusDown {
|
||||
verb = "is DOWN"
|
||||
}
|
||||
s := fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
|
||||
if e.Message != "" {
|
||||
s += ": " + e.Message
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Dispatch delivers ev to a single channel, formatting per channel type.
|
||||
func Dispatch(ch models.NotificationChannel, ev Event) error {
|
||||
switch ch.Type {
|
||||
case models.ChannelWebhook:
|
||||
return dispatchWebhook(ch, ev)
|
||||
case models.ChannelDiscord:
|
||||
return dispatchDiscord(ch, ev)
|
||||
case models.ChannelSlack:
|
||||
return dispatchSlack(ch, ev)
|
||||
case models.ChannelTelegram:
|
||||
return dispatchTelegram(ch, ev)
|
||||
case models.ChannelSMTP:
|
||||
return dispatchSMTP(ch, ev)
|
||||
default:
|
||||
return fmt.Errorf("unknown channel type: %s", ch.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// Test delivers a synthetic event so users can verify a channel's configuration.
|
||||
func Test(ch models.NotificationChannel) error {
|
||||
return Dispatch(ch, Event{
|
||||
MonitorName: "Test monitor",
|
||||
Type: "http",
|
||||
OldStatus: models.StatusUp,
|
||||
NewStatus: models.StatusDown,
|
||||
Message: "this is a test alert from Vantage",
|
||||
Time: time.Now(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func postJSON(target string, payload any) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Post(target, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatchWebhook posts the full event as JSON to a user-supplied URL.
|
||||
func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
|
||||
target := ch.Config["url"]
|
||||
if target == "" {
|
||||
return fmt.Errorf("webhook: missing url")
|
||||
}
|
||||
return postJSON(target, map[string]any{
|
||||
"monitor": ev.MonitorName,
|
||||
"type": ev.Type,
|
||||
"old_status": ev.OldStatus,
|
||||
"new_status": ev.NewStatus,
|
||||
"message": ev.Message,
|
||||
"time": ev.Time.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func dispatchDiscord(ch models.NotificationChannel, ev Event) error {
|
||||
target := ch.Config["url"]
|
||||
if target == "" {
|
||||
return fmt.Errorf("discord: missing url")
|
||||
}
|
||||
return postJSON(target, map[string]string{"content": ev.title()})
|
||||
}
|
||||
|
||||
func dispatchSlack(ch models.NotificationChannel, ev Event) error {
|
||||
target := ch.Config["url"]
|
||||
if target == "" {
|
||||
return fmt.Errorf("slack: missing url")
|
||||
}
|
||||
return postJSON(target, map[string]string{"text": ev.title()})
|
||||
}
|
||||
|
||||
func dispatchTelegram(ch models.NotificationChannel, ev Event) error {
|
||||
token := ch.Config["token"]
|
||||
chatID := ch.Config["chat_id"]
|
||||
if token == "" || chatID == "" {
|
||||
return fmt.Errorf("telegram: missing token or chat_id")
|
||||
}
|
||||
api := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
|
||||
return postJSON(api, map[string]string{"chat_id": chatID, "text": ev.title()})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
const smtpTimeout = 15 * time.Second
|
||||
|
||||
// dispatchSMTP sends the alert as a plain-text email. Config keys: host, port,
|
||||
// username, password, from, to. Auth is skipped when username is empty. Port 465
|
||||
// uses implicit TLS; other ports use STARTTLS when the server advertises it.
|
||||
//
|
||||
// It dials with a timeout and sets a connection deadline so an unreachable or
|
||||
// misconfigured SMTP host fails fast instead of hanging the request until the OS
|
||||
// TCP timeout (which resets the upstream proxy connection).
|
||||
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
|
||||
host := ch.Config["host"]
|
||||
port := ch.Config["port"]
|
||||
from := ch.Config["from"]
|
||||
to := ch.Config["to"]
|
||||
if host == "" || port == "" || from == "" || to == "" {
|
||||
return fmt.Errorf("smtp: missing host/port/from/to")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
conn, err := net.DialTimeout("tcp", addr, smtpTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp: dial %s: %w", addr, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
|
||||
|
||||
// Implicit TLS on 465; otherwise start plain and upgrade via STARTTLS.
|
||||
if port == "465" {
|
||||
conn = tls.Client(conn, &tls.Config{ServerName: host})
|
||||
}
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("smtp: client: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if port != "465" {
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
return fmt.Errorf("smtp: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user := ch.Config["username"]; user != "" {
|
||||
if err := c.Auth(smtp.PlainAuth("", user, ch.Config["password"], host)); err != nil {
|
||||
return fmt.Errorf("smtp: auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
recipients := strings.Split(to, ",")
|
||||
for i := range recipients {
|
||||
recipients[i] = strings.TrimSpace(recipients[i])
|
||||
}
|
||||
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("smtp: mail from: %w", err)
|
||||
}
|
||||
for _, rcpt := range recipients {
|
||||
if rcpt == "" {
|
||||
continue
|
||||
}
|
||||
if err := c.Rcpt(rcpt); err != nil {
|
||||
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
|
||||
}
|
||||
}
|
||||
|
||||
title := ev.title()
|
||||
msg := strings.Join([]string{
|
||||
"From: " + from,
|
||||
"To: " + to,
|
||||
"Subject: " + title,
|
||||
"",
|
||||
title,
|
||||
"",
|
||||
"Monitor: " + ev.MonitorName,
|
||||
"Status: " + ev.OldStatus + " -> " + ev.NewStatus,
|
||||
"Time: " + ev.Time.String(),
|
||||
}, "\r\n")
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp: data: %w", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return fmt.Errorf("smtp: write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp: close data: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/notify"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func ListChannels() ([]models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.NotificationChannel
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetChannel(channelID string) (*models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
var ch models.NotificationChannel
|
||||
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID}).Decode(&ch)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ch, nil
|
||||
}
|
||||
|
||||
// GetChannels loads multiple channels by ID, skipping any not found.
|
||||
func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"channel_id": bson.M{"$in": channelIDs}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.NotificationChannel
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
ch.ChannelID = uuid.NewString()
|
||||
ch.CreatedAt = time.Now()
|
||||
if ch.Config == nil {
|
||||
ch.Config = map[string]string{}
|
||||
}
|
||||
if _, err := db.Col("notification_channels").InsertOne(ctx, ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func UpdateChannel(channelID string, upd bson.M) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteChannel(channelID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID})
|
||||
return err
|
||||
}
|
||||
|
||||
// TestChannel sends a synthetic alert to verify configuration.
|
||||
func TestChannel(channelID string) error {
|
||||
ch, err := GetChannel(channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ch == nil {
|
||||
return errors.New("channel not found")
|
||||
}
|
||||
return notify.Test(*ch)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// DefaultStepsDir returns the directory holding default step JSON files.
|
||||
func DefaultStepsDir() string {
|
||||
dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR")
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "default-steps")
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
return dir
|
||||
}
|
||||
|
||||
// readDefaultStepFiles parses every *.json in the defaults dir into
|
||||
// source=default library steps (with slug set). Non-json and invalid files are
|
||||
// skipped silently; a slug is derived from the step name.
|
||||
func readDefaultStepFiles() ([]models.WorkflowStep, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []models.WorkflowStep{}
|
||||
for _, path := range matches {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s, err := ParseStepDoc(b)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s.Source = "default"
|
||||
s.Slug = Slugify(s.Name)
|
||||
if s.Slug == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SeedDefaultSteps upserts default steps from disk keyed on {slug, source}.
|
||||
// Re-sync overwrites default-step content; user steps are never touched.
|
||||
func SeedDefaultSteps() (created, updated int, err error) {
|
||||
steps, err := readDefaultStepFiles()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
col := db.Col("workflow_steps")
|
||||
for _, s := range steps {
|
||||
filter := bson.M{"slug": s.Slug, "source": "default"}
|
||||
set := bson.M{
|
||||
"name": s.Name,
|
||||
"description": s.Description,
|
||||
"interpreter": s.Interpreter,
|
||||
"script": s.Script,
|
||||
"declared_outputs": s.DeclaredOutputs,
|
||||
"declared_inputs": s.DeclaredInputs,
|
||||
"secret_refs": s.SecretRefs,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
res, uerr := col.UpdateOne(ctx, filter, bson.M{
|
||||
"$set": set,
|
||||
"$setOnInsert": bson.M{
|
||||
"step_id": uuid.New().String(),
|
||||
"slug": s.Slug,
|
||||
"source": "default",
|
||||
"created_at": time.Now(),
|
||||
},
|
||||
}, options.UpdateOne().SetUpsert(true))
|
||||
if uerr != nil {
|
||||
return created, updated, uerr
|
||||
}
|
||||
if res.UpsertedCount > 0 {
|
||||
created++
|
||||
} else if res.ModifiedCount > 0 {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
return created, updated, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultStepsDirEnv(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "ds")
|
||||
t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir)
|
||||
got := DefaultStepsDir()
|
||||
if got != dir {
|
||||
t.Fatalf("got %q want %q", got, dir)
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("dir not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDefaultStepFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir)
|
||||
good := `{"kind":"vantage.step/v1","name":"Ping Host","interpreter":"bash","script":"ping -c1 x=1 >> $WORKFLOW_ENV"}`
|
||||
os.WriteFile(filepath.Join(dir, "ping.json"), []byte(good), 0600)
|
||||
os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0600)
|
||||
|
||||
steps, err := readDefaultStepFiles()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("want 1 step, got %d", len(steps))
|
||||
}
|
||||
if steps[0].Slug != "ping-host" || steps[0].Source != "default" {
|
||||
t.Fatalf("bad seed step: %+v", steps[0])
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,19 @@ func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
|
||||
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
|
||||
}
|
||||
|
||||
// DispatchCleanupWorkspace tells a server's agent to remove a run's working
|
||||
// directory. Best-effort and fire-and-forget: if the agent is gone the temp dir
|
||||
// is reclaimed by the OS on reboot anyway.
|
||||
func DispatchCleanupWorkspace(serverID, workspaceID string) {
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
return
|
||||
}
|
||||
_ = Dispatcher.dispatch(serverID, &pb.ServerCommand{
|
||||
CommandId: uuid.New().String(),
|
||||
CleanupWorkspace: &pb.CleanupWorkspaceCmd{WorkspaceId: workspaceID},
|
||||
})
|
||||
}
|
||||
|
||||
// KeyGenParams carries all options for a generate-key command.
|
||||
type KeyGenParams struct {
|
||||
Label string
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/grpc/pb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// StoreInventory upserts the latest inventory snapshot onto the server document.
|
||||
// Metrics fields update every call; static fields only when r.IncludeStatic.
|
||||
func StoreInventory(serverID string, r *pb.InventoryReport) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now()
|
||||
set := bson.M{"inventory.metrics_at": now}
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
|
||||
set["inventory.cpu.load1"] = r.CPU.Load1
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
|
||||
}
|
||||
set["inventory.swap_used_bytes"] = r.SwapUsed
|
||||
|
||||
if r.IncludeStatic {
|
||||
set["inventory.static_at"] = now
|
||||
set["inventory.swap_total_bytes"] = r.SwapTotal
|
||||
set["inventory.kernel"] = r.Kernel
|
||||
if r.CPU != nil {
|
||||
set["inventory.cpu.model"] = r.CPU.Model
|
||||
set["inventory.cpu.cores"] = r.CPU.Cores
|
||||
}
|
||||
if r.Memory != nil {
|
||||
set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
|
||||
}
|
||||
parts := make([]bson.M, 0, len(r.Partitions))
|
||||
for _, p := range r.Partitions {
|
||||
parts = append(parts, bson.M{
|
||||
"device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
|
||||
"total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
|
||||
})
|
||||
}
|
||||
set["inventory.partitions"] = parts
|
||||
}
|
||||
|
||||
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mrhid6/vantage/server/internal/checker"
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
"github.com/mrhid6/vantage/server/internal/notify"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func monCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// SpecFor maps a monitor onto a checker.Spec.
|
||||
func SpecFor(m *models.Monitor) checker.Spec {
|
||||
return checker.Spec{
|
||||
Type: m.Type,
|
||||
URL: m.Target.URL,
|
||||
Host: m.Target.Host,
|
||||
Port: m.Target.Port,
|
||||
Method: m.Target.Method,
|
||||
ExpectedStatus: m.Target.ExpectedStatus,
|
||||
Keyword: m.Target.Keyword,
|
||||
TLSWarnDays: m.Target.TLSWarnDays,
|
||||
Insecure: m.Target.Insecure,
|
||||
TimeoutSec: m.IntervalSec,
|
||||
}
|
||||
}
|
||||
|
||||
func ListMonitors() ([]models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitors").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Monitor
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListMonitorsForRunner returns enabled monitors whose Runner matches runner.
|
||||
func ListMonitorsForRunner(runner string) ([]models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitors").Find(ctx, bson.M{"runner": runner, "enabled": true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Monitor
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetMonitor(monitorID string) (*models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
var m models.Monitor
|
||||
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID}).Decode(&m)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func CreateMonitor(m *models.Monitor) (*models.Monitor, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
m.MonitorID = uuid.NewString()
|
||||
m.CreatedAt = time.Now()
|
||||
if m.IntervalSec <= 0 {
|
||||
m.IntervalSec = 60
|
||||
}
|
||||
if m.Retries <= 0 {
|
||||
m.Retries = 1
|
||||
}
|
||||
if m.Runner == "" {
|
||||
m.Runner = models.RunnerServer
|
||||
}
|
||||
m.State = models.MonitorState{Status: models.StatusPending}
|
||||
if _, err := db.Col("monitors").InsertOne(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func UpdateMonitor(monitorID string, upd bson.M) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteMonitor(monitorID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID}); err != nil {
|
||||
return err
|
||||
}
|
||||
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
|
||||
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID},
|
||||
options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Incident
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UptimeRollups returns hourly rollups for a monitor since the cutoff, oldest first.
|
||||
func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("monitor_rollups").Find(ctx,
|
||||
bson.M{"monitor_id": monitorID, "period_start": bson.M{"$gte": since}},
|
||||
options.Find().SetSort(bson.M{"period_start": 1}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []models.Rollup
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IngestResult applies a check result to a monitor: updates state, opens/resolves
|
||||
// incidents on up<->down transitions, rolls up the hourly bucket, and fires
|
||||
// notifications on transition. Both the server scheduler and agent-reported
|
||||
// results funnel through here.
|
||||
func IngestResult(monitorID string, res checker.Result) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
|
||||
m, err := GetMonitor(monitorID)
|
||||
if err != nil || m == nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
prev := m.State.Status
|
||||
retries := m.Retries
|
||||
if retries < 1 {
|
||||
retries = 1
|
||||
}
|
||||
|
||||
newStatus := prev
|
||||
fails := m.State.Fails
|
||||
if res.Up {
|
||||
fails = 0
|
||||
newStatus = models.StatusUp
|
||||
} else {
|
||||
fails++
|
||||
if fails >= retries {
|
||||
newStatus = models.StatusDown
|
||||
} else if prev == "" || prev == models.StatusPending {
|
||||
newStatus = models.StatusPending
|
||||
}
|
||||
}
|
||||
|
||||
state := bson.M{
|
||||
"state.status": newStatus,
|
||||
"state.last_check_at": now,
|
||||
"state.latency_ms": res.LatencyMs,
|
||||
"state.message": res.Message,
|
||||
"state.fails": fails,
|
||||
}
|
||||
if res.CertExpiry != nil {
|
||||
state["state.cert_expiry_at"] = *res.CertExpiry
|
||||
}
|
||||
if _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": state}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hourly rollup.
|
||||
bucket := now.Truncate(time.Hour)
|
||||
up := 0
|
||||
if res.Up {
|
||||
up = 1
|
||||
}
|
||||
db.Col("monitor_rollups").UpdateOne(ctx,
|
||||
bson.M{"monitor_id": monitorID, "period_start": bucket},
|
||||
bson.M{"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
|
||||
// Transition handling.
|
||||
if newStatus != prev {
|
||||
switch newStatus {
|
||||
case models.StatusDown:
|
||||
inc := models.Incident{
|
||||
IncidentID: uuid.NewString(),
|
||||
MonitorID: monitorID,
|
||||
StartedAt: now,
|
||||
Cause: res.Message,
|
||||
}
|
||||
db.Col("incidents").InsertOne(ctx, inc)
|
||||
notifyTransition(m, newStatus, res.Message)
|
||||
case models.StatusUp:
|
||||
if prev == models.StatusDown {
|
||||
db.Col("incidents").UpdateOne(ctx,
|
||||
bson.M{"monitor_id": monitorID, "resolved_at": nil},
|
||||
bson.M{"$set": bson.M{"resolved_at": now}})
|
||||
notifyTransition(m, newStatus, res.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifyTransition dispatches notifications on an up<->down transition to each
|
||||
// enabled channel bound to the monitor. Deliveries run in the background;
|
||||
// failures are logged, not fatal.
|
||||
func notifyTransition(m *models.Monitor, newStatus, message string) {
|
||||
if len(m.ChannelIDs) == 0 {
|
||||
return
|
||||
}
|
||||
channels, err := GetChannels(m.ChannelIDs)
|
||||
if err != nil {
|
||||
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
|
||||
return
|
||||
}
|
||||
ev := notify.Event{
|
||||
MonitorName: m.Name,
|
||||
Type: m.Type,
|
||||
OldStatus: m.State.Status,
|
||||
NewStatus: newStatus,
|
||||
Message: message,
|
||||
Time: time.Now(),
|
||||
}
|
||||
for _, ch := range channels {
|
||||
if !ch.Enabled {
|
||||
continue
|
||||
}
|
||||
go func(c models.NotificationChannel) {
|
||||
if err := notify.Dispatch(c, ev); err != nil {
|
||||
log.Printf("notify: dispatch to %s (%s): %v", c.Name, c.Type, err)
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
_ = UpdateMonitor(m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func TestResolveInlineStep(t *testing.T) {
|
||||
ref := models.WorkflowStepRef{
|
||||
Order: 2,
|
||||
OnFailure: "",
|
||||
Inline: &models.WorkflowStep{
|
||||
Name: "adhoc",
|
||||
Interpreter: "bash",
|
||||
Script: "echo hi",
|
||||
SecretRefs: []string{"TOKEN"},
|
||||
DeclaredInputs: []models.InputParam{
|
||||
{Name: "REGION", Default: "eu"},
|
||||
},
|
||||
},
|
||||
Inputs: map[string]string{"REGION": "us"},
|
||||
}
|
||||
rs := resolveInlineStep(ref)
|
||||
if rs.Name != "adhoc" || rs.Script != "echo hi" || rs.Order != 2 {
|
||||
t.Fatalf("bad resolve: %+v", rs)
|
||||
}
|
||||
if rs.OnFailure != "stop" {
|
||||
t.Fatalf("want default on_failure=stop, got %q", rs.OnFailure)
|
||||
}
|
||||
if rs.Inputs["REGION"] != "us" {
|
||||
t.Fatalf("want input override us, got %q", rs.Inputs["REGION"])
|
||||
}
|
||||
if len(rs.SecretRefs) != 1 || rs.SecretRefs[0] != "TOKEN" {
|
||||
t.Fatalf("bad secret refs: %v", rs.SecretRefs)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func VerifySecretsReadToken(token string) bool {
|
||||
return subtle.ConstantTimeCompare(expected, got[:]) == 1
|
||||
}
|
||||
|
||||
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings) error {
|
||||
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -111,14 +111,31 @@ func SaveSettings(alerts models.AlertSettings, email models.EmailSettings) error
|
||||
email.SMTPPort = 587
|
||||
}
|
||||
|
||||
set := bson.M{"alerts": alerts, "email": email}
|
||||
if retentionDays != nil {
|
||||
set["workflow_log_retention_days"] = *retentionDays
|
||||
}
|
||||
_, err := db.Col("settings").UpdateOne(ctx,
|
||||
bson.M{},
|
||||
bson.M{"$set": bson.M{"alerts": alerts, "email": email}},
|
||||
bson.M{"$set": set},
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetWorkflowLogRetentionDays returns the log retention in days: 30 when unset,
|
||||
// 0 for keep-forever, or the configured value.
|
||||
func GetWorkflowLogRetentionDays() (int, error) {
|
||||
s, err := GetSettings()
|
||||
if err != nil {
|
||||
return 30, err
|
||||
}
|
||||
if s.WorkflowLogRetentionDays == nil {
|
||||
return 30, nil
|
||||
}
|
||||
return *s.WorkflowLogRetentionDays, nil
|
||||
}
|
||||
|
||||
func SendOfflineWebhook(webhookURL, hostname, serverID, ipAddress string) {
|
||||
payload := map[string]any{
|
||||
"event": "server.offline",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
const StepDocKind = "vantage.step/v1"
|
||||
|
||||
// StepDoc is the portable, id-free representation of a step.
|
||||
type StepDoc struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Interpreter string `json:"interpreter"`
|
||||
Script string `json:"script"`
|
||||
DeclaredOutputs []string `json:"declared_outputs"`
|
||||
DeclaredInputs []models.InputParam `json:"declared_inputs"`
|
||||
SecretRefs []string `json:"secret_refs"`
|
||||
}
|
||||
|
||||
// ExportStepDoc builds a portable doc from a library step (ids/source stripped).
|
||||
func ExportStepDoc(s models.WorkflowStep) StepDoc {
|
||||
return StepDoc{
|
||||
Kind: StepDocKind,
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Interpreter: s.Interpreter,
|
||||
Script: s.Script,
|
||||
DeclaredOutputs: s.DeclaredOutputs,
|
||||
DeclaredInputs: s.DeclaredInputs,
|
||||
SecretRefs: s.SecretRefs,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseStepDoc validates a v1 doc and returns a normalized (id-free) step with
|
||||
// declared_outputs recomputed from the script.
|
||||
func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
|
||||
var d StepDoc
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return models.WorkflowStep{}, fmt.Errorf("invalid step JSON: %w", err)
|
||||
}
|
||||
if d.Kind != StepDocKind {
|
||||
return models.WorkflowStep{}, fmt.Errorf("unsupported kind %q (want %q)", d.Kind, StepDocKind)
|
||||
}
|
||||
if d.Name == "" || d.Interpreter == "" {
|
||||
return models.WorkflowStep{}, fmt.Errorf("step name and interpreter are required")
|
||||
}
|
||||
if d.SecretRefs == nil {
|
||||
d.SecretRefs = []string{}
|
||||
}
|
||||
if d.DeclaredInputs == nil {
|
||||
d.DeclaredInputs = []models.InputParam{}
|
||||
}
|
||||
return models.WorkflowStep{
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
Interpreter: d.Interpreter,
|
||||
Script: d.Script,
|
||||
DeclaredOutputs: DeriveOutputs(d.Script),
|
||||
DeclaredInputs: d.DeclaredInputs,
|
||||
SecretRefs: d.SecretRefs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ImportStepToLibrary parses a doc and persists it as a new user library step.
|
||||
func ImportStepToLibrary(b []byte) (*models.WorkflowStep, error) {
|
||||
s, err := ParseStepDoc(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CreateStep(s)
|
||||
}
|
||||
|
||||
// ExportStep loads a library step and marshals it to a portable doc.
|
||||
func ExportStep(stepID string) ([]byte, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
s, err := getStep(ctx, stepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.MarshalIndent(ExportStepDoc(*s), "", " ")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func mkStep() models.WorkflowStep {
|
||||
return models.WorkflowStep{
|
||||
StepID: "should-not-export", Source: "default", Name: "Restart",
|
||||
Interpreter: "bash", Script: "echo x=1 >> $WORKFLOW_ENV",
|
||||
SecretRefs: []string{"TOK"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStepDocValid(t *testing.T) {
|
||||
raw := `{"kind":"vantage.step/v1","name":"Restart","interpreter":"bash",
|
||||
"script":"echo x=1 >> $WORKFLOW_ENV","declared_outputs":["stale"],
|
||||
"declared_inputs":[{"name":"A","default":"1"}],"secret_refs":["TOK"]}`
|
||||
s, err := ParseStepDoc([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Name != "Restart" || s.Interpreter != "bash" {
|
||||
t.Fatalf("bad parse: %+v", s)
|
||||
}
|
||||
// declared_outputs recomputed from script, ignoring the file's ["stale"].
|
||||
if len(s.DeclaredOutputs) != 1 || s.DeclaredOutputs[0] != "x" {
|
||||
t.Fatalf("outputs should be derived, got %v", s.DeclaredOutputs)
|
||||
}
|
||||
if s.StepID != "" || s.Source != "" {
|
||||
t.Fatalf("parse must not set id/source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStepDocBadKind(t *testing.T) {
|
||||
if _, err := ParseStepDoc([]byte(`{"kind":"nope","name":"x"}`)); err == nil {
|
||||
t.Fatal("want error for bad kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStepDocBadJSON(t *testing.T) {
|
||||
if _, err := ParseStepDoc([]byte(`{`)); err == nil {
|
||||
t.Fatal("want error for bad json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportStepDocRoundTrip(t *testing.T) {
|
||||
doc := ExportStepDoc(mkStep())
|
||||
b, _ := json.Marshal(doc)
|
||||
s, err := ParseStepDoc(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Name != "Restart" || s.Interpreter != "bash" {
|
||||
t.Fatalf("round trip lost data: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// WorkflowLogDir returns the base directory for workflow step logs, creating it.
|
||||
func WorkflowLogDir() string {
|
||||
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "workflow-logs")
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0700)
|
||||
return dir
|
||||
}
|
||||
|
||||
// ServerRunLogPath is the per-server-run log file path.
|
||||
func ServerRunLogPath(runID, serverID string) string {
|
||||
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
|
||||
}
|
||||
|
||||
// logTS is the UTC timestamp prefix stamped on every log line. Stored in UTC
|
||||
// (RFC3339, millisecond precision); the UI renders it in the viewer's timezone.
|
||||
func logTS() string {
|
||||
return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
|
||||
}
|
||||
|
||||
// AppendMarker writes a timestamped event line to the server-run log and returns
|
||||
// the byte offset at which the write began (used as a step's log_offset).
|
||||
func AppendMarker(runID, serverID, text string) (int64, error) {
|
||||
path := ServerRunLogPath(runID, serverID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
off, _ := f.Seek(0, 2) // current end = offset before write
|
||||
if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil {
|
||||
return off, err
|
||||
}
|
||||
return off, nil
|
||||
}
|
||||
|
||||
// ---- streamed chunk writer, boundary-safe secret masking ----
|
||||
|
||||
type stepLogWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
carry []byte // bytes of an as-yet-unterminated line
|
||||
secrets []string
|
||||
}
|
||||
|
||||
type stepLogRegistry struct {
|
||||
mu sync.Mutex
|
||||
writers map[string]*stepLogWriter
|
||||
}
|
||||
|
||||
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
|
||||
|
||||
// Open opens (append) the server-run file for a step's streamed chunks.
|
||||
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := &stepLogWriter{f: f, secrets: secrets}
|
||||
r.mu.Lock()
|
||||
r.writers[commandID] = w
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.writers[commandID]
|
||||
}
|
||||
|
||||
// Append buffers chunks into whole lines, then writes each complete line with a
|
||||
// UTC timestamp prefix and secret masking applied. Buffering by line means a
|
||||
// secret split across a chunk boundary is always masked (the whole line is
|
||||
// assembled first) and every line carries its own timestamp.
|
||||
func (r *stepLogRegistry) Append(commandID string, data []byte) {
|
||||
w := r.get(commandID)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
buf := append(w.carry, data...)
|
||||
for {
|
||||
i := bytes.IndexByte(buf, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
w.writeLine(buf[:i])
|
||||
buf = buf[i+1:]
|
||||
}
|
||||
w.carry = append([]byte{}, buf...)
|
||||
}
|
||||
|
||||
// writeLine emits one masked, timestamped log line. Caller holds w.mu.
|
||||
func (w *stepLogWriter) writeLine(line []byte) {
|
||||
masked := maskBytes(line, w.secrets)
|
||||
_, _ = w.f.WriteString("[" + logTS() + "] ")
|
||||
_, _ = w.f.Write(masked)
|
||||
_, _ = w.f.WriteString("\n")
|
||||
}
|
||||
|
||||
// Close flushes any trailing partial line and closes the file.
|
||||
func (r *stepLogRegistry) Close(commandID string) {
|
||||
r.mu.Lock()
|
||||
w := r.writers[commandID]
|
||||
delete(r.writers, commandID)
|
||||
r.mu.Unlock()
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if len(w.carry) > 0 {
|
||||
w.writeLine(w.carry)
|
||||
w.carry = nil
|
||||
}
|
||||
_ = w.f.Close()
|
||||
}
|
||||
|
||||
func maskBytes(b []byte, secrets []string) []byte {
|
||||
s := string(b)
|
||||
for _, v := range secrets {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
s = strings.ReplaceAll(s, v, "***")
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// ---- retention sweeper ----
|
||||
|
||||
// StartLogSweeper sweeps expired run-log dirs hourly (and once now).
|
||||
func StartLogSweeper() {
|
||||
go func() {
|
||||
sweepLogs()
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
sweepLogs()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func sweepLogs() {
|
||||
days := retentionDays()
|
||||
if days <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -days)
|
||||
base := WorkflowLogDir()
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
runID := e.Name()
|
||||
dir := filepath.Join(base, runID)
|
||||
if runExpired(runID, dir, cutoff) {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runExpired is true when the run finished before cutoff (falling back to dir
|
||||
// mtime when the run doc is gone).
|
||||
func runExpired(runID, dir string, cutoff time.Time) bool {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
var run struct {
|
||||
FinishedAt *time.Time `bson:"finished_at"`
|
||||
}
|
||||
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
|
||||
if err == nil {
|
||||
if run.FinishedAt == nil {
|
||||
return false // still running / never finished — keep
|
||||
}
|
||||
return run.FinishedAt.Before(cutoff)
|
||||
}
|
||||
// run doc gone: use dir mtime
|
||||
if fi, e := os.Stat(dir); e == nil {
|
||||
return fi.ModTime().Before(cutoff)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func retentionDays() int {
|
||||
if v, err := GetWorkflowLogRetentionDays(); err == nil {
|
||||
return v
|
||||
}
|
||||
return 30
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// keyAssign matches an env-var assignment target: KEY= (captures KEY).
|
||||
var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`)
|
||||
|
||||
// DeriveOutputs scans a step script and returns the output keys it writes to
|
||||
// $WORKFLOW_ENV. Best-effort: only lines that reference WORKFLOW_ENV are
|
||||
// considered. Deduplicated, first-seen order preserved.
|
||||
func DeriveOutputs(script string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
if !strings.Contains(line, "WORKFLOW_ENV") {
|
||||
continue
|
||||
}
|
||||
for _, m := range keyAssign.FindAllStringSubmatch(line, -1) {
|
||||
key := m[1]
|
||||
// Skip the sentinel itself (e.g. "WORKFLOW_ENV=..." assignments).
|
||||
if key == "WORKFLOW_ENV" || key == "env" {
|
||||
continue
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// Slugify converts a step name into a stable kebab-case slug.
|
||||
func Slugify(name string) string {
|
||||
s := strings.ToLower(name)
|
||||
s = slugStrip.ReplaceAllString(s, "-")
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveOutputs(t *testing.T) {
|
||||
script := `#!/bin/bash
|
||||
echo "test=123" >> $WORKFLOW_ENV
|
||||
echo "other=hi" >> "$WORKFLOW_ENV"
|
||||
printf 'third=1\n' >> $WORKFLOW_ENV
|
||||
echo "test=456" >> $WORKFLOW_ENV
|
||||
echo "ignored=nope"
|
||||
NORMAL=assignment
|
||||
`
|
||||
got := DeriveOutputs(script)
|
||||
want := []string{"test", "other", "third"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOutputsPowershell(t *testing.T) {
|
||||
script := `"result=ok" >> $env:WORKFLOW_ENV
|
||||
Add-Content $env:WORKFLOW_ENV "count=5"`
|
||||
got := DeriveOutputs(script)
|
||||
want := []string{"result", "count"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOutputsNone(t *testing.T) {
|
||||
got := DeriveOutputs("echo hello\nNOPE=1")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("got %v want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
if got := Slugify("Restart NGINX Service!"); got != "restart-nginx-service" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
// ValidateWorkflow checks each step ref sets exactly one of step_id / inline.
|
||||
func ValidateWorkflow(w models.Workflow) error {
|
||||
for i, ref := range w.Steps {
|
||||
hasLib := ref.StepID != ""
|
||||
hasInline := ref.Inline != nil
|
||||
if hasLib == hasInline {
|
||||
return fmt.Errorf("step %d: exactly one of step_id or inline must be set", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func TestValidateWorkflow(t *testing.T) {
|
||||
inline := &models.WorkflowStep{Name: "x", Interpreter: "bash", Script: "echo hi"}
|
||||
cases := []struct {
|
||||
name string
|
||||
ref models.WorkflowStepRef
|
||||
wantErr bool
|
||||
}{
|
||||
{"library only", models.WorkflowStepRef{StepID: "abc"}, false},
|
||||
{"inline only", models.WorkflowStepRef{Inline: inline}, false},
|
||||
{"both set", models.WorkflowStepRef{StepID: "abc", Inline: inline}, true},
|
||||
{"neither set", models.WorkflowStepRef{}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateWorkflow(models.Workflow{Steps: []models.WorkflowStepRef{tc.ref}})
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("got err=%v want wantErr=%v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -82,10 +83,24 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
defer cancel()
|
||||
out := make([]models.ResolvedStep, 0, len(wf.Steps))
|
||||
for _, ref := range wf.Steps {
|
||||
if ref.Inline != nil {
|
||||
out = append(out, resolveInlineStep(ref))
|
||||
continue
|
||||
}
|
||||
lib, err := getStep(ctx, ref.StepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputs := map[string]string{}
|
||||
for _, p := range lib.DeclaredInputs {
|
||||
if ref.Inputs != nil {
|
||||
if v, ok := ref.Inputs[p.Name]; ok {
|
||||
inputs[p.Name] = v
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputs[p.Name] = p.Default
|
||||
}
|
||||
rs := models.ResolvedStep{
|
||||
Order: ref.Order,
|
||||
Name: lib.Name,
|
||||
@@ -94,6 +109,7 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
SecretRefs: lib.SecretRefs,
|
||||
OnFailure: ref.OnFailure,
|
||||
MaxRetries: ref.MaxRetries,
|
||||
Inputs: inputs,
|
||||
}
|
||||
if ref.Overrides != nil {
|
||||
if ref.Overrides.Script != nil {
|
||||
@@ -111,6 +127,35 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveInlineStep freezes an ad-hoc (inline) step ref into a ResolvedStep.
|
||||
func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep {
|
||||
in := ref.Inline
|
||||
inputs := map[string]string{}
|
||||
for _, p := range in.DeclaredInputs {
|
||||
if ref.Inputs != nil {
|
||||
if v, ok := ref.Inputs[p.Name]; ok {
|
||||
inputs[p.Name] = v
|
||||
continue
|
||||
}
|
||||
}
|
||||
inputs[p.Name] = p.Default
|
||||
}
|
||||
onFailure := ref.OnFailure
|
||||
if onFailure == "" {
|
||||
onFailure = "stop"
|
||||
}
|
||||
return models.ResolvedStep{
|
||||
Order: ref.Order,
|
||||
Name: in.Name,
|
||||
Interpreter: in.Interpreter,
|
||||
Script: in.Script,
|
||||
SecretRefs: in.SecretRefs,
|
||||
OnFailure: onFailure,
|
||||
MaxRetries: ref.MaxRetries,
|
||||
Inputs: inputs,
|
||||
}
|
||||
}
|
||||
|
||||
// executeRun fans out one goroutine per server run and waits for all to finish.
|
||||
func executeRun(runID string) {
|
||||
run, err := GetRun(runID)
|
||||
@@ -151,16 +196,20 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
|
||||
if !Dispatcher.IsConnected(serverID) {
|
||||
fin := time.Now()
|
||||
_, _ = AppendMarker(runID, serverID, "agent not connected — server skipped")
|
||||
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "skipped", "server_runs.$.finished_at": fin})
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run started on %s — %d step(s), workspace vantage-run-%s", serverID, len(steps), runID))
|
||||
|
||||
runEnv := map[string]string{}
|
||||
allSecrets := map[string]string{}
|
||||
serverFailed := false
|
||||
|
||||
for i, step := range steps {
|
||||
startStep(runID, serverID, i, "running")
|
||||
stepStart := time.Now()
|
||||
var res *pb.StepResult
|
||||
attempts := 0
|
||||
maxAttempts := 1
|
||||
@@ -173,7 +222,20 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
for k, v := range secretVals {
|
||||
allSecrets[k] = v
|
||||
}
|
||||
// Input values may template earlier step outputs and secrets, e.g.
|
||||
// URL="http://example.com/$VersionNumber". Expand against runEnv (outputs
|
||||
// threaded from prior steps) and this step's secrets before dispatch.
|
||||
subst := map[string]string{}
|
||||
for k, v := range runEnv {
|
||||
subst[k] = v
|
||||
}
|
||||
for k, v := range secretVals {
|
||||
subst[k] = v
|
||||
}
|
||||
cmdEnv := map[string]string{}
|
||||
for k, v := range step.Inputs {
|
||||
cmdEnv[k] = expandVars(v, subst)
|
||||
}
|
||||
for k, v := range runEnv {
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
@@ -181,60 +243,83 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
cmdEnv[k] = v
|
||||
}
|
||||
|
||||
// Write the step marker to the server-run log and remember the offset so
|
||||
// the UI can slice this step's output later.
|
||||
marker := fmt.Sprintf("===== step %d/%d: %s (%s) =====", step.Order+1, len(steps), step.Name, step.Interpreter)
|
||||
offset, _ := AppendMarker(runID, serverID, marker)
|
||||
logPath := ServerRunLogPath(runID, serverID)
|
||||
secretsSlice := secretValues(secretVals)
|
||||
|
||||
commandID := uuid.New().String()
|
||||
for attempts < maxAttempts {
|
||||
attempts++
|
||||
res = dispatchAndWait(serverID, &pb.RunStepCmd{
|
||||
if attempts > 1 {
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("retry %d/%d after failure", attempts-1, maxAttempts-1))
|
||||
}
|
||||
// Open a fresh writer per attempt; the agent's eof closes it, and the
|
||||
// defensive Close below covers a missing result.
|
||||
_ = StepLogs.Open(commandID, logPath, secretsSlice)
|
||||
res = dispatchAndWait(serverID, commandID, &pb.RunStepCmd{
|
||||
Interpreter: step.Interpreter,
|
||||
Script: step.Script,
|
||||
Env: cmdEnv,
|
||||
TimeoutSeconds: 0,
|
||||
WorkspaceId: runID,
|
||||
})
|
||||
StepLogs.Close(commandID) // idempotent; no-op if eof already closed it
|
||||
if res != nil && res.ExitCode == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Mask secret values before persisting.
|
||||
stdout, stderr := "", ""
|
||||
exit := 1
|
||||
outEnv := map[string]string{} // masked copy, safe to persist
|
||||
outEnv := map[string]string{} // masked copy, safe to persist
|
||||
if res != nil {
|
||||
stdout = maskSecrets(res.Stdout, allSecrets)
|
||||
stderr = maskSecrets(res.Stderr, allSecrets)
|
||||
exit = res.ExitCode
|
||||
for k, v := range res.OutputEnv {
|
||||
runEnv[k] = v // real, unmasked value threads forward to later steps
|
||||
outEnv[k] = maskSecrets(v, allSecrets)
|
||||
}
|
||||
} else {
|
||||
stderr = "[vantage] agent did not return a result"
|
||||
_, _ = AppendMarker(runID, serverID, "agent did not return a result")
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if exit != 0 {
|
||||
status = "failed"
|
||||
}
|
||||
finishStep(runID, serverID, i, status, attempts, exit, stdout, stderr, outEnv)
|
||||
finishStep(runID, serverID, i, status, attempts, exit, offset, outEnv)
|
||||
|
||||
dur := time.Since(stepStart).Round(time.Millisecond)
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("step %d/%d %s — exit %d, %d attempt(s), %s",
|
||||
step.Order+1, len(steps), status, exit, attempts, dur))
|
||||
|
||||
if exit != 0 {
|
||||
switch step.OnFailure {
|
||||
case "continue":
|
||||
// keep going
|
||||
_, _ = AppendMarker(runID, serverID, "on_failure=continue — proceeding to next step")
|
||||
default: // "stop" or exhausted "retry"
|
||||
serverFailed = true
|
||||
}
|
||||
if serverFailed {
|
||||
_, _ = AppendMarker(runID, serverID, "stopping run — remaining steps skipped")
|
||||
markRemainingSkipped(runID, serverID, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tell the agent to remove the run's working directory now that its steps are
|
||||
// done (success or failure). Best-effort; the OS reclaims temp dirs anyway.
|
||||
DispatchCleanupWorkspace(serverID, runID)
|
||||
|
||||
fin := time.Now()
|
||||
status := "success"
|
||||
if serverFailed {
|
||||
status = "failed"
|
||||
}
|
||||
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run %s in %s — workspace removed",
|
||||
status, fin.Sub(now).Round(time.Millisecond)))
|
||||
// Persist only a masked copy of runEnv; the real (unmasked) runEnv was already
|
||||
// used above to build cmdEnv for each step and must never be written to the DB.
|
||||
maskedRunEnv := make(map[string]string, len(runEnv))
|
||||
@@ -250,8 +335,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
|
||||
|
||||
// dispatchAndWait registers a waiter, dispatches the step, and blocks for the
|
||||
// result or a timeout.
|
||||
func dispatchAndWait(serverID string, cmd *pb.RunStepCmd) *pb.StepResult {
|
||||
commandID := uuid.New().String()
|
||||
func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepResult {
|
||||
ch := StepResults.Await(commandID)
|
||||
if err := DispatchRunStep(serverID, commandID, cmd); err != nil {
|
||||
StepResults.Cancel(commandID)
|
||||
@@ -270,6 +354,18 @@ func dispatchAndWait(serverID string, cmd *pb.RunStepCmd) *pb.StepResult {
|
||||
}
|
||||
}
|
||||
|
||||
// expandVars substitutes $VAR and ${VAR} references in an input value from the
|
||||
// given lookup (prior step outputs and secrets). Unknown references expand to
|
||||
// empty, matching shell behaviour; a literal "$" is written as "$$".
|
||||
func expandVars(v string, lookup map[string]string) string {
|
||||
return os.Expand(v, func(name string) string {
|
||||
if name == "$" {
|
||||
return "$"
|
||||
}
|
||||
return lookup[name]
|
||||
})
|
||||
}
|
||||
|
||||
func resolveSecrets(refs []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, ref := range refs {
|
||||
@@ -322,19 +418,27 @@ func startStep(runID, serverID string, order int, status string) {
|
||||
})
|
||||
}
|
||||
|
||||
func finishStep(runID, serverID string, order int, status string, attempts, exit int, stdout, stderr string, outEnv map[string]string) {
|
||||
func finishStep(runID, serverID string, order int, status string, attempts, exit int, logOffset int64, outEnv map[string]string) {
|
||||
now := time.Now()
|
||||
updateStep(runID, serverID, order, bson.M{
|
||||
"server_runs.$[s].steps.$[t].status": status,
|
||||
"server_runs.$[s].steps.$[t].attempts": attempts,
|
||||
"server_runs.$[s].steps.$[t].exit_code": exit,
|
||||
"server_runs.$[s].steps.$[t].stdout": stdout,
|
||||
"server_runs.$[s].steps.$[t].stderr": stderr,
|
||||
"server_runs.$[s].steps.$[t].log_offset": logOffset,
|
||||
"server_runs.$[s].steps.$[t].output_env": outEnv,
|
||||
"server_runs.$[s].steps.$[t].finished_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
// secretValues returns just the values of a secret map, for masking log output.
|
||||
func secretValues(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for _, v := range m {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func markRemainingSkipped(runID, serverID string, fromOrder int) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
|
||||
@@ -25,6 +25,13 @@ func EnsureWorkflowIndexes() error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "slug", Value: 1}},
|
||||
Options: options.Index().SetUnique(true).
|
||||
SetPartialFilterExpression(bson.M{"source": "default"}),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Col("workflows").Indexes().CreateOne(ctx, mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "workflow_id", Value: 1}}, Options: options.Index().SetUnique(true),
|
||||
}); err != nil {
|
||||
@@ -54,18 +61,50 @@ func ListSteps() ([]models.WorkflowStep, error) {
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// StepUsageCounts returns, per library step_id, the number of distinct
|
||||
// workflows that reference it. Inline steps have no step_id and are ignored.
|
||||
func StepUsageCounts() (map[string]int, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var wfs []models.Workflow
|
||||
if err := cur.All(ctx, &wfs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, w := range wfs {
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range w.Steps {
|
||||
if ref.StepID == "" || seen[ref.StepID] {
|
||||
continue
|
||||
}
|
||||
seen[ref.StepID] = true
|
||||
counts[ref.StepID]++
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
s.StepID = uuid.New().String()
|
||||
s.CreatedAt = time.Now()
|
||||
s.UpdatedAt = s.CreatedAt
|
||||
if s.DeclaredOutputs == nil {
|
||||
s.DeclaredOutputs = []string{}
|
||||
s.DeclaredOutputs = DeriveOutputs(s.Script)
|
||||
if s.Source == "" {
|
||||
s.Source = "user"
|
||||
}
|
||||
if s.SecretRefs == nil {
|
||||
s.SecretRefs = []string{}
|
||||
}
|
||||
if s.DeclaredInputs == nil {
|
||||
s.DeclaredInputs = []models.InputParam{}
|
||||
}
|
||||
if _, err := db.Col("workflow_steps").InsertOne(ctx, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,7 +119,8 @@ func UpdateStep(stepID string, s models.WorkflowStep) error {
|
||||
"description": s.Description,
|
||||
"interpreter": s.Interpreter,
|
||||
"script": s.Script,
|
||||
"declared_outputs": s.DeclaredOutputs,
|
||||
"declared_outputs": DeriveOutputs(s.Script),
|
||||
"declared_inputs": s.DeclaredInputs,
|
||||
"secret_refs": s.SecretRefs,
|
||||
"updated_at": time.Now(),
|
||||
}})
|
||||
@@ -90,8 +130,38 @@ func UpdateStep(stepID string, s models.WorkflowStep) error {
|
||||
func DeleteStep(stepID string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID})
|
||||
return err
|
||||
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
|
||||
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
var wfs []models.Workflow
|
||||
if err := cur.All(ctx, &wfs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range wfs {
|
||||
kept := make([]models.WorkflowStepRef, 0, len(w.Steps))
|
||||
for _, ref := range w.Steps {
|
||||
if ref.StepID == stepID {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ref)
|
||||
}
|
||||
for i := range kept {
|
||||
kept[i].Order = i
|
||||
}
|
||||
if _, err := db.Col("workflows").UpdateOne(ctx,
|
||||
bson.M{"workflow_id": w.WorkflowID},
|
||||
bson.M{"$set": bson.M{"steps": kept, "updated_at": time.Now()}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
|
||||
@@ -144,6 +214,10 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
|
||||
if w.Steps == nil {
|
||||
w.Steps = []models.WorkflowStepRef{}
|
||||
}
|
||||
if err := ValidateWorkflow(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizeInlineSteps(&w)
|
||||
if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -153,6 +227,10 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
|
||||
func UpdateWorkflow(id string, w models.Workflow) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
if err := ValidateWorkflow(w); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeInlineSteps(&w)
|
||||
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{
|
||||
"name": w.Name,
|
||||
"target_server_ids": w.TargetServerIDs,
|
||||
@@ -162,6 +240,29 @@ func UpdateWorkflow(id string, w models.Workflow) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// normalizeInlineSteps derives outputs for inline steps and strips fields that
|
||||
// only belong to library steps.
|
||||
func normalizeInlineSteps(w *models.Workflow) {
|
||||
for i := range w.Steps {
|
||||
in := w.Steps[i].Inline
|
||||
if in == nil {
|
||||
continue
|
||||
}
|
||||
in.DeclaredOutputs = DeriveOutputs(in.Script)
|
||||
in.StepID = ""
|
||||
in.Slug = ""
|
||||
in.Source = ""
|
||||
in.CreatedAt = time.Time{}
|
||||
in.UpdatedAt = time.Time{}
|
||||
if in.SecretRefs == nil {
|
||||
in.SecretRefs = []string{}
|
||||
}
|
||||
if in.DeclaredInputs == nil {
|
||||
in.DeclaredInputs = []models.InputParam{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteWorkflow(id string) error {
|
||||
ctx, cancel := wfCtx()
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/mrhid6/vantage/server/internal/db"
|
||||
"github.com/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
var mongoAvailable bool
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
uri := os.Getenv("VANTAGE_TEST_MONGO_URI")
|
||||
if uri == "" {
|
||||
uri = "mongodb://localhost:27117"
|
||||
}
|
||||
if err := db.Connect(uri, "vantage_test"); err != nil {
|
||||
// No MongoDB available in this environment; DB-backed tests will be skipped
|
||||
// individually, but the rest of the package's tests must still run.
|
||||
mongoAvailable = false
|
||||
} else {
|
||||
mongoAvailable = true
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func mkUsageStep(name string) models.WorkflowStep {
|
||||
return models.WorkflowStep{Name: name, Interpreter: "bash", Script: "echo hi"}
|
||||
}
|
||||
|
||||
func mkWorkflowWithStep(name, stepID string) models.Workflow {
|
||||
return models.Workflow{Name: name, Steps: []models.WorkflowStepRef{{StepID: stepID, Order: 0, OnFailure: "stop"}}}
|
||||
}
|
||||
|
||||
func TestStepUsageCounts(t *testing.T) {
|
||||
if !mongoAvailable {
|
||||
t.Skip("mongo unavailable: set VANTAGE_TEST_MONGO_URI")
|
||||
}
|
||||
// A step used by two workflows, a step used by none.
|
||||
used, err := CreateStep(mkUsageStep("used-step"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unused, err := CreateStep(mkUsageStep("unused-step"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateWorkflow(mkWorkflowWithStep("wf-a", used.StepID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateWorkflow(mkWorkflowWithStep("wf-b", used.StepID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
counts, err := StepUsageCounts()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counts[used.StepID] != 2 {
|
||||
t.Fatalf("used step: want 2, got %d", counts[used.StepID])
|
||||
}
|
||||
if counts[unused.StepID] != 0 {
|
||||
t.Fatalf("unused step: want 0, got %d", counts[unused.StepID])
|
||||
}
|
||||
}
|
||||
@@ -36,3 +36,22 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3e4160;
|
||||
}
|
||||
|
||||
@keyframes led-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.led-pulse { animation: led-pulse 1.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes cell-ring {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(99, 102, 241, 0); }
|
||||
}
|
||||
.cell-ring { animation: cell-ring 1.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes caret-blink { 50% { opacity: 0; } }
|
||||
.caret-blink { animation: caret-blink 1s step-end infinite; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.led-pulse, .cell-ring, .caret-blink { animation: none; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, MonitorInput } from "@/lib/api";
|
||||
import { Card } from "@/components/ui";
|
||||
import { MonitorForm } from "@/components/monitors/MonitorForm";
|
||||
|
||||
export default function EditMonitorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const monitorId = params.id as string;
|
||||
|
||||
const { data: monitor, isLoading } = useQuery({
|
||||
queryKey: ["monitors", monitorId],
|
||||
queryFn: () => api.getMonitor(monitorId),
|
||||
});
|
||||
|
||||
const { mutate: update, isPending, error } = useMutation({
|
||||
mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
|
||||
router.push(`/monitors/${monitorId}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Link href={`/monitors/${monitorId}`} className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← {monitor.name}
|
||||
</Link>
|
||||
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">Edit Monitor</h1>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<MonitorForm initial={monitor} submitLabel="Save Changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, MonitorStatus, Rollup } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
function statusVariant(status: MonitorStatus) {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "success";
|
||||
case "down":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
function uptimePct(rollups: Rollup[]): number {
|
||||
const checks = rollups.reduce((a, r) => a + r.checks, 0);
|
||||
const up = rollups.reduce((a, r) => a + r.up_count, 0);
|
||||
return checks > 0 ? (up / checks) * 100 : 0;
|
||||
}
|
||||
|
||||
function Heartbeat({ rollups }: { rollups: Rollup[] }) {
|
||||
const recent = rollups.slice(-48);
|
||||
return (
|
||||
<div className="flex items-end gap-0.5">
|
||||
{recent.map((r) => {
|
||||
const pct = r.checks > 0 ? (r.up_count / r.checks) * 100 : 0;
|
||||
const color = r.checks === 0 ? "bg-surface-2" : pct >= 99 ? "bg-success" : pct >= 80 ? "bg-warning" : "bg-danger";
|
||||
return (
|
||||
<div
|
||||
key={r.period_start}
|
||||
className={`h-8 w-1.5 rounded-sm ${color}`}
|
||||
title={`${new Date(r.period_start).toLocaleString()} — ${pct.toFixed(0)}% up`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{recent.length === 0 && <span className="text-xs text-text-secondary">No history yet.</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MonitorDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const monitorId = params.id as string;
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const { data: monitor, isLoading } = useQuery({
|
||||
queryKey: ["monitors", monitorId],
|
||||
queryFn: () => api.getMonitor(monitorId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: rollups } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "uptime"],
|
||||
queryFn: () => api.getMonitorUptime(monitorId),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: incidents } = useQuery({
|
||||
queryKey: ["monitors", monitorId, "incidents"],
|
||||
queryFn: () => api.getMonitorIncidents(monitorId),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
|
||||
mutationFn: () => api.deleteMonitor(monitorId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
router.push("/monitors");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: toggleEnabled } = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const all = rollups ?? [];
|
||||
const last24 = all.slice(-24);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-text-primary">{monitor.name}</h1>
|
||||
<Badge variant={statusVariant(monitor.state.status)}>{monitor.state.status}</Badge>
|
||||
<Badge variant="neutral">{monitor.type}</Badge>
|
||||
{!monitor.enabled && <Badge variant="warning">disabled</Badge>}
|
||||
</div>
|
||||
{monitor.state.message && <p className="mt-1 text-sm text-text-secondary">{monitor.state.message}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/monitors/${monitorId}/edit`}>
|
||||
<Button variant="secondary">Edit</Button>
|
||||
</Link>
|
||||
<Button variant="secondary" onClick={() => toggleEnabled(!monitor.enabled)}>
|
||||
{monitor.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
{!confirmDelete ? (
|
||||
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">Are you sure?</span>
|
||||
<Button variant="danger" loading={isDeleting} onClick={() => deleteMonitor()}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (24h)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(last24).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Uptime (30d)</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(all).toFixed(1)}%</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Latency</p>
|
||||
<p className="mt-1 text-2xl font-bold text-text-primary">{monitor.state.latency_ms}ms</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs text-text-secondary">Cert expiry</p>
|
||||
<p className="mt-1 text-sm font-medium text-text-primary">
|
||||
{monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "—"}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Heartbeat (last 48h)</CardTitle>
|
||||
</CardHeader>
|
||||
<Heartbeat rollups={all} />
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card padding={false}>
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Incidents</h2>
|
||||
</div>
|
||||
{!incidents || incidents.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-secondary">No incidents recorded.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Started</Th>
|
||||
<Th>Resolved</Th>
|
||||
<Th>Cause</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{incidents.map((inc) => (
|
||||
<Tr key={inc.incident_id}>
|
||||
<Td>
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.started_at).toLocaleString()}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
{inc.resolved_at ? (
|
||||
<span className="text-xs text-text-secondary">{new Date(inc.resolved_at).toLocaleString()}</span>
|
||||
) : (
|
||||
<Badge variant="danger">ongoing</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-xs text-text-primary">{inc.cause || "—"}</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-text-secondary">Runner</dt>
|
||||
<dd className="mt-0.5 font-mono text-text-primary">{monitor.runner}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Interval</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.interval_sec}s</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Retries before down</dt>
|
||||
<dd className="mt-0.5 text-text-primary">{monitor.retries}</dd>
|
||||
</div>
|
||||
<div className="border-t border-border pt-3">
|
||||
<dt className="text-text-secondary">Target</dt>
|
||||
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">
|
||||
{monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, MonitorInput } from "@/lib/api";
|
||||
import { Card } from "@/components/ui";
|
||||
import { MonitorForm } from "@/components/monitors/MonitorForm";
|
||||
|
||||
export default function NewMonitorPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: create, isPending, error } = useMutation({
|
||||
mutationFn: (input: MonitorInput) => api.createMonitor(input),
|
||||
onSuccess: (m) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["monitors"] });
|
||||
router.push(`/monitors/${m.monitor_id}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">New Monitor</h1>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<MonitorForm submitLabel="Create Monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, MonitorStatus } from "@/lib/api";
|
||||
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
function statusVariant(status: MonitorStatus) {
|
||||
switch (status) {
|
||||
case "up":
|
||||
return "success";
|
||||
case "down":
|
||||
return "danger";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
function targetSummary(m: Monitor): string {
|
||||
if (m.type === "http") return m.target.url ?? "";
|
||||
if (m.type === "tls") return `${m.target.host ?? ""}:${m.target.port || 443}`;
|
||||
if (m.type === "icmp") return m.target.host ?? "";
|
||||
return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
|
||||
}
|
||||
|
||||
export default function MonitorsPage() {
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ["monitors"],
|
||||
queryFn: () => api.listMonitors(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Monitors</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Service uptime and latency checks.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary">Notifications</Button>
|
||||
</Link>
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="primary">New Monitor</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : !monitors || monitors.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-text-secondary">No monitors yet.</p>
|
||||
<Link href="/monitors/new">
|
||||
<Button variant="secondary" size="sm" className="mt-3">
|
||||
Create your first monitor
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Type</Th>
|
||||
<Th>Target</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Latency</Th>
|
||||
<Th>Last check</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{monitors.map((m) => (
|
||||
<Tr key={m.monitor_id}>
|
||||
<Td>
|
||||
<Link href={`/monitors/${m.monitor_id}`} className="font-medium text-text-primary hover:text-accent">
|
||||
{m.name}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral">{m.type}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs text-text-secondary">{targetSummary(m)}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={statusVariant(m.state.status)}>{m.state.status}</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-text-secondary">{m.state.latency_ms}ms</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "—"}
|
||||
</span>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate } from "@/lib/api";
|
||||
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate, Inventory } from "@/lib/api";
|
||||
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
|
||||
@@ -23,6 +23,60 @@ function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024));
|
||||
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function UsageBar({ used, total }: { used: number; total: number }) {
|
||||
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
|
||||
return (
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryPanel({ inv }: { inv: Inventory }) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
|
||||
<UsageBar used={inv.cpu.usage_pct} total={100} />
|
||||
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
|
||||
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
|
||||
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
|
||||
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
|
||||
</div>
|
||||
</div>
|
||||
{inv.partitions && inv.partitions.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
|
||||
<div className="space-y-3">
|
||||
{inv.partitions.map((p) => (
|
||||
<div key={p.mountpoint}>
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="font-mono text-text-primary">{p.mountpoint}</span>
|
||||
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
|
||||
</div>
|
||||
<UsageBar used={p.used_bytes} total={p.total_bytes} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const KEY_SIZES: Record<string, number[]> = {
|
||||
rsa: [2048, 3072, 4096],
|
||||
ecdsa: [256, 384, 521],
|
||||
@@ -432,6 +486,12 @@ export default function ServerDetailPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{server.inventory && (
|
||||
<div className="mb-6">
|
||||
<InventoryPanel inv={server.inventory} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
|
||||
import { Badge, Button, Card } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
|
||||
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
|
||||
|
||||
// Config fields required per channel type.
|
||||
const CONFIG_FIELDS: Record<ChannelType, string[]> = {
|
||||
webhook: ["url"],
|
||||
slack: ["url"],
|
||||
discord: ["url"],
|
||||
telegram: ["token", "chat_id"],
|
||||
smtp: ["host", "port", "username", "password", "from", "to"],
|
||||
};
|
||||
|
||||
function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDone: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [type, setType] = useState<ChannelType>(initial?.type ?? "webhook");
|
||||
const [config, setConfig] = useState<Record<string, string>>(initial?.config ?? {});
|
||||
|
||||
const { mutate: submit, isPending, error } = useMutation({
|
||||
mutationFn: (input: ChannelInput) =>
|
||||
initial ? api.updateChannel(initial.channel_id, input) : api.createChannel(input).then(() => undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
onDone();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit({ name, type, config, enabled: initial?.enabled ?? true });
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Type</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={type}
|
||||
onChange={(e) => {
|
||||
setType(e.target.value as ChannelType);
|
||||
setConfig({});
|
||||
}}
|
||||
>
|
||||
{(["webhook", "smtp", "discord", "slack", "telegram"] as const).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{CONFIG_FIELDS[type].map((field) => (
|
||||
<div key={field}>
|
||||
<label className={labelClass}>{field}</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type={field === "password" ? "password" : "text"}
|
||||
value={config[field] ?? ""}
|
||||
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{initial ? "Save Changes" : "Add Channel"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onDone}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelRow({ ch }: { ch: NotificationChannel }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const { mutate: remove } = useMutation({
|
||||
mutationFn: () => api.deleteChannel(ch.channel_id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
|
||||
const { mutate: test, isPending: testing } = useMutation({
|
||||
mutationFn: () => api.testChannel(ch.channel_id),
|
||||
onSuccess: () => setTestMsg("Sent!"),
|
||||
onError: (e) => setTestMsg((e as Error).message),
|
||||
});
|
||||
|
||||
const { mutate: toggle } = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.updateChannel(ch.channel_id, { enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="border-b border-border p-4 last:border-0">
|
||||
<ChannelForm initial={ch} onDone={() => setEditing(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-text-primary">{ch.name}</span>
|
||||
<Badge variant="neutral">{ch.type}</Badge>
|
||||
{!ch.enabled && <Badge variant="warning">disabled</Badge>}
|
||||
</div>
|
||||
{testMsg && <p className="mt-1 text-xs text-text-secondary">{testMsg}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" loading={testing} onClick={() => test()}>
|
||||
Test
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => toggle(!ch.enabled)}>
|
||||
{ch.enabled ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => remove()}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationSettingsPage() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { data: channels, isLoading } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Monitors
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">Notification Channels</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Alert destinations for monitor state changes.</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="primary" onClick={() => setShowForm(true)}>
|
||||
New Channel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-6 max-w-xl">
|
||||
<ChannelForm onDone={() => setShowForm(false)} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : !channels || channels.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-text-secondary">No channels configured.</div>
|
||||
) : (
|
||||
channels.map((ch) => <ChannelRow key={ch.channel_id} ch={ch} />)
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+221
-370
@@ -2,392 +2,243 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, AlertSettings, EmailSettings } from "@/lib/api";
|
||||
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
|
||||
function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none ${
|
||||
enabled ? "bg-accent" : "bg-surface-2 border border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-accent">{icon}</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-text-primary">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-sm text-text-secondary">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
enabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-2 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
||||
<p className="text-xs text-text-secondary">{description}</p>
|
||||
</div>
|
||||
<Toggle enabled={enabled} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
|
||||
|
||||
function SecretsTokenCard({
|
||||
tokenSet,
|
||||
rotatedAt,
|
||||
}: {
|
||||
tokenSet: boolean;
|
||||
rotatedAt?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const readUrl =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.origin}/api/secrets/<group>/values`
|
||||
: "/api/secrets/<group>/values";
|
||||
|
||||
const { mutate: rotate, isPending } = useMutation({
|
||||
mutationFn: api.rotateSecretsToken,
|
||||
onSuccess: (res) => {
|
||||
setToken(res.token);
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function copy() {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Secrets Read Token (ESO)</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="mb-5 text-sm text-text-secondary">
|
||||
Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer
|
||||
token. Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
|
||||
<span className="font-mono text-text-primary">{readUrl}</span>.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`}
|
||||
/>
|
||||
<span className="text-text-secondary">
|
||||
{tokenSet ? "A read token is configured" : "No read token configured yet"}
|
||||
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{token && (
|
||||
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-warning">
|
||||
Copy this token now — it will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
|
||||
{token}
|
||||
</code>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={copy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
|
||||
{tokenSet ? "Rotate Token" : "Generate Token"}
|
||||
</Button>
|
||||
{tokenSet && (
|
||||
<p className="mt-2 text-xs text-text-tertiary">
|
||||
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedAt?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const readUrl = typeof window !== "undefined" ? `${window.location.origin}/api/secrets/<group>/values` : "/api/secrets/<group>/values";
|
||||
|
||||
const { mutate: rotate, isPending } = useMutation({
|
||||
mutationFn: api.rotateSecretsToken,
|
||||
onSuccess: (res) => {
|
||||
setToken(res.token);
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
|
||||
async function copy() {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="Secrets Read Token (ESO)" description="Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer token." icon={<KeyIcon />}>
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
Point your <span className="font-mono">ClusterSecretStore</span> at <span className="font-mono text-text-primary">{readUrl}</span>.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`} />
|
||||
<span className="text-text-secondary">
|
||||
{tokenSet ? "A read token is configured" : "No read token configured yet"}
|
||||
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{token && (
|
||||
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
|
||||
<p className="mb-2 text-xs font-medium text-warning">Copy this token now — it will not be shown again.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">{token}</code>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={copy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
|
||||
{tokenSet ? "Rotate Token" : "Generate Token"}
|
||||
</Button>
|
||||
{tokenSet && <p className="mt-2 text-xs text-text-tertiary">Rotating invalidates the previous token. Update the Kubernetes secret afterwards.</p>}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings });
|
||||
|
||||
// Webhook / offline alerting state
|
||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||
const [webhookURL, setWebhookURL] = useState("");
|
||||
const [thresholdMinutes, setThresholdMinutes] = useState(5);
|
||||
const [thresholdMinutes, setThresholdMinutes] = useState(5);
|
||||
const [logRetentionDays, setLogRetentionDays] = useState(30);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
// Email state
|
||||
const [emailEnabled, setEmailEnabled] = useState(false);
|
||||
const [smtpHost, setSmtpHost] = useState("");
|
||||
const [smtpPort, setSmtpPort] = useState(587);
|
||||
const [smtpUser, setSmtpUser] = useState("");
|
||||
const [smtpPass, setSmtpPass] = useState("");
|
||||
const [fromAddr, setFromAddr] = useState("");
|
||||
const [toAddrs, setToAddrs] = useState(""); // comma-separated in UI
|
||||
const [useTLS, setUseTLS] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
|
||||
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
|
||||
}, [settings]);
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setAlertsEnabled(settings.alerts.enabled);
|
||||
setWebhookURL(settings.alerts.webhook_url ?? "");
|
||||
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
|
||||
setEmailEnabled(settings.email?.enabled ?? false);
|
||||
setSmtpHost(settings.email?.smtp_host ?? "");
|
||||
setSmtpPort(settings.email?.smtp_port || 587);
|
||||
setSmtpUser(settings.email?.username ?? "");
|
||||
setSmtpPass(settings.email?.password ?? "");
|
||||
setFromAddr(settings.email?.from_addr ?? "");
|
||||
setToAddrs((settings.email?.to_addrs ?? []).join(", "));
|
||||
setUseTLS(settings.email?.use_tls ?? false);
|
||||
}, [settings]);
|
||||
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: (payload: { alerts: AlertSettings; email: EmailSettings }) =>
|
||||
api.saveSettings(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const toList = toAddrs
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
save({
|
||||
alerts: {
|
||||
enabled: alertsEnabled,
|
||||
webhook_url: webhookURL,
|
||||
offline_threshold_minutes: thresholdMinutes,
|
||||
},
|
||||
email: {
|
||||
enabled: emailEnabled,
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: smtpPort,
|
||||
username: smtpUser,
|
||||
password: smtpPass,
|
||||
from_addr: fromAddr,
|
||||
to_addrs: toList,
|
||||
use_tls: useTLS,
|
||||
},
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof api.saveSettings>[0]) => api.saveSettings(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!settings) return;
|
||||
// Preserve legacy alert/email values (managed via Notification Channels now);
|
||||
// only the offline threshold and log retention are edited here.
|
||||
save({
|
||||
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
|
||||
email: settings.email,
|
||||
workflow_log_retention_days: logRetentionDays,
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Configure alerting and monitoring behaviour</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
|
||||
{/* Webhook alerting */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Webhook Alerting</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="mb-5 text-sm text-text-secondary">
|
||||
POST a JSON payload to a URL when a server goes offline. Compatible with Slack,
|
||||
Discord, n8n, and any service that accepts JSON.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="Enable webhook alerts"
|
||||
description="Webhook fires only when this is on"
|
||||
enabled={alertsEnabled}
|
||||
onChange={setAlertsEnabled}
|
||||
/>
|
||||
<Field
|
||||
label="Webhook URL"
|
||||
hint={`POST body: { event, hostname, server_id, ip_address, timestamp, message }`}
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={webhookURL}
|
||||
onChange={(e) => setWebhookURL(e.target.value)}
|
||||
placeholder="https://hooks.slack.com/... or https://discord.com/api/webhooks/..."
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Offline threshold (minutes)"
|
||||
hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum."
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={thresholdMinutes}
|
||||
onChange={(e) => setThresholdMinutes(Number(e.target.value))}
|
||||
className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Email alerting */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<p className="mb-5 text-sm text-text-secondary">
|
||||
Send an email when a server goes offline. Uses the same offline threshold as the
|
||||
webhook setting above.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="Enable email alerts"
|
||||
description="Emails are only sent when this is on"
|
||||
enabled={emailEnabled}
|
||||
onChange={setEmailEnabled}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="SMTP Host" hint="">
|
||||
<input
|
||||
type="text"
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="smtp.gmail.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Port" hint="">
|
||||
<input
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||
placeholder="587"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-col justify-center pt-5">
|
||||
<ToggleRow
|
||||
label="TLS (port 465)"
|
||||
description="Use implicit TLS instead of STARTTLS"
|
||||
enabled={useTLS}
|
||||
onChange={(v) => {
|
||||
setUseTLS(v);
|
||||
setSmtpPort(v ? 465 : 587);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Configure monitoring, alerting, and integrations.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Username">
|
||||
<input
|
||||
type="text"
|
||||
value={smtpUser}
|
||||
onChange={(e) => setSmtpUser(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
className={inputClass}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<input
|
||||
type="password"
|
||||
value={smtpPass}
|
||||
onChange={(e) => setSmtpPass(e.target.value)}
|
||||
placeholder="App password or SMTP password"
|
||||
className={inputClass}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="From address">
|
||||
<input
|
||||
type="email"
|
||||
value={fromAddr}
|
||||
onChange={(e) => setFromAddr(e.target.value)}
|
||||
placeholder="vantage@example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="To addresses"
|
||||
hint="Separate multiple addresses with commas"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={toAddrs}
|
||||
onChange={(e) => setToAddrs(e.target.value)}
|
||||
placeholder="admin@example.com, ops@example.com"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{saved ? "Saved!" : "Save Settings"}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
|
||||
<div className="space-y-6">
|
||||
{/* Alerting — replaces the legacy webhook/email settings */}
|
||||
<SectionCard title="Alerting" description="Alerts are now delivered through notification channels, triggered by service monitors." icon={<BellIcon />}>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/settings/notifications">
|
||||
<Button variant="secondary">Manage Notification Channels</Button>
|
||||
</Link>
|
||||
<Link href="/monitors">
|
||||
<Button variant="ghost">View Monitors</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-text-tertiary">
|
||||
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SectionCard title="Server Health" description="When to consider an agent-backed server offline." icon={<ServerIcon />}>
|
||||
<Field label="Offline threshold (minutes)" hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum.">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={thresholdMinutes}
|
||||
onChange={(e) => setThresholdMinutes(Number(e.target.value))}
|
||||
className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Workflow Logs" description="How long run logs are kept before automatic deletion." icon={<DocumentIcon />}>
|
||||
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={logRetentionDays}
|
||||
onChange={(e) => setLogRetentionDays(Number(e.target.value))}
|
||||
className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{saved ? "Saved!" : "Save Settings"}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<SecretsTokenCard tokenSet={settings?.secrets?.read_token_set ?? false} rotatedAt={settings?.secrets?.rotated_at} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 max-w-xl">
|
||||
<SecretsTokenCard
|
||||
tokenSet={settings?.secrets?.read_token_set ?? false}
|
||||
rotatedAt={settings?.secrets?.rotated_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
import { EditStepModal } from "@/components/workflows/EditStepModal";
|
||||
|
||||
type Tab = "all" | "bash" | "powershell" | "default" | "shared";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
|
||||
const isBash = interpreter === "bash";
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"}`}>
|
||||
{isBash ? "bash" : "pwsh"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StepsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: steps } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
|
||||
const { data: usage } = useQuery({ queryKey: ["step-usage"], queryFn: api.stepUsage });
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("all");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WorkflowStep | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return (steps ?? []).filter((s) => {
|
||||
const matchesText = s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
|
||||
const matchesTab =
|
||||
tab === "all" ||
|
||||
(tab === "bash" && s.interpreter === "bash") ||
|
||||
(tab === "powershell" && s.interpreter === "powershell") ||
|
||||
(tab === "default" && s.source === "default") ||
|
||||
(tab === "shared" && s.source !== "default");
|
||||
return matchesText && matchesTab;
|
||||
});
|
||||
}, [steps, search, tab]);
|
||||
|
||||
const openNew = () => {
|
||||
setEditing(null);
|
||||
setEditOpen(true);
|
||||
};
|
||||
const openEdit = (s: WorkflowStep) => {
|
||||
setEditing(s);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const onImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const doc = JSON.parse(await file.text());
|
||||
await api.importStep(doc);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
setNotice("Step imported.");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const onSync = async () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { created, updated } = await api.seedDefaults();
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
setNotice(`${created} created, ${updated} updated`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-text-primary">Steps</h1>
|
||||
<p className="text-sm text-text-secondary">Reusable steps shared across all workflows.</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={onImport} />
|
||||
<Button variant="secondary" size="sm" loading={syncing} onClick={onSync}>
|
||||
Sync defaults
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" loading={importing} onClick={() => fileRef.current?.click()}>
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm" onClick={openNew}>
|
||||
+ New step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
{notice && <div className="mb-4 rounded border border-signal/30 bg-signal/10 px-3 py-2 text-sm text-signal">{notice}</div>}
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<input className={`${inputClass} max-w-sm`} placeholder="Search steps…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="flex gap-1.5">
|
||||
{(["all", "bash", "powershell", "default", "shared"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`rounded-full border px-3 py-1 text-xs capitalize ${
|
||||
tab === t ? "border-signal/50 bg-signal/15 text-signal" : "border-border bg-surface-2 text-text-secondary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t === "powershell" ? "PowerShell" : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-text-secondary">
|
||||
<th className="px-4 py-2.5 font-bold">Name</th>
|
||||
<th className="px-4 py-2.5 font-bold">Shell</th>
|
||||
<th className="px-4 py-2.5 font-bold">Source</th>
|
||||
<th className="px-4 py-2.5 font-bold">Outputs</th>
|
||||
<th className="px-4 py-2.5 font-bold">Used by</th>
|
||||
<th className="px-4 py-2.5 text-right font-bold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => {
|
||||
const count = usage?.[s.step_id] ?? 0;
|
||||
return (
|
||||
<tr key={s.step_id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-text-primary">{s.name}</div>
|
||||
{s.description && <div className="text-xs text-text-secondary">{s.description}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ShellBadge interpreter={s.interpreter} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
|
||||
{s.source === "default" ? "default" : "shared"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(s.declared_outputs ?? []).map((o) => (
|
||||
<span key={o} className="rounded border border-signal/35 px-1.5 py-0.5 font-mono text-[10px] text-signal">
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">
|
||||
{count === 0 ? "—" : `${count} workflow${count === 1 ? "" : "s"}`}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-3 text-text-secondary">
|
||||
<button onClick={() => openEdit(s)} className="hover:text-text-primary">
|
||||
Edit
|
||||
</button>
|
||||
<a href={api.exportStepUrl(s.step_id)} download className="hover:text-text-primary">
|
||||
Export
|
||||
</a>
|
||||
<button onClick={() => openEdit(s)} className="hover:text-danger">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-sm text-text-secondary">
|
||||
No steps found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<EditStepModal
|
||||
key={editing?.step_id ?? "new"}
|
||||
open={editOpen}
|
||||
step={editing}
|
||||
onClose={() => {
|
||||
setEditOpen(false);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
qc.invalidateQueries({ queryKey: ["step-usage"] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+463
-224
@@ -1,93 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow, WorkflowStep, WorkflowStepRef, SecretGroupSummary } from "@/lib/api";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { Button } from "@/components/ui";
|
||||
import { EditWorkflowModal } from "@/components/workflows/EditWorkflowModal";
|
||||
import { StepPickerModal } from "@/components/workflows/StepPickerModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
function NewStepForm({ onClose }: { onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState("");
|
||||
const [interpreter, setInterpreter] = useState<"bash" | "powershell">("bash");
|
||||
const [script, setScript] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
type DragPayload = { kind: "lib"; stepId: string } | { kind: "move"; from: number };
|
||||
|
||||
const create = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.createStep({
|
||||
name: name.trim(),
|
||||
description: "",
|
||||
interpreter,
|
||||
script,
|
||||
declared_outputs: [],
|
||||
secret_refs: [],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["steps"] });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
|
||||
const isBash = interpreter === "bash";
|
||||
return <span className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"}`}>{isBash ? "bash" : "pwsh"}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2 rounded-lg border border-border bg-surface-2 p-2">
|
||||
{error && <div className="text-xs text-danger">{error}</div>}
|
||||
<input className={inputClass} placeholder="Step name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
|
||||
<option value="bash">bash</option>
|
||||
<option value="powershell">powershell</option>
|
||||
</select>
|
||||
<textarea className={`${inputClass} h-20 font-mono text-xs`} placeholder="script" value={script} onChange={(e) => setScript(e.target.value)} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" loading={saving} disabled={!name.trim()} onClick={create}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function AdhocBadge() {
|
||||
return <span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-signal/15 text-signal">ad-hoc</span>;
|
||||
}
|
||||
|
||||
// Stable snapshot of only the fields the editor controls. Excludes volatile
|
||||
// server-echo fields (e.g. updated_at) that would otherwise change on every
|
||||
// save and cause autosave to loop forever.
|
||||
function snapshotOf(w: Workflow): string {
|
||||
return JSON.stringify({
|
||||
name: w.name,
|
||||
target_server_ids: w.target_server_ids,
|
||||
steps: w.steps,
|
||||
});
|
||||
}
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (s < 5) return "just now";
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ago`;
|
||||
}
|
||||
|
||||
export default function WorkflowBuilder() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params.id;
|
||||
const router = useRouter();
|
||||
|
||||
const [wf, setWf] = useState<Workflow | null>(null);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [showNewStep, setShowNewStep] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [, setTick] = useState(0);
|
||||
const savedSnapshotRef = useRef<string | null>(null);
|
||||
const savingRef = useRef(false);
|
||||
const wfRef = useRef<Workflow | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [importingInline, setImportingInline] = useState(false);
|
||||
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
|
||||
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
|
||||
const [dragOverZone, setDragOverZone] = useState<number | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const { data: loaded } = useQuery({
|
||||
queryKey: ["workflow", id],
|
||||
queryFn: () => api.getWorkflow(id),
|
||||
});
|
||||
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
|
||||
const { data: secretGroups } = useQuery({
|
||||
queryKey: ["secret-groups"],
|
||||
queryFn: api.listSecretGroups,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && !wf) setWf(loaded);
|
||||
}, [loaded, wf]);
|
||||
if (loaded && !wf) {
|
||||
setWf(loaded);
|
||||
savedSnapshotRef.current = snapshotOf(loaded);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loaded]);
|
||||
|
||||
// Lazily fetch the keys for every secret group so the inspector's
|
||||
// secret-ref multiselect can offer "group/KEY" options.
|
||||
// secret-ref checklist can offer "group/KEY" options.
|
||||
useEffect(() => {
|
||||
if (!secretGroups) return;
|
||||
secretGroups.forEach((g: SecretGroupSummary) => {
|
||||
@@ -106,20 +104,81 @@ export default function WorkflowBuilder() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [secretGroups]);
|
||||
|
||||
// Keep a ref to the latest workflow so an in-flight save can tell whether
|
||||
// the user edited again while the request was on the wire.
|
||||
wfRef.current = wf;
|
||||
|
||||
// Autosave: debounce 800ms after any change to the workflow (step added,
|
||||
// removed, reordered, or edited) and persist. Diffing the serialized state
|
||||
// against the last saved snapshot skips no-op saves and the initial load.
|
||||
// Must stay above the early return below so hook order is stable.
|
||||
useEffect(() => {
|
||||
if (!wf || savedSnapshotRef.current === null) return;
|
||||
if (snapshotOf(wf) === savedSnapshotRef.current) return;
|
||||
const t = setTimeout(() => {
|
||||
save();
|
||||
}, 800);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wf]);
|
||||
|
||||
// Re-render every 15s so the "Saved … ago" label stays current.
|
||||
useEffect(() => {
|
||||
if (!lastSaved) return;
|
||||
const iv = setInterval(() => setTick((n) => n + 1), 15000);
|
||||
return () => clearInterval(iv);
|
||||
}, [lastSaved]);
|
||||
|
||||
if (!wf) {
|
||||
return <div className="p-8 text-text-secondary">Loading…</div>;
|
||||
}
|
||||
|
||||
const libById = (sid?: string) => (sid ? library?.find((l) => l.step_id === sid) : undefined);
|
||||
|
||||
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
|
||||
const selectedRef = selected !== null ? sortedSteps[selected] : null;
|
||||
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
|
||||
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
|
||||
|
||||
const save = async () => {
|
||||
// Never run two saves concurrently: a request in flight would race the
|
||||
// next one. The finally block re-triggers if edits landed meanwhile.
|
||||
if (savingRef.current) return;
|
||||
const current = wfRef.current;
|
||||
if (!current) return;
|
||||
const snapshot = snapshotOf(current);
|
||||
if (snapshot === savedSnapshotRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(id, wf);
|
||||
setWf(updated);
|
||||
const updated = await api.updateWorkflow(id, current);
|
||||
if (!updated || !Array.isArray(updated.steps)) {
|
||||
setError("Save failed: server returned an unexpected response.");
|
||||
return;
|
||||
}
|
||||
if (wfRef.current && snapshotOf(wfRef.current) === snapshot) {
|
||||
// Nothing changed while the request was in flight: adopt the
|
||||
// server echo as the new saved baseline.
|
||||
savedSnapshotRef.current = snapshotOf(updated);
|
||||
setWf(updated);
|
||||
} else {
|
||||
// The user edited again mid-flight. Keep their newer state and
|
||||
// mark only the SENT snapshot as saved, so the effect re-fires
|
||||
// and persists the remaining changes.
|
||||
savedSnapshotRef.current = snapshot;
|
||||
}
|
||||
setLastSaved(new Date());
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
// If edits arrived during the save (or a concurrent save was
|
||||
// skipped), persist them on the next tick.
|
||||
if (wfRef.current && snapshotOf(wfRef.current) !== savedSnapshotRef.current) {
|
||||
setTimeout(() => save(), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,11 +194,66 @@ export default function WorkflowBuilder() {
|
||||
}
|
||||
};
|
||||
|
||||
const addStep = (s: WorkflowStep) =>
|
||||
setWf({
|
||||
...wf,
|
||||
steps: [...wf.steps, { step_id: s.step_id, order: wf.steps.length, on_failure: "stop", max_retries: 0 }],
|
||||
const resequence = (steps: WorkflowStepRef[]) => steps.map((r, i) => ({ ...r, order: i }));
|
||||
|
||||
const insertLibStep = (stepId: string, pos: number) => {
|
||||
const next = [...sortedSteps];
|
||||
next.splice(pos, 0, { step_id: stepId, order: 0, on_failure: "stop", max_retries: 0 });
|
||||
setWf({ ...wf, steps: resequence(next) });
|
||||
};
|
||||
|
||||
const appendRef = (ref: WorkflowStepRef) => {
|
||||
setWf({ ...wf, steps: resequence([...sortedSteps, ref]) });
|
||||
};
|
||||
|
||||
const addAdhocStep = () => {
|
||||
appendRef({
|
||||
inline: {
|
||||
step_id: "",
|
||||
name: "New ad-hoc step",
|
||||
description: "",
|
||||
interpreter: "bash",
|
||||
script: "",
|
||||
declared_outputs: [],
|
||||
declared_inputs: [],
|
||||
secret_refs: [],
|
||||
},
|
||||
order: wf.steps.length,
|
||||
on_failure: "stop",
|
||||
max_retries: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const moveStep = (from: number, pos: number) => {
|
||||
const next = [...sortedSteps];
|
||||
const [item] = next.splice(from, 1);
|
||||
const target = from < pos ? pos - 1 : pos;
|
||||
next.splice(target, 0, item);
|
||||
setWf({ ...wf, steps: resequence(next) });
|
||||
if (selected === from) setSelected(target);
|
||||
else if (selected !== null) {
|
||||
if (from < selected && target >= selected) setSelected(selected - 1);
|
||||
else if (from > selected && target <= selected) setSelected(selected + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, pos: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverZone(null);
|
||||
const raw = e.dataTransfer.getData("text/plain");
|
||||
if (!raw) return;
|
||||
let payload: DragPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "lib") {
|
||||
insertLibStep(payload.stepId, pos);
|
||||
} else if (payload.kind === "move") {
|
||||
moveStep(payload.from, pos);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRef = (idx: number, patch: Partial<WorkflowStepRef>) =>
|
||||
setWf({
|
||||
@@ -147,147 +261,141 @@ export default function WorkflowBuilder() {
|
||||
steps: wf.steps.map((r, i) => (i === idx ? { ...r, ...patch } : r)),
|
||||
});
|
||||
|
||||
const updateInline = (idx: number, patch: Partial<WorkflowStep>) =>
|
||||
setWf({
|
||||
...wf,
|
||||
steps: wf.steps.map((r, i) => (i === idx && r.inline ? { ...r, inline: { ...r.inline, ...patch } } : r)),
|
||||
});
|
||||
|
||||
const removeStep = (idx: number) => {
|
||||
const remaining = wf.steps.filter((_, i) => i !== idx).map((r, i) => ({ ...r, order: i }));
|
||||
const remaining = resequence(wf.steps.filter((_, i) => i !== idx));
|
||||
setWf({ ...wf, steps: remaining });
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const moveStep = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
const sorted = [...wf.steps].sort((a, b) => a.order - b.order);
|
||||
if (target < 0 || target >= sorted.length) return;
|
||||
const next = sorted.map((r, i) => {
|
||||
if (i === idx) return { ...r, order: sorted[target].order };
|
||||
if (i === target) return { ...r, order: sorted[idx].order };
|
||||
return r;
|
||||
});
|
||||
setWf({ ...wf, steps: next });
|
||||
if (selected === idx) setSelected(target);
|
||||
else if (selected === target) setSelected(idx);
|
||||
};
|
||||
|
||||
const toggleTargetServer = (serverId: string) => {
|
||||
const set = new Set(wf.target_server_ids);
|
||||
if (set.has(serverId)) set.delete(serverId);
|
||||
else set.add(serverId);
|
||||
setWf({ ...wf, target_server_ids: Array.from(set) });
|
||||
};
|
||||
|
||||
const libById = (sid: string) => library?.find((l) => l.step_id === sid);
|
||||
|
||||
const sortedSteps = [...wf.steps].sort((a, b) => a.order - b.order);
|
||||
const selectedRef = selected !== null ? sortedSteps[selected] : null;
|
||||
const selectedLib = selectedRef ? libById(selectedRef.step_id) : null;
|
||||
const selectedIdxInWf = selectedRef ? wf.steps.indexOf(selectedRef) : -1;
|
||||
|
||||
const toggleSecretRef = (ref: string) => {
|
||||
if (selectedIdxInWf === -1) return;
|
||||
const current = selectedRef?.overrides?.secret_refs ?? [];
|
||||
if (selectedIdxInWf === -1 || !selectedRef) return;
|
||||
if (selectedRef.inline) {
|
||||
const current = selectedRef.inline.secret_refs ?? [];
|
||||
const next = current.includes(ref) ? current.filter((r) => r !== ref) : [...current, ref];
|
||||
updateInline(selectedIdxInWf, { secret_refs: next });
|
||||
return;
|
||||
}
|
||||
const current = selectedRef.overrides?.secret_refs ?? [];
|
||||
const next = current.includes(ref) ? current.filter((r) => r !== ref) : [...current, ref];
|
||||
updateRef(selectedIdxInWf, { overrides: { ...selectedRef?.overrides, secret_refs: next } });
|
||||
updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } });
|
||||
};
|
||||
|
||||
const upstreamOutputsFor = (i: number) => Array.from(new Set(sortedSteps.slice(0, i).flatMap((r) => r.inline?.declared_outputs ?? libById(r.step_id)?.declared_outputs ?? [])));
|
||||
|
||||
const DropZone = ({ pos }: { pos: number }) => (
|
||||
<div
|
||||
className={`h-3 w-full transition-all ${dragOverZone === pos ? "h-8 rounded bg-signal/15 border border-dashed border-signal/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverZone(pos);
|
||||
}}
|
||||
onDragLeave={() => setDragOverZone((z) => (z === pos ? null : z))}
|
||||
onDrop={(e) => handleDrop(e, pos)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar border-b border-border bg-surface p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<input className={`${inputClass} flex-1`} value={wf.name} onChange={(e) => setWf({ ...wf, name: e.target.value })} />
|
||||
<Button variant="secondary" loading={saving} onClick={save}>
|
||||
Save
|
||||
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-signal" />
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="text-text-secondary">Workflows /</span>
|
||||
<span className="font-medium text-text-primary">{wf.name}</span>
|
||||
<span className="text-text-secondary">· {saving ? "Saving…" : lastSaved ? `Saved ${timeAgo(lastSaved)}` : ""}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="rounded-full border border-border bg-surface-2 px-3 py-1 text-xs text-text-secondary">{wf.target_server_ids.length} servers</span>
|
||||
<Link href={`/workflows/${id}/runs`} className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary">
|
||||
Runs
|
||||
</Link>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditWorkflowOpen(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="primary" loading={running} onClick={run}>
|
||||
Run Workflow
|
||||
<Button size="sm" loading={running} onClick={run} className="bg-signal text-signal-ink border-transparent hover:bg-signal/90">
|
||||
Run workflow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-[calc(100vh-0px)] grid-cols-[264px_1fr_320px]">
|
||||
{/* LEFT: library */}
|
||||
<aside className="overflow-auto border-r border-border bg-surface p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wide text-text-secondary">Step Library</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowNewStep((v) => !v)}>
|
||||
{showNewStep ? "Close" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
{showNewStep && <NewStepForm onClose={() => setShowNewStep(false)} />}
|
||||
{library?.map((s) => (
|
||||
<button key={s.step_id} onClick={() => addStep(s)} className="mb-2 block w-full rounded-lg border border-border bg-surface-2 p-2 text-left hover:border-accent">
|
||||
<span className="font-mono text-[10px] uppercase text-accent">{s.interpreter}</span>
|
||||
<div className="text-sm font-medium text-text-primary">{s.name}</div>
|
||||
</button>
|
||||
))}
|
||||
{library && library.length === 0 && <p className="text-xs text-text-secondary">No steps yet. Add one above.</p>}
|
||||
</aside>
|
||||
|
||||
{error && <div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>}
|
||||
{notice && <div className="border-b border-signal/30 bg-signal/10 px-4 py-2 text-sm text-signal">{notice}</div>}
|
||||
|
||||
<div className="grid h-[calc(100vh-53px)] grid-cols-[1fr_320px]">
|
||||
{/* CENTER: canvas */}
|
||||
<main className="overflow-auto p-6">
|
||||
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
|
||||
<Card className="mb-4">
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-wide text-text-secondary">Target servers</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const isChecked = wf.target_server_ids.includes(s.server_id);
|
||||
return (
|
||||
<label
|
||||
key={s.server_id}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${
|
||||
isChecked ? "border-accent bg-accent/10 text-text-primary" : "border-border text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
<input type="checkbox" className="accent-accent" checked={isChecked} onChange={() => toggleTargetServer(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mx-auto flex max-w-md flex-col items-center gap-2">
|
||||
<main className="overflow-auto bg-background bg-[radial-gradient(circle_at_1px_1px,theme(colors.border)_1px,transparent_0)] bg-[length:22px_22px] p-8">
|
||||
<div className="pointer-events-none sticky top-0 z-10 flex justify-center pt-4">
|
||||
<button
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="pointer-events-auto inline-flex items-center gap-2 rounded-[9px] bg-signal px-4 py-2.5 text-sm font-semibold text-signal-ink shadow-[0_6px_20px_rgba(245,165,36,0.28)] hover:bg-signal/90"
|
||||
>
|
||||
<span className="text-base leading-none">+</span> Add step
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto flex w-[340px] flex-col items-center">
|
||||
<DropZone pos={0} />
|
||||
{sortedSteps.map((ref, i) => {
|
||||
const lib = libById(ref.step_id);
|
||||
const outs = sortedSteps.slice(0, i).flatMap((r) => libById(r.step_id)?.declared_outputs ?? []);
|
||||
const script = ref.overrides?.script ?? lib?.script ?? "";
|
||||
const outs = upstreamOutputsFor(i);
|
||||
const script = ref.inline?.script ?? ref.overrides?.script ?? lib?.script ?? "";
|
||||
const wfIdx = wf.steps.indexOf(ref);
|
||||
const isSelected = selected === i;
|
||||
return (
|
||||
<div key={i} className="w-full">
|
||||
{i > 0 && outs.length > 0 && (
|
||||
<div className="mx-auto my-1 flex w-fit flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-accent/50 px-3 py-1">
|
||||
<span className="text-[10px] uppercase text-text-secondary">passes</span>
|
||||
{outs.map((o) => (
|
||||
<span key={o} className="rounded bg-accent px-2 py-0.5 font-mono text-[11px] text-white">
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
<div key={wfIdx} className="w-full">
|
||||
{i > 0 && (
|
||||
<div className="flex flex-col items-center py-1">
|
||||
<div className="h-[13px] w-0.5 bg-border" />
|
||||
{outs.length > 0 && (
|
||||
<div className="flex w-fit max-w-[300px] flex-wrap items-center justify-center gap-1 rounded-full border border-dashed border-signal/55 bg-surface px-3 py-1">
|
||||
<span className="text-[10px] uppercase text-text-secondary">passes</span>
|
||||
{outs.map((o) => (
|
||||
<span key={o} className="rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[13px] w-0.5 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-full rounded-lg border bg-surface p-3 ${selected === i ? "border-accent" : "border-border"}`}>
|
||||
<button onClick={() => setSelected(i)} className="block w-full text-left">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] uppercase text-accent">{lib?.interpreter}</span>
|
||||
<span className="font-medium text-text-primary">{lib?.name ?? ref.step_id}</span>
|
||||
<span className="ml-auto rounded bg-surface-2 px-2 py-0.5 text-[10px] uppercase text-text-secondary">{ref.on_failure}</span>
|
||||
</div>
|
||||
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap font-mono text-[11px] text-text-secondary">{script.slice(0, 160)}</pre>
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={i === 0} onClick={() => moveStep(i, -1)}>
|
||||
↑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={i === sortedSteps.length - 1} onClick={() => moveStep(i, 1)}>
|
||||
↓
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => removeStep(wfIdx)}>
|
||||
Remove
|
||||
</Button>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", JSON.stringify({ kind: "move", from: i }));
|
||||
}}
|
||||
onClick={() => setSelected(i)}
|
||||
className={`w-[340px] cursor-pointer rounded-[10px] border bg-surface p-3 ${isSelected ? "border-signal ring-2 ring-signal/40" : "border-border"}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="grid h-5 w-5 place-items-center rounded border border-border font-mono text-[10px] text-text-secondary">{i + 1}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{ref.inline?.name ?? lib?.name ?? ref.step_id}</span>
|
||||
{ref.inline && <ShellBadge interpreter={ref.inline.interpreter} />}
|
||||
{lib && !ref.inline && <ShellBadge interpreter={lib.interpreter} />}
|
||||
{ref.inline && <AdhocBadge />}
|
||||
</div>
|
||||
<pre className="max-h-16 overflow-hidden text-ellipsis whitespace-pre-wrap rounded border border-border bg-surface-2 p-2 font-mono text-xs text-text-secondary">
|
||||
{script.slice(0, 200)}
|
||||
</pre>
|
||||
</div>
|
||||
<DropZone pos={i + 1} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{wf.steps.length === 0 && <p className="py-10 text-text-secondary">Click a step on the left to add it.</p>}
|
||||
{sortedSteps.length === 0 && (
|
||||
<button
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDrop(e, 0)}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="mt-2 w-full rounded-[10px] border border-dashed border-border bg-surface py-6 text-sm text-text-secondary hover:border-signal/50 hover:text-text-primary"
|
||||
>
|
||||
+ Add your first step
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -297,22 +405,142 @@ export default function WorkflowBuilder() {
|
||||
<p className="text-sm text-text-secondary">Select a step to configure it.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-bold text-text-primary">{selectedLib?.name ?? selectedRef.step_id}</h2>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-40 font-mono text-xs`}
|
||||
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
overrides: { ...selectedRef.overrides, script: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-text-secondary">Step {selected + 1} · Inspector</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedRef.inline && <ShellBadge interpreter={selectedRef.inline.interpreter} />}
|
||||
{selectedLib && !selectedRef.inline && <ShellBadge interpreter={selectedLib.interpreter} />}
|
||||
{selectedRef.inline && <AdhocBadge />}
|
||||
<h2 className="text-sm font-bold text-text-primary">{selectedRef.inline?.name ?? selectedLib?.name ?? selectedRef.step_id}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{selectedRef.inline ? (
|
||||
<div className="space-y-4 border-b border-border pb-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={selectedRef.inline.name} onChange={(e) => updateInline(selectedIdxInWf, { name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={selectedRef.inline.interpreter}
|
||||
onChange={(e) =>
|
||||
updateInline(selectedIdxInWf, {
|
||||
interpreter: e.target.value as WorkflowStep["interpreter"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="bash">bash</option>
|
||||
<option value="powershell">powershell</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.inline.script}
|
||||
onChange={(e) => updateInline(selectedIdxInWf, { script: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps. Outputs are derived
|
||||
automatically on save.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Command</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-32 font-mono text-xs`}
|
||||
value={selectedRef.overrides?.script ?? selectedLib?.script ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
overrides: { ...selectedRef.overrides, script: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedRef.inline?.declared_inputs ?? selectedLib?.declared_inputs ?? []).length > 0 && (
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{(selectedRef.inline?.declared_inputs ?? selectedLib?.declared_inputs ?? []).map((param) => (
|
||||
<div key={param.name}>
|
||||
<div className="mb-1 font-mono text-xs text-text-primary">{param.name}</div>
|
||||
{param.description && <div className="mb-1 text-[11px] text-text-secondary">{param.description}</div>}
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={param.default}
|
||||
value={selectedRef.inputs?.[param.name] ?? ""}
|
||||
onChange={(e) =>
|
||||
updateRef(selectedIdxInWf, {
|
||||
inputs: { ...selectedRef.inputs, [param.name]: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Inputs · from upstream</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{upstreamOutputsFor(selected).length === 0 && <p className="text-xs text-text-secondary">No upstream outputs.</p>}
|
||||
{upstreamOutputsFor(selected).map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-surface-2 border border-border px-2 py-0.5 font-mono text-[11px] text-text-primary">
|
||||
<span className="text-[9px] uppercase text-text-secondary">in</span>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Outputs · to $WORKFLOW_ENV</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(selectedRef.inline?.declared_outputs ?? selectedLib?.declared_outputs ?? []).length === 0 && <p className="text-xs text-text-secondary">No declared outputs.</p>}
|
||||
{(selectedRef.inline?.declared_outputs ?? selectedLib?.declared_outputs ?? []).map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
<span className="text-[9px] uppercase">out</span>
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-2 block text-xs uppercase text-text-secondary">Secret refs</label>
|
||||
<div className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-2">
|
||||
{secretGroups?.map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="font-mono text-[11px] font-semibold text-text-secondary">{g.group}</div>
|
||||
{(groupKeys[g.group] ?? []).map((key) => {
|
||||
const ref = `${g.group}/${key}`;
|
||||
const checked = (selectedRef.inline ? (selectedRef.inline.secret_refs ?? []) : (selectedRef.overrides?.secret_refs ?? [])).includes(ref);
|
||||
return (
|
||||
<label key={ref} className="ml-2 flex cursor-pointer items-center gap-2 text-xs text-text-primary">
|
||||
<input type="checkbox" className="accent-signal" checked={checked} onChange={() => toggleSecretRef(ref)} />
|
||||
<span className="font-mono">{key}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(groupKeys[g.group] ?? []).length === 0 && <p className="ml-2 text-[11px] text-text-secondary">No keys.</p>}
|
||||
</div>
|
||||
))}
|
||||
{secretGroups && secretGroups.length === 0 && <p className="text-xs text-text-secondary">No secret groups yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">On failure</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
@@ -327,51 +555,62 @@ export default function WorkflowBuilder() {
|
||||
<option value="continue">Continue</option>
|
||||
<option value="retry">Retry</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedRef.on_failure === "retry" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Max retries</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className={inputClass}
|
||||
value={selectedRef.max_retries}
|
||||
onChange={(e) => updateRef(selectedIdxInWf, { max_retries: parseInt(e.target.value || "0", 10) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Secret refs</label>
|
||||
<div className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-2">
|
||||
{secretGroups?.map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="font-mono text-[11px] font-semibold text-text-secondary">{g.group}</div>
|
||||
{(groupKeys[g.group] ?? []).map((key) => {
|
||||
const ref = `${g.group}/${key}`;
|
||||
const checked = (selectedRef.overrides?.secret_refs ?? []).includes(ref);
|
||||
return (
|
||||
<label key={ref} className="ml-2 flex cursor-pointer items-center gap-2 text-xs text-text-primary">
|
||||
<input type="checkbox" className="accent-accent" checked={checked} onChange={() => toggleSecretRef(ref)} />
|
||||
<span className="font-mono">{key}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{(groupKeys[g.group] ?? []).length === 0 && <p className="ml-2 text-[11px] text-text-secondary">No keys.</p>}
|
||||
</div>
|
||||
))}
|
||||
{secretGroups && secretGroups.length === 0 && <p className="text-xs text-text-secondary">No secret groups yet.</p>}
|
||||
</div>
|
||||
{selectedRef.on_failure === "retry" && (
|
||||
<div className="mt-2">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Max retries</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className={inputClass}
|
||||
value={selectedRef.max_retries}
|
||||
onChange={(e) => updateRef(selectedIdxInWf, { max_retries: parseInt(e.target.value || "0", 10) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="danger" size="sm" onClick={() => removeStep(selectedIdxInWf)}>
|
||||
Remove step
|
||||
Remove from workflow
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<EditWorkflowModal
|
||||
open={editWorkflowOpen}
|
||||
workflow={wf}
|
||||
onSaved={(w) => {
|
||||
// The modal already persisted w; sync the snapshot so
|
||||
// autosave doesn't fire a redundant follow-up save.
|
||||
savedSnapshotRef.current = snapshotOf(w);
|
||||
setWf(w);
|
||||
}}
|
||||
onClose={() => setEditWorkflowOpen(false)}
|
||||
/>
|
||||
<StepPickerModal
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={(stepId) => insertLibStep(stepId, sortedSteps.length)}
|
||||
onAddAdhoc={() => {
|
||||
addAdhocStep();
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
onImportAdhoc={async (file) => {
|
||||
setPickerOpen(false);
|
||||
setImportingInline(true);
|
||||
setError(null);
|
||||
try {
|
||||
const doc = JSON.parse(await file.text());
|
||||
const step = await api.parseStep(doc);
|
||||
appendRef({ inline: step, order: wf.steps.length, on_failure: "stop", max_retries: 0 });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setImportingInline(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,442 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, ServerRun, StepRun } from "@/lib/api";
|
||||
import { Button, Badge, Card } from "@/components/ui";
|
||||
import { api, ServerRun, StepRun, WorkflowRun } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
|
||||
// ---- status vocabulary ----------------------------------------------------
|
||||
|
||||
const statusVariant: Record<string, BadgeVariant> = {
|
||||
success: "success",
|
||||
failed: "danger",
|
||||
running: "accent",
|
||||
queued: "neutral",
|
||||
skipped: "neutral",
|
||||
cancelled: "warning",
|
||||
type CellKind = "done" | "fail" | "run" | "wait" | "skip" | "warn";
|
||||
|
||||
function cellKind(status: string): CellKind {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "done";
|
||||
case "failed":
|
||||
return "fail";
|
||||
case "running":
|
||||
return "run";
|
||||
case "skipped":
|
||||
return "skip";
|
||||
case "cancelled":
|
||||
return "warn";
|
||||
default:
|
||||
return "wait"; // queued / pending / missing
|
||||
}
|
||||
}
|
||||
|
||||
const cellGlyph: Record<CellKind, string> = {
|
||||
done: "✓",
|
||||
fail: "✕",
|
||||
run: "●",
|
||||
wait: "○",
|
||||
skip: "–",
|
||||
warn: "!",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={statusVariant[status] ?? "neutral"}>{status}</Badge>;
|
||||
const cellClass: Record<CellKind, string> = {
|
||||
done: "bg-success/15 text-success",
|
||||
fail: "bg-danger/15 text-danger",
|
||||
run: "bg-accent/15 text-accent",
|
||||
wait: "text-border",
|
||||
skip: "text-text-secondary",
|
||||
warn: "bg-warning/15 text-warning",
|
||||
};
|
||||
|
||||
// ---- run-level status pill ------------------------------------------------
|
||||
|
||||
type PillKind = "running" | "success" | "failed" | "neutral";
|
||||
|
||||
function pillKind(status: string): PillKind {
|
||||
if (status === "running") return "running";
|
||||
if (status === "success") return "success";
|
||||
if (status === "failed" || status === "cancelled") return "failed";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
const pillClass: Record<PillKind, string> = {
|
||||
running: "text-accent border-accent/40 bg-accent/10",
|
||||
success: "text-success border-success/35 bg-success/10",
|
||||
failed: "text-danger border-danger/35 bg-danger/10",
|
||||
neutral: "text-text-secondary border-border bg-surface-2",
|
||||
};
|
||||
|
||||
const pillLed: Record<PillKind, string> = {
|
||||
running: "bg-accent led-pulse",
|
||||
success: "bg-success",
|
||||
failed: "bg-danger",
|
||||
neutral: "bg-text-secondary",
|
||||
};
|
||||
|
||||
function StatusPill({ status, small }: { status: string; small?: boolean }) {
|
||||
const kind = pillKind(status);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 rounded-full border font-mono font-semibold uppercase tracking-wide ${
|
||||
small ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-xs"
|
||||
} ${pillClass[kind]}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${pillLed[kind]}`} />
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- time helpers ---------------------------------------------------------
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 0) ms = 0;
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
if (m < 60) return `${m}m ${rem}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function stepDuration(st: StepRun, running: boolean, now: number): string {
|
||||
if (!st.started_at) return st.status === "queued" ? "queued" : "";
|
||||
const start = new Date(st.started_at).getTime();
|
||||
const end = st.finished_at ? new Date(st.finished_at).getTime() : running ? now : start;
|
||||
return fmtDuration(end - start);
|
||||
}
|
||||
|
||||
// ---- live log terminal ----------------------------------------------------
|
||||
|
||||
function LogTerminal({ runId, server }: { runId: string; server: ServerRun }) {
|
||||
const [text, setText] = useState("");
|
||||
const preRef = useRef<HTMLDivElement>(null);
|
||||
const running = server.status === "running";
|
||||
const serverId = server.server_id;
|
||||
|
||||
useEffect(() => {
|
||||
setText("");
|
||||
if (running) {
|
||||
const es = new EventSource(api.serverRunLogStreamUrl(runId, serverId), {
|
||||
withCredentials: true,
|
||||
});
|
||||
es.onmessage = (e) => setText((t) => t + e.data + "\n");
|
||||
es.addEventListener("done", () => es.close());
|
||||
es.onerror = () => es.close();
|
||||
return () => es.close();
|
||||
}
|
||||
api.getServerRunLog(runId, serverId)
|
||||
.then(setText)
|
||||
.catch(() => setText(""));
|
||||
}, [running, runId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
preRef.current?.scrollTo(0, preRef.current.scrollHeight);
|
||||
}, [text]);
|
||||
|
||||
const activeStep = server.steps.find((s) => s.status === "running") ?? [...server.steps].reverse().find((s) => s.started_at);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-[#0a0b10]">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface px-4 py-3">
|
||||
<span className="truncate font-mono text-[13px] font-semibold text-text-primary">
|
||||
{activeStep ? activeStep.name : "Output"} <span className="font-normal text-text-secondary">{server.hostname}</span>
|
||||
</span>
|
||||
{running && (
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-wide text-accent">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent led-pulse" />
|
||||
Streaming
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div ref={preRef} className="max-h-[340px] overflow-auto whitespace-pre-wrap px-4 py-3.5 font-mono text-[12.5px] leading-relaxed text-text-secondary">
|
||||
{text ? <LogLines text={text} /> : running ? "Waiting for output…" : "No output."}
|
||||
{running && text && <span className="ml-0.5 inline-block h-3.5 w-[7px] translate-y-[2px] bg-accent caret-blink align-baseline" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LogLines renders the raw server-run log, parsing each line's leading UTC
|
||||
// timestamp ([2026-07-20T12:04:02.000Z]) and rendering it in the viewer's local
|
||||
// timezone. Event markers (===== …) are highlighted so the run's shape scans.
|
||||
const TS_RE = /^\[(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\]\s?(.*)$/;
|
||||
|
||||
function LogLines({ text }: { text: string }) {
|
||||
const lines = text.replace(/\n$/, "").split("\n");
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const m = TS_RE.exec(line);
|
||||
if (!m) {
|
||||
return (
|
||||
<span key={i} className="block">
|
||||
{line || " "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const local = new Date(m[1]).toLocaleTimeString([], { hour12: false });
|
||||
const body = m[2];
|
||||
const isMarker = body.startsWith("=====");
|
||||
return (
|
||||
<span key={i} className="block">
|
||||
<span className="select-none text-[#565b74]" title={m[1]}>
|
||||
{local}{" "}
|
||||
</span>
|
||||
<span className={isMarker ? "font-semibold text-accent" : ""}>{body || " "}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- step list ------------------------------------------------------------
|
||||
|
||||
function StepList({ server, now }: { server: ServerRun; now: number }) {
|
||||
const running = server.status === "running";
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-surface">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||
<span className="font-mono text-[13px] font-semibold text-text-primary">
|
||||
Steps <span className="font-normal text-text-secondary">{server.steps.length}</span>
|
||||
</span>
|
||||
<StatusPill status={server.status} small />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 p-1.5">
|
||||
{server.steps.map((st) => {
|
||||
const kind = cellKind(st.status);
|
||||
return (
|
||||
<div
|
||||
key={st.order}
|
||||
className={`grid grid-cols-[20px_1fr_auto] items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13px] hover:bg-surface-2 ${st.status === "running" ? "bg-accent/[0.06]" : ""}`}
|
||||
>
|
||||
<span className="text-right font-mono text-[11px] text-text-secondary">{String(st.order + 1).padStart(2, "0")}</span>
|
||||
<span className="flex items-center gap-2 font-medium text-text-primary">
|
||||
<span className={`font-mono ${cellClass[kind].replace(/bg-\S+/, "")}`}>{cellGlyph[kind]}</span>
|
||||
{st.name}
|
||||
</span>
|
||||
<span className="text-right font-mono text-[10.5px] text-text-secondary">
|
||||
{st.status === "failed" && <span className="text-danger">exit {st.exit_code} · </span>}
|
||||
{st.attempts > 1 ? `${st.attempts} tries` : "1 try"}
|
||||
{stepDuration(st, running, now) ? ` · ${stepDuration(st, running, now)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{server.steps.length === 0 && <p className="px-3 py-2 text-xs text-text-secondary">No steps yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- execution matrix (signature) -----------------------------------------
|
||||
|
||||
interface Column {
|
||||
order: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function buildColumns(run: WorkflowRun): Column[] {
|
||||
const byOrder = new Map<number, string>();
|
||||
for (const sr of run.server_runs) {
|
||||
for (const st of sr.steps) {
|
||||
if (!byOrder.has(st.order)) byOrder.set(st.order, st.name);
|
||||
}
|
||||
}
|
||||
return [...byOrder.entries()].map(([order, name]) => ({ order, name })).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
function ExecutionMatrix({ run, columns, selected, onSelect }: { run: WorkflowRun; columns: Column[]; selected: string; onSelect: (serverId: string) => void }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-surface">
|
||||
<table className="w-full border-collapse font-mono text-[12.5px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border-b border-border px-4 py-3 text-left align-bottom text-xs font-semibold uppercase tracking-wider text-text-primary">Server</th>
|
||||
{columns.map((c) => (
|
||||
<th key={c.order} className="whitespace-nowrap border-b border-border px-3.5 py-3 align-bottom text-[11px] font-medium text-text-secondary">
|
||||
<span className="block text-[10px] text-border">{String(c.order + 1).padStart(2, "0")}</span>
|
||||
{c.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.server_runs.map((sr) => {
|
||||
const byOrder = new Map(sr.steps.map((s) => [s.order, s]));
|
||||
const isSel = sr.server_id === selected;
|
||||
return (
|
||||
<tr key={sr.server_id} onClick={() => onSelect(sr.server_id)} className={`cursor-pointer ${isSel ? "bg-accent/5" : "hover:bg-white/[0.02]"}`}>
|
||||
<th className="min-w-[240px] border-b border-r border-border px-4 py-3 text-left font-medium text-text-primary">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex-1 whitespace-nowrap">{sr.hostname}</span>
|
||||
<StatusPill status={sr.status} small />
|
||||
</div>
|
||||
</th>
|
||||
{columns.map((c) => {
|
||||
const st = byOrder.get(c.order);
|
||||
const kind = st ? cellKind(st.status) : "wait";
|
||||
return (
|
||||
<td key={c.order} className="relative border-b border-r border-border last:border-r-0">
|
||||
<span className="flex h-[54px] items-center justify-center">
|
||||
<span className={`relative flex h-[26px] w-[26px] items-center justify-center rounded-md ${cellClass[kind]}`}>
|
||||
{kind === "run" && <span className="absolute inset-0 rounded-md border border-accent/50 cell-ring" />}
|
||||
{cellGlyph[kind]}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- page -----------------------------------------------------------------
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-3 mt-8 flex items-center gap-2.5 font-mono text-[11px] uppercase tracking-widest text-text-secondary">
|
||||
{children}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunDetail() {
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const { runId } = useParams<{ runId: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const { data: run, isLoading } = useQuery({
|
||||
queryKey: ["run", runId],
|
||||
queryFn: () => api.getRun(runId),
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
const { data: run, isLoading } = useQuery({
|
||||
queryKey: ["run", runId],
|
||||
queryFn: () => api.getRun(runId),
|
||||
refetchInterval: (query) => (query.state.data?.status === "running" ? 2000 : false),
|
||||
});
|
||||
|
||||
const cancel = async () => {
|
||||
await api.cancelRun(runId);
|
||||
queryClient.invalidateQueries({ queryKey: ["run", runId] });
|
||||
};
|
||||
const running = run?.status === "running";
|
||||
|
||||
if (isLoading || !run) {
|
||||
return <div className="p-8 text-text-secondary">Loading…</div>;
|
||||
}
|
||||
// tick the elapsed clock while running
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [running]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{run.name}</h1>
|
||||
<p className="mt-1 flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>Run {run.run_id.slice(0, 8)}</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</p>
|
||||
const columns = useMemo(() => (run ? buildColumns(run) : []), [run]);
|
||||
|
||||
// default selection: first running server, else first server
|
||||
const selectedServer = useMemo(() => {
|
||||
if (!run || run.server_runs.length === 0) return null;
|
||||
if (selected) {
|
||||
const match = run.server_runs.find((s) => s.server_id === selected);
|
||||
if (match) return match;
|
||||
}
|
||||
return run.server_runs.find((s) => s.status === "running") ?? run.server_runs[0];
|
||||
}, [run, selected]);
|
||||
|
||||
const cancel = async () => {
|
||||
await api.cancelRun(runId);
|
||||
queryClient.invalidateQueries({ queryKey: ["run", runId] });
|
||||
};
|
||||
|
||||
if (isLoading || !run) {
|
||||
return <div className="p-8 text-text-secondary">Loading…</div>;
|
||||
}
|
||||
|
||||
const totalSteps = run.server_runs.reduce((n, s) => n + s.steps.length, 0);
|
||||
const doneSteps = run.server_runs.reduce((n, s) => n + s.steps.filter((st) => st.status === "success").length, 0);
|
||||
const succeeded = run.server_runs.filter((s) => s.status === "success").length;
|
||||
const failed = run.server_runs.filter((s) => s.status === "failed" || s.status === "cancelled").length;
|
||||
|
||||
const startMs = run.started_at ? new Date(run.started_at).getTime() : now;
|
||||
const endMs = run.finished_at ? new Date(run.finished_at).getTime() : now;
|
||||
const elapsed = fmtDuration(endMs - startMs);
|
||||
const ago = fmtDuration(now - startMs);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1180px] p-8 pb-16">
|
||||
{/* identity bar */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div>
|
||||
<div className="mb-2 font-mono text-xs uppercase tracking-wide text-text-secondary">Workflows / {run.name} / Runs</div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight text-text-primary">{run.name}</h1>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-[12.5px] text-text-secondary">
|
||||
<span>
|
||||
run <b className="font-medium text-text-primary">{run.run_id.slice(0, 8)}</b>
|
||||
</span>
|
||||
<span className="h-[3px] w-[3px] rounded-full bg-border" />
|
||||
<span>
|
||||
triggered by <b className="font-medium text-text-primary">{run.triggered_by || "—"}</b>
|
||||
</span>
|
||||
<span className="h-[3px] w-[3px] rounded-full bg-border" />
|
||||
<span>
|
||||
started <b className="font-medium text-text-primary">{ago}</b> ago
|
||||
</span>
|
||||
<span className="h-[3px] w-[3px] rounded-full bg-border" />
|
||||
<span>
|
||||
elapsed <b className="font-medium text-text-primary">{elapsed}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<StatusPill status={run.status} />
|
||||
{running && (
|
||||
<Button variant="danger" onClick={cancel}>
|
||||
Cancel run
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* summary strip */}
|
||||
<div className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-4">
|
||||
<div className="bg-surface px-[18px] py-4">
|
||||
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Servers</div>
|
||||
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">{run.server_runs.length}</div>
|
||||
</div>
|
||||
<div className="bg-surface px-[18px] py-4">
|
||||
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Succeeded</div>
|
||||
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-success">
|
||||
{succeeded}
|
||||
<small className="text-sm font-medium text-text-secondary"> / {run.server_runs.length}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface px-[18px] py-4">
|
||||
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Failed</div>
|
||||
<div className={`mt-1 font-mono text-[22px] font-semibold tabular-nums ${failed > 0 ? "text-danger" : "text-text-primary"}`}>{failed}</div>
|
||||
</div>
|
||||
<div className="bg-surface px-[18px] py-4">
|
||||
<div className="font-mono text-[10.5px] uppercase tracking-wider text-text-secondary">Steps done</div>
|
||||
<div className="mt-1 font-mono text-[22px] font-semibold tabular-nums text-text-primary">
|
||||
{doneSteps}
|
||||
<small className="text-sm font-medium text-text-secondary"> / {totalSteps}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run.server_runs.length === 0 ? (
|
||||
<p className="mt-8 text-text-secondary">No servers targeted by this run.</p>
|
||||
) : (
|
||||
<>
|
||||
<SectionLabel>Execution matrix</SectionLabel>
|
||||
<ExecutionMatrix run={run} columns={columns} selected={selectedServer?.server_id ?? ""} onSelect={setSelected} />
|
||||
|
||||
{selectedServer && (
|
||||
<>
|
||||
<SectionLabel>{selectedServer.hostname} · steps & live output</SectionLabel>
|
||||
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-[320px_1fr]">
|
||||
<StepList server={selectedServer} now={now} />
|
||||
<LogTerminal runId={run.run_id} server={selectedServer} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{run.status === "running" && (
|
||||
<Button variant="danger" onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{run.server_runs.map((sr: ServerRun) => (
|
||||
<Card key={sr.server_id}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-text-primary">{sr.hostname}</span>
|
||||
<StatusBadge status={sr.status} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sr.steps.map((st: StepRun) => (
|
||||
<details
|
||||
key={st.order}
|
||||
className="rounded-lg border border-border bg-surface-2 p-2"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between gap-2">
|
||||
<span className="text-sm text-text-primary">{st.name}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">
|
||||
attempts: {st.attempts}
|
||||
{st.status === "failed" ? ` · exit ${st.exit_code}` : ""}
|
||||
</span>
|
||||
<StatusBadge status={st.status} />
|
||||
</span>
|
||||
</summary>
|
||||
{(st.stdout || st.stderr) && (
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-black/40 p-2 font-mono text-xs text-text-secondary">
|
||||
{st.stdout}
|
||||
{st.stderr ? `\n${st.stderr}` : ""}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
{sr.steps.length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No steps yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{run.server_runs.length === 0 && (
|
||||
<p className="text-text-secondary">No servers targeted by this run.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, WorkflowRun } from "@/lib/api";
|
||||
import { Card, Table, Thead, Tbody, Tr, Th, Td, Badge } from "@/components/ui";
|
||||
|
||||
type BadgeVariant = "success" | "warning" | "danger" | "neutral" | "accent";
|
||||
|
||||
const statusVariant: Record<string, BadgeVariant> = {
|
||||
success: "success",
|
||||
failed: "danger",
|
||||
running: "warning",
|
||||
cancelled: "neutral",
|
||||
queued: "neutral",
|
||||
};
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: wf } = useQuery({ queryKey: ["workflow", id], queryFn: () => api.getWorkflow(id) });
|
||||
const { data: runs, isLoading, error } = useQuery({ queryKey: ["runs", id], queryFn: () => api.listRuns(id) });
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<Link href={`/workflows/${id}`} className="text-sm text-text-secondary hover:text-text-primary">
|
||||
← Back to builder
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">Runs · {wf?.name ?? ""}</h1>
|
||||
</div>
|
||||
|
||||
<Card padding={false}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-20 text-center text-danger">Failed to load runs. Is the backend running?</div>
|
||||
) : runs && runs.length > 0 ? (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Run</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Started</Th>
|
||||
<Th>By</Th>
|
||||
<Th>Servers</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{runs.map((r: WorkflowRun) => (
|
||||
<Tr key={r.run_id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/workflows/${id}/runs/${r.run_id}`}
|
||||
className="font-mono text-text-primary hover:text-signal"
|
||||
>
|
||||
{r.run_id.slice(0, 8)}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={statusVariant[r.status] ?? "neutral"}>{r.status}</Badge>
|
||||
</Td>
|
||||
<Td className="text-text-secondary">{new Date(r.started_at).toLocaleString()}</Td>
|
||||
<Td className="text-text-secondary">{r.triggered_by}</Td>
|
||||
<Td className="text-text-secondary">{r.server_runs.length}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="py-16 text-center text-text-secondary">No runs yet.</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,9 +82,14 @@ export default function WorkflowsPage() {
|
||||
<span className="text-text-secondary">{w.steps.length}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">Open →</Button>
|
||||
</Link>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/workflows/${w.workflow_id}/runs`}>
|
||||
<Button variant="ghost" size="sm">Runs</Button>
|
||||
</Link>
|
||||
<Link href={`/workflows/${w.workflow_id}`}>
|
||||
<Button variant="ghost" size="sm">Open →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
|
||||
@@ -60,11 +60,29 @@ function SettingsIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12h4l2 6 4-14 2 8h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.75A.75.75 0 016.75 6h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 6.75zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 12zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 17.25zM3 6.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM3 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 5.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
||||
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
||||
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
|
||||
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
|
||||
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
||||
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
|
||||
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, Monitor, MonitorInput, MonitorType } from "@/lib/api";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
|
||||
const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary";
|
||||
|
||||
export function MonitorForm({
|
||||
initial,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
isPending,
|
||||
error,
|
||||
}: {
|
||||
initial?: Monitor;
|
||||
submitLabel: string;
|
||||
onSubmit: (input: MonitorInput) => void;
|
||||
isPending: boolean;
|
||||
error?: Error | null;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
|
||||
const [url, setUrl] = useState(initial?.target.url ?? "");
|
||||
const [host, setHost] = useState(initial?.target.host ?? "");
|
||||
const [port, setPort] = useState<number>(initial?.target.port ?? 443);
|
||||
const [method, setMethod] = useState(initial?.target.method ?? "GET");
|
||||
const [expectedStatus, setExpectedStatus] = useState<number>(initial?.target.expected_status ?? 200);
|
||||
const [keyword, setKeyword] = useState(initial?.target.keyword ?? "");
|
||||
const [tlsWarnDays, setTlsWarnDays] = useState<number>(initial?.target.tls_warn_days ?? 14);
|
||||
const [insecure, setInsecure] = useState<boolean>(initial?.target.insecure ?? false);
|
||||
const [intervalSec, setIntervalSec] = useState<number>(initial?.interval_sec ?? 60);
|
||||
const [retries, setRetries] = useState<number>(initial?.retries ?? 1);
|
||||
const [runner, setRunner] = useState(initial?.runner ?? "server");
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
|
||||
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
|
||||
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const target: MonitorInput["target"] = {};
|
||||
if (type === "http") {
|
||||
target.url = url;
|
||||
target.method = method;
|
||||
target.expected_status = expectedStatus;
|
||||
if (keyword) target.keyword = keyword;
|
||||
target.insecure = insecure;
|
||||
} else if (type === "tls") {
|
||||
target.host = host;
|
||||
target.port = port || 443;
|
||||
target.tls_warn_days = tlsWarnDays;
|
||||
} else if (type === "icmp") {
|
||||
target.host = host;
|
||||
} else {
|
||||
target.host = host;
|
||||
target.port = port;
|
||||
}
|
||||
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. API health" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Type</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(["http", "tcp", "icmp", "tls"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
type === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>URL</label>
|
||||
<input className={inputClass} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/health" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Method</label>
|
||||
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||
<option>GET</option>
|
||||
<option>HEAD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Expected status</label>
|
||||
<input type="number" className={inputClass} value={expectedStatus} onChange={(e) => setExpectedStatus(Number(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Keyword (optional, body must contain)</label>
|
||||
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="e.g. ok" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={insecure} onChange={(e) => setInsecure(e.target.checked)} />
|
||||
Ignore TLS certificate errors (self-signed / expired)
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(type === "tcp" || type === "tls" || type === "icmp") && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Host</label>
|
||||
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} placeholder="example.com" required />
|
||||
</div>
|
||||
{type !== "icmp" && (
|
||||
<div>
|
||||
<label className={labelClass}>Port</label>
|
||||
<input type="number" className={inputClass} value={port} onChange={(e) => setPort(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "tls" && (
|
||||
<div>
|
||||
<label className={labelClass}>Warn days before expiry</label>
|
||||
<input type="number" className={inputClass} value={tlsWarnDays} onChange={(e) => setTlsWarnDays(Number(e.target.value))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Interval (seconds)</label>
|
||||
<input type="number" className={inputClass} value={intervalSec} onChange={(e) => setIntervalSec(Number(e.target.value))} min={10} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Retries before down</label>
|
||||
<input type="number" className={inputClass} value={retries} onChange={(e) => setRetries(Number(e.target.value))} min={1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Runner</label>
|
||||
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
|
||||
<option value="server">Server (central)</option>
|
||||
{servers?.map((s) => (
|
||||
<option key={s.server_id} value={s.server_id}>
|
||||
Agent · {s.hostname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-text-tertiary">Agent-run monitors require the agent monitor scheduler (P2).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notification channels</label>
|
||||
{!channels || channels.length === 0 ? (
|
||||
<p className="text-xs text-text-tertiary">
|
||||
No channels yet.{" "}
|
||||
<Link href="/settings/notifications" className="text-accent hover:underline">
|
||||
Add one
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{channels.map((ch) => (
|
||||
<label key={ch.channel_id} className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channelIds.includes(ch.channel_id)}
|
||||
onChange={(e) =>
|
||||
setChannelIds((prev) => (e.target.checked ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
|
||||
}
|
||||
/>
|
||||
{ch.name} <span className="text-text-tertiary">({ch.type})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error.message}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
<Link href="/monitors">
|
||||
<Button type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div
|
||||
className={`relative z-10 w-full ${wide ? "max-w-2xl" : "max-w-md"} max-h-[90vh] overflow-auto rounded-xl border border-border bg-surface shadow-2xl`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 className="text-sm font-bold text-text-primary">{title}</h2>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-text-primary" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { Button } from "./Button";
|
||||
export { Badge } from "./Badge";
|
||||
export { Card, CardHeader, CardTitle } from "./Card";
|
||||
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
|
||||
export { Modal } from "./Modal";
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep, InputParam } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditStepModal({ open, step, onClose }: { open: boolean; step: WorkflowStep | null; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(step?.name ?? "");
|
||||
const [interpreter, setInterpreter] = useState<"bash" | "powershell">(step?.interpreter ?? "bash");
|
||||
const [script, setScript] = useState(step?.script ?? "");
|
||||
const [inputs, setInputs] = useState<InputParam[]>(step?.declared_inputs ?? []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// NOTE: because state is seeded from props, render the modal conditionally
|
||||
// (parent mounts it only when opening) OR key it by step_id so it re-seeds.
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const payload: Partial<WorkflowStep> = {
|
||||
name: name.trim(), description: step?.description ?? "", interpreter, script,
|
||||
declared_inputs: inputs.filter((i) => i.name.trim() !== ""),
|
||||
secret_refs: step?.secret_refs ?? [],
|
||||
};
|
||||
if (step) await api.updateStep(step.step_id, payload);
|
||||
else await api.createStep(payload);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!step || !window.confirm("Delete this step? It will be removed from every workflow that uses it.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.deleteStep(step.step_id);
|
||||
qc.invalidateQueries({ queryKey: ["steps"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflow"] });
|
||||
onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={step ? "Edit base step" : "New step"} wide>
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<p className="text-xs text-text-secondary">Reusable steps are shared across all workflows. Editing here changes it everywhere.</p>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Interpreter</label>
|
||||
<select className={inputClass} value={interpreter} onChange={(e) => setInterpreter(e.target.value as "bash" | "powershell")}>
|
||||
<option value="bash">bash</option>
|
||||
<option value="powershell">powershell</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Script</label>
|
||||
<textarea className={`${inputClass} h-40 font-mono text-xs`} value={script} onChange={(e) => setScript(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-text-secondary">Write <code className="text-signal">KEY=value</code> to <code className="text-signal">$WORKFLOW_ENV</code> to expose it to later steps.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Outputs</label>
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
{(step?.declared_outputs ?? []).length === 0 && (
|
||||
<p className="text-xs text-text-secondary">No declared outputs.</p>
|
||||
)}
|
||||
{(step?.declared_outputs ?? []).map((o) => (
|
||||
<span key={o} className="flex items-center gap-1 rounded bg-signal px-2 py-0.5 font-mono text-[11px] text-signal-ink">
|
||||
{o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">Outputs are detected automatically from lines writing to $WORKFLOW_ENV.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Inputs</label>
|
||||
<div className="space-y-2">
|
||||
{inputs.map((inp, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input className={inputClass} placeholder="name" value={inp.name} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="default" value={inp.default} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, default: e.target.value } : x))} />
|
||||
<input className={inputClass} placeholder="description" value={inp.description} onChange={(e) => setInputs(inputs.map((x, j) => j === i ? { ...x, description: e.target.value } : x))} />
|
||||
<Button variant="ghost" size="sm" onClick={() => setInputs(inputs.filter((_, j) => j !== i))}>✕</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={() => setInputs([...inputs, { name: "", default: "", description: "" }])}>Add input</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{step ? <Button variant="danger" onClick={del} loading={busy}>Delete step</Button> : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open: boolean; workflow: Workflow; onSaved: (w: Workflow) => void; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: api.listServers });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(workflow.name);
|
||||
setTargets(workflow.target_server_ids);
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
onSaved(updated); onClose();
|
||||
} catch (e) { setError((e as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const del = async () => {
|
||||
if (!window.confirm("Delete this workflow? This cannot be undone.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await api.deleteWorkflow(workflow.workflow_id); router.push("/workflows"); }
|
||||
catch (e) { setError((e as Error).message); setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Edit workflow">
|
||||
<div className="space-y-4">
|
||||
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Name</label>
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const on = targets.includes(s.server_id);
|
||||
return (
|
||||
<label key={s.server_id} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}>
|
||||
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="danger" onClick={del} loading={busy}>Delete workflow</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={save} loading={busy} disabled={!name.trim()}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, WorkflowStep } from "@/lib/api";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
type Tab = "all" | "bash" | "powershell" | "adhoc";
|
||||
|
||||
function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
|
||||
const isBash = interpreter === "bash";
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase ${
|
||||
isBash ? "bg-bash/15 text-bash" : "bg-pwsh/15 text-pwsh"
|
||||
}`}
|
||||
>
|
||||
{isBash ? "bash" : "pwsh"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultBadge() {
|
||||
return (
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] uppercase text-text-secondary">
|
||||
default
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({ step, onAdd }: { step: WorkflowStep; onAdd: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="group relative rounded-[10px] border border-border bg-surface-2 p-3 text-left transition-colors hover:border-signal/55"
|
||||
>
|
||||
<span className="absolute right-3 top-3 text-xs font-semibold text-signal opacity-0 group-hover:opacity-100">
|
||||
+ Add
|
||||
</span>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<ShellBadge interpreter={step.interpreter} />
|
||||
{step.source === "default" && <DefaultBadge />}
|
||||
<span className="text-sm font-medium text-text-primary">{step.name}</span>
|
||||
</div>
|
||||
{step.description && <p className="text-xs text-text-secondary">{step.description}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{(step.declared_inputs ?? []).map((p) => (
|
||||
<span key={p.name} className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
|
||||
in {p.name}
|
||||
</span>
|
||||
))}
|
||||
{(step.declared_outputs ?? []).map((o) => (
|
||||
<span key={o} className="rounded border border-signal/35 bg-background px-1.5 py-0.5 font-mono text-[10px] text-signal">
|
||||
out {o}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
onAddAdhoc,
|
||||
onImportAdhoc,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (stepId: string) => void;
|
||||
onAddAdhoc: () => void;
|
||||
onImportAdhoc: (file: File) => void;
|
||||
}) {
|
||||
const { data: library } = useQuery({ queryKey: ["steps"], queryFn: api.listSteps });
|
||||
const [search, setSearch] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("all");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return (library ?? []).filter(
|
||||
(s) =>
|
||||
(s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q)) &&
|
||||
(tab === "all" || tab === "adhoc" ? true : s.interpreter === tab),
|
||||
);
|
||||
}, [library, search, tab]);
|
||||
|
||||
const group = (source: "default" | "shared", interp: "bash" | "powershell") =>
|
||||
filtered.filter(
|
||||
(s) => s.interpreter === interp && (source === "default" ? s.source === "default" : s.source !== "default"),
|
||||
);
|
||||
|
||||
const groups: { label: string; steps: WorkflowStep[] }[] = [
|
||||
{ label: "Default · Bash", steps: group("default", "bash") },
|
||||
{ label: "Default · PowerShell", steps: group("default", "powershell") },
|
||||
{ label: "Shared · Bash", steps: group("shared", "bash") },
|
||||
{ label: "Shared · PowerShell", steps: group("shared", "powershell") },
|
||||
];
|
||||
|
||||
const showLibrary = tab !== "adhoc";
|
||||
const showAdhocCards = tab === "all" || tab === "adhoc";
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Add a step" wide>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal"
|
||||
placeholder="Search steps by name or description…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
{(["all", "bash", "powershell", "adhoc"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`rounded-full border px-3 py-1 text-xs capitalize ${
|
||||
tab === t
|
||||
? "border-signal/50 bg-signal/15 text-signal"
|
||||
: "border-border bg-surface-2 text-text-secondary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{t === "all" ? "All" : t === "powershell" ? "PowerShell" : t === "adhoc" ? "Ad-hoc" : "Bash"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAdhocCards && (
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<button
|
||||
onClick={onAddAdhoc}
|
||||
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
|
||||
>
|
||||
+ New ad-hoc step
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex min-h-[74px] items-center justify-center gap-2 rounded-[10px] border border-dashed border-border text-sm text-text-secondary hover:border-signal/55 hover:text-signal"
|
||||
>
|
||||
⬆ Import ad-hoc from file
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onImportAdhoc(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLibrary &&
|
||||
groups.map(
|
||||
(g) =>
|
||||
g.steps.length > 0 && (
|
||||
<div key={g.label}>
|
||||
<div className="mb-2.5 flex items-center gap-2 text-[11px] font-bold uppercase tracking-wide text-text-secondary">
|
||||
{g.label}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{g.steps.map((s) => (
|
||||
<StepCard key={s.step_id} step={s} onAdd={() => onSelect(s.step_id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
{showLibrary && filtered.length === 0 && (
|
||||
<p className="text-sm text-text-secondary">No steps match your search.</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-secondary">
|
||||
Click a card to append it to the workflow · manage the library on the{" "}
|
||||
<a href="/steps" className="text-signal hover:underline">
|
||||
Steps
|
||||
</a>{" "}
|
||||
page.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+203
-4
@@ -7,6 +7,17 @@ export interface PackageUpdate {
|
||||
new_version: string;
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
|
||||
memory: { total_bytes: number; used_bytes: number };
|
||||
swap_total_bytes: number;
|
||||
swap_used_bytes: number;
|
||||
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
|
||||
kernel?: string;
|
||||
metrics_at?: string;
|
||||
static_at?: string;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
server_id: string;
|
||||
@@ -20,6 +31,89 @@ export interface Server {
|
||||
available_updates?: PackageUpdate[];
|
||||
updates_checked_at?: string;
|
||||
console_protocols?: string[];
|
||||
inventory?: Inventory;
|
||||
}
|
||||
|
||||
export type MonitorType = "http" | "tcp" | "icmp" | "tls";
|
||||
export type MonitorStatus = "up" | "down" | "pending";
|
||||
|
||||
export interface MonitorTarget {
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
method?: string;
|
||||
expected_status?: number;
|
||||
keyword?: string;
|
||||
tls_warn_days?: number;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export interface MonitorState {
|
||||
status: MonitorStatus;
|
||||
last_check_at?: string;
|
||||
latency_ms: number;
|
||||
message?: string;
|
||||
cert_expiry_at?: string;
|
||||
fails: number;
|
||||
}
|
||||
|
||||
export interface Monitor {
|
||||
monitor_id: string;
|
||||
name: string;
|
||||
type: MonitorType;
|
||||
target: MonitorTarget;
|
||||
interval_sec: number;
|
||||
runner: string; // "server" or a server_id
|
||||
retries: number;
|
||||
enabled: boolean;
|
||||
channel_ids?: string[];
|
||||
state: MonitorState;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MonitorInput {
|
||||
name: string;
|
||||
type: MonitorType;
|
||||
target: MonitorTarget;
|
||||
interval_sec: number;
|
||||
runner: string;
|
||||
retries: number;
|
||||
enabled: boolean;
|
||||
channel_ids?: string[];
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
incident_id: string;
|
||||
monitor_id: string;
|
||||
started_at: string;
|
||||
resolved_at?: string;
|
||||
cause?: string;
|
||||
}
|
||||
|
||||
export interface Rollup {
|
||||
monitor_id: string;
|
||||
period_start: string;
|
||||
checks: number;
|
||||
up_count: number;
|
||||
sum_latency: number;
|
||||
}
|
||||
|
||||
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
|
||||
|
||||
export interface NotificationChannel {
|
||||
channel_id: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
config: Record<string, string>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChannelInput {
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
config: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConsoleConnectRequest {
|
||||
@@ -95,6 +189,7 @@ export interface Settings {
|
||||
alerts: AlertSettings;
|
||||
email: EmailSettings;
|
||||
secrets: SecretsSettings;
|
||||
workflow_log_retention_days?: number | null;
|
||||
}
|
||||
|
||||
export interface SecretGroupSummary {
|
||||
@@ -132,6 +227,12 @@ export interface ServerWithKeys extends Server {
|
||||
keys: (Assignment & { key: Key })[];
|
||||
}
|
||||
|
||||
export interface InputParam {
|
||||
name: string;
|
||||
default: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
step_id: string;
|
||||
name: string;
|
||||
@@ -139,15 +240,20 @@ export interface WorkflowStep {
|
||||
interpreter: "bash" | "powershell";
|
||||
script: string;
|
||||
declared_outputs: string[];
|
||||
declared_inputs: InputParam[];
|
||||
secret_refs: string[];
|
||||
source?: "user" | "default";
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStepRef {
|
||||
step_id: string;
|
||||
step_id?: string;
|
||||
inline?: WorkflowStep;
|
||||
order: number;
|
||||
on_failure: "stop" | "continue" | "retry";
|
||||
max_retries: number;
|
||||
overrides?: { script?: string; secret_refs?: string[] };
|
||||
inputs?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
@@ -163,8 +269,7 @@ export interface StepRun {
|
||||
status: string;
|
||||
attempts: number;
|
||||
exit_code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
log_offset: number;
|
||||
output_env: Record<string, string>;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
@@ -255,6 +360,56 @@ export const api = {
|
||||
return `curl -fsSL "${window.location.origin}/update" | bash`;
|
||||
},
|
||||
|
||||
// Monitors
|
||||
listMonitors(): Promise<Monitor[]> {
|
||||
return request<Monitor[]>("/monitors");
|
||||
},
|
||||
|
||||
getMonitor(monitorId: string): Promise<Monitor> {
|
||||
return request<Monitor>(`/monitors/${monitorId}`);
|
||||
},
|
||||
|
||||
createMonitor(input: MonitorInput): Promise<Monitor> {
|
||||
return request<Monitor>("/monitors", { method: "POST", body: JSON.stringify(input) });
|
||||
},
|
||||
|
||||
updateMonitor(monitorId: string, input: Partial<MonitorInput>): Promise<void> {
|
||||
return request<void>(`/monitors/${monitorId}`, { method: "PUT", body: JSON.stringify(input) });
|
||||
},
|
||||
|
||||
deleteMonitor(monitorId: string): Promise<void> {
|
||||
return request<void>(`/monitors/${monitorId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
getMonitorIncidents(monitorId: string): Promise<Incident[]> {
|
||||
return request<Incident[]>(`/monitors/${monitorId}/incidents`);
|
||||
},
|
||||
|
||||
getMonitorUptime(monitorId: string): Promise<Rollup[]> {
|
||||
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
|
||||
},
|
||||
|
||||
// Notification channels
|
||||
listChannels(): Promise<NotificationChannel[]> {
|
||||
return request<NotificationChannel[]>("/channels");
|
||||
},
|
||||
|
||||
createChannel(input: ChannelInput): Promise<NotificationChannel> {
|
||||
return request<NotificationChannel>("/channels", { method: "POST", body: JSON.stringify(input) });
|
||||
},
|
||||
|
||||
updateChannel(channelId: string, input: Partial<ChannelInput>): Promise<void> {
|
||||
return request<void>(`/channels/${channelId}`, { method: "PUT", body: JSON.stringify(input) });
|
||||
},
|
||||
|
||||
deleteChannel(channelId: string): Promise<void> {
|
||||
return request<void>(`/channels/${channelId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
testChannel(channelId: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>(`/channels/${channelId}/test`, { method: "POST" });
|
||||
},
|
||||
|
||||
getLatestAgentVersion(): Promise<{ version: string }> {
|
||||
return request<{ version: string }>("/agent/latest-version");
|
||||
},
|
||||
@@ -282,7 +437,11 @@ export const api = {
|
||||
return request<Settings>("/settings");
|
||||
},
|
||||
|
||||
saveSettings(settings: { alerts: AlertSettings; email: EmailSettings }): Promise<{ saved: boolean }> {
|
||||
saveSettings(settings: {
|
||||
alerts: AlertSettings;
|
||||
email: EmailSettings;
|
||||
workflow_log_retention_days?: number | null;
|
||||
}): Promise<{ saved: boolean }> {
|
||||
return request<{ saved: boolean }>("/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
@@ -407,6 +566,34 @@ export const api = {
|
||||
return request<void>(`/steps/${stepId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
exportStepUrl(stepId: string): string {
|
||||
return `/api/steps/${stepId}/export`;
|
||||
},
|
||||
|
||||
importStep(doc: unknown): Promise<WorkflowStep> {
|
||||
return request<WorkflowStep>("/steps/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
},
|
||||
|
||||
parseStep(doc: unknown): Promise<WorkflowStep> {
|
||||
return request<WorkflowStep>("/steps/parse", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
},
|
||||
|
||||
seedDefaults(): Promise<{ created: number; updated: number }> {
|
||||
return request<{ created: number; updated: number }>("/steps/seed-defaults", {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
stepUsage(): Promise<Record<string, number>> {
|
||||
return request<Record<string, number>>("/steps/usage");
|
||||
},
|
||||
|
||||
// Workflows
|
||||
listWorkflows(): Promise<Workflow[]> {
|
||||
return request<Workflow[]>("/workflows");
|
||||
@@ -452,4 +639,16 @@ export const api = {
|
||||
cancelRun(runId: string): Promise<void> {
|
||||
return request<void>(`/runs/${runId}/cancel`, { method: "POST" });
|
||||
},
|
||||
|
||||
async getServerRunLog(runId: string, serverId: string): Promise<string> {
|
||||
const res = await fetch(`/api/runs/${runId}/servers/${serverId}/logs`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("no logs");
|
||||
return res.text();
|
||||
},
|
||||
|
||||
serverRunLogStreamUrl(runId: string, serverId: string): string {
|
||||
return `/api/runs/${runId}/servers/${serverId}/logs/stream`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,6 +21,10 @@ const config: Config = {
|
||||
warning: "#f59e0b",
|
||||
danger: "#ef4444",
|
||||
"danger-hover": "#dc2626",
|
||||
bash: "#3fb950",
|
||||
pwsh: "#5b9bff",
|
||||
signal: "#f5a524",
|
||||
"signal-ink": "#241800",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user