feat: Audit and settings
Server Deploy / deploy (push) Successful in 1m26s

This commit is contained in:
domrichardson
2026-06-25 11:30:26 +01:00
parent e37a09ef0d
commit c3c16083f7
12 changed files with 856 additions and 4 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ func main() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := services.MarkOfflineServers(5 * time.Minute); err != nil {
if err := services.MarkOfflineServers(); err != nil {
log.Printf("mark offline error: %v", err)
}
}
+73
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
@@ -11,6 +12,13 @@ import (
"github.com/mrhid6/vantage/server/internal/services"
)
func actorFromCtx(c *gin.Context) string {
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
return sess.Email
}
return "admin"
}
func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript)
r.GET("/update", handleUpdateScript)
@@ -37,6 +45,11 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
apiGroup.GET("/audit", listAuditEvents)
apiGroup.GET("/settings", getSettings)
apiGroup.PUT("/settings", saveSettings)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
apiGroup.GET("/keys/:id", getKey)
@@ -75,6 +88,7 @@ func newServer(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
@@ -120,10 +134,16 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(id)
if err := services.DeleteServer(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
hostname := id
if s != nil {
hostname = s.Hostname
}
services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -160,6 +180,7 @@ func generateKey(c *gin.Context) {
return
}
services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
@@ -192,6 +213,7 @@ func createKey(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
c.JSON(http.StatusCreated, key)
}
@@ -227,10 +249,16 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(id)
if err := services.DeleteKey(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
label := id
if k != nil {
label = k.Label
}
services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -249,6 +277,7 @@ func assignKey(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -260,6 +289,7 @@ func revokeAssignment(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
@@ -285,6 +315,7 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
@@ -303,6 +334,7 @@ func applyUpdates(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
}
@@ -362,6 +394,47 @@ echo "vantage-agent updated to ${VERSION} and restarted."
c.String(http.StatusOK, script)
}
func listAuditEvents(c *gin.Context) {
limit := int64(100)
if l := c.Query("limit"); l != "" {
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
limit = n
}
}
events, err := services.ListAuditEvents(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, events)
}
func getSettings(c *gin.Context) {
s, err := services.GetSettings()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func saveSettings(c *gin.Context) {
var body struct {
Alerts models.AlertSettings `json:"alerts"`
Email models.EmailSettings `json:"email"`
}
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AuditEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
EventType string `bson:"event_type" json:"event_type"`
Actor string `bson:"actor" json:"actor"`
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
Details string `bson:"details" json:"details"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+26
View File
@@ -0,0 +1,26 @@
package models
import "go.mongodb.org/mongo-driver/v2/bson"
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
}
type EmailSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
FromAddr string `bson:"from_addr" json:"from_addr"`
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
}
+50
View File
@@ -0,0 +1,50 @@
package services
import (
"context"
"log"
"time"
"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"
)
func LogEvent(eventType, actor, serverID, keyID, details string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
event := models.AuditEvent{
EventType: eventType,
Actor: actor,
ServerID: serverID,
KeyID: keyID,
Details: details,
CreatedAt: time.Now(),
}
if _, err := db.Col("audit_logs").InsertOne(ctx, event); err != nil {
log.Printf("audit log error: %v", err)
}
}
func ListAuditEvents(limit int64) ([]models.AuditEvent, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opts := options.Find().
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetLimit(limit)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var events []models.AuditEvent
if err := cursor.All(ctx, &events); err != nil {
return nil, err
}
return events, nil
}
+40 -3
View File
@@ -197,12 +197,49 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
return err
}
func MarkOfflineServers(threshold time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
func MarkOfflineServers() error {
settings, _ := GetSettings()
thresholdMinutes := 5
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
threshold := time.Duration(thresholdMinutes) * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cutoff := time.Now().Add(-threshold)
_, err := db.Col("servers").UpdateMany(ctx,
// Find servers about to transition to offline so we can alert on them.
cursor, err := db.Col("servers").Find(ctx, bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
})
if err != nil {
return err
}
defer cursor.Close(ctx)
var goingOffline []models.Server
if err := cursor.All(ctx, &goingOffline); err != nil {
return err
}
if len(goingOffline) == 0 {
return nil
}
for _, s := range goingOffline {
LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
if settings != nil && settings.Email.Enabled {
go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress)
}
}
_, err = db.Col("servers").UpdateMany(ctx,
bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
+166
View File
@@ -0,0 +1,166 @@
package services
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"strings"
"time"
"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"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var defaultSettings = models.Settings{
Alerts: models.AlertSettings{
Enabled: false,
WebhookURL: "",
OfflineThresholdMinutes: 5,
},
Email: models.EmailSettings{
SMTPPort: 587,
},
}
func GetSettings() (*models.Settings, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{}).Decode(&s)
if err == mongo.ErrNoDocuments {
cp := defaultSettings
return &cp, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
if email.SMTPPort <= 0 {
email.SMTPPort = 587
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{"alerts": alerts, "email": email}},
options.UpdateOne().SetUpsert(true),
)
return err
}
func SendOfflineWebhook(webhookURL, hostname, serverID, ipAddress string) {
payload := map[string]any{
"event": "server.offline",
"hostname": hostname,
"server_id": serverID,
"ip_address": ipAddress,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"message": fmt.Sprintf("Server %s (%s) has gone offline", hostname, ipAddress),
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("webhook marshal error: %v", err)
return
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("webhook delivery error for %s: %v", hostname, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("webhook returned %d for %s", resp.StatusCode, hostname)
}
}
func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress string) {
if !cfg.Enabled || cfg.SMTPHost == "" || len(cfg.ToAddrs) == 0 {
return
}
subject := fmt.Sprintf("Vantage Alert: %s is offline", hostname)
bodyText := fmt.Sprintf(
"Server %s (%s) has gone offline.\r\n\r\nServer ID: %s\r\nTimestamp: %s\r\n",
hostname, ipAddress, serverID, time.Now().UTC().Format(time.RFC3339),
)
msg := []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.FromAddr,
strings.Join(cfg.ToAddrs, ", "),
subject,
bodyText,
))
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.SMTPHost)
}
var sendErr error
if cfg.UseTLS {
sendErr = sendMailTLS(addr, cfg.SMTPHost, auth, cfg.FromAddr, cfg.ToAddrs, msg)
} else {
sendErr = smtp.SendMail(addr, auth, cfg.FromAddr, cfg.ToAddrs, msg)
}
if sendErr != nil {
log.Printf("email alert error for %s: %v", hostname, sendErr)
}
}
// sendMailTLS dials with implicit TLS (port 465) instead of STARTTLS.
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const EVENT_LABELS: Record<string, string> = {
"server.created": "Server Created",
"server.deleted": "Server Deleted",
"server.offline": "Server Offline",
"key.uploaded": "Key Uploaded",
"key.deleted": "Key Deleted",
"key.assigned": "Key Assigned",
"key.revoked": "Key Revoked",
"key.generation_dispatched": "Key Generation",
"agent.update_dispatched": "Agent Updated",
"updates.applied": "Updates Applied",
"settings.updated": "Settings Updated",
};
const EVENT_COLOURS: Record<string, string> = {
"server.offline": "text-danger",
"server.deleted": "text-danger",
"key.deleted": "text-danger",
"key.revoked": "text-warning",
"server.created": "text-success",
"key.uploaded": "text-success",
"key.assigned": "text-success",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
function EventTypeBadge({ type }: { type: string }) {
const label = EVENT_LABELS[type] ?? type;
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
return (
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
);
}
export default function AuditPage() {
const { data: events, isLoading, error } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
All administrative actions and server status changes
</p>
</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 audit log.</div>
) : events && events.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td>
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td>
<EventTypeBadge type={e.event_type} />
</Td>
<Td>
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td>
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
</div>
)}
</Card>
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
"use client";
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";
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 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";
export default function SettingsPage() {
const queryClient = useQueryClient();
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);
// 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);
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,
},
});
}
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="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>
<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>
</form>
</div>
);
}
+19
View File
@@ -27,9 +27,28 @@ function KeyIcon() {
);
}
function AuditIcon() {
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="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
);
}
function SettingsIcon() {
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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
];
export function Sidebar() {
+50
View File
@@ -42,6 +42,38 @@ export interface Assignment {
revoked_at: string | null;
}
export interface AuditEvent {
id: string;
event_type: string;
actor: string;
server_id?: string;
key_id?: string;
details: string;
created_at: string;
}
export interface AlertSettings {
enabled: boolean;
webhook_url: string;
offline_threshold_minutes: number;
}
export interface EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
username: string;
password: string;
from_addr: string;
to_addrs: string[];
use_tls: boolean;
}
export interface Settings {
alerts: AlertSettings;
email: EmailSettings;
}
export interface NewServerResponse {
server_id: string;
pre_reg_token: string;
@@ -141,6 +173,24 @@ export const api = {
});
},
// Audit
listAuditEvents(limit?: number): Promise<AuditEvent[]> {
const qs = limit ? `?limit=${limit}` : "";
return request<AuditEvent[]>(`/audit${qs}`);
},
// Settings
getSettings(): Promise<Settings> {
return request<Settings>("/settings");
},
saveSettings(settings: { alerts: AlertSettings; email: EmailSettings }): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
body: JSON.stringify(settings),
});
},
// Keys
listKeys(): Promise<Key[]> {
return request<Key[]>("/keys");
File diff suppressed because one or more lines are too long