feat(web): system settings pages (status, database, backups, logs, updates, licenses, about) (#942)
This commit is contained in:
@@ -11,6 +11,8 @@ on:
|
||||
- "apps/web/**"
|
||||
- "apps/packages/**"
|
||||
- "apps/pnpm-lock.yaml"
|
||||
- "apps/backend/go.mod"
|
||||
- "apps/backend/go.sum"
|
||||
- ".github/workflows/frontend-tests.yml"
|
||||
- "!**/*.md"
|
||||
- "!**/*.mdx"
|
||||
@@ -25,6 +27,8 @@ on:
|
||||
- "apps/web/**"
|
||||
- "apps/packages/**"
|
||||
- "apps/pnpm-lock.yaml"
|
||||
- "apps/backend/go.mod"
|
||||
- "apps/backend/go.sum"
|
||||
- ".github/workflows/frontend-tests.yml"
|
||||
- "!**/*.md"
|
||||
- "!**/*.mdx"
|
||||
@@ -61,6 +65,35 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: apps/backend/go.mod
|
||||
cache-dependency-path: apps/backend/go.sum
|
||||
|
||||
- name: Install go-licenses
|
||||
run: go install github.com/google/go-licenses@latest
|
||||
|
||||
- name: Regenerate OSS licenses manifest
|
||||
run: pnpm --filter @kandev/web licenses:gen
|
||||
|
||||
- name: Verify licenses manifest is fresh
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `defaults.run.working-directory` is `apps`, so the path is relative.
|
||||
# The diff is reported as a warning, not a hard failure: `go-licenses`
|
||||
# resolves slightly different license metadata across platforms (macOS
|
||||
# dev vs. ubuntu CI) for the same Go modules, and we have not yet
|
||||
# eliminated that drift. The committed file is still the source of
|
||||
# truth for what the page renders.
|
||||
if ! git diff --quiet --exit-code web/generated/licenses.json; then
|
||||
echo "::warning::apps/web/generated/licenses.json differs from a fresh re-run."
|
||||
echo "::warning::Run 'pnpm --filter @kandev/web licenses:gen' and commit if the drift is real."
|
||||
git --no-pager diff --stat web/generated/licenses.json || true
|
||||
fi
|
||||
|
||||
- name: Check formatting
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -40,6 +40,9 @@ Thumbs.db
|
||||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
!apps/backend/internal/system/logs/
|
||||
!apps/web/app/settings/system/logs/
|
||||
!apps/web/e2e/tests/system/logs-page.spec.ts
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
|
||||
@@ -61,6 +61,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/secrets"
|
||||
"github.com/kandev/kandev/internal/slack"
|
||||
spriteshandlers "github.com/kandev/kandev/internal/sprites"
|
||||
systemsvc "github.com/kandev/kandev/internal/system"
|
||||
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
||||
@@ -425,6 +426,7 @@ type routeParams struct {
|
||||
hostUtilityMgr *hostutility.Manager
|
||||
eventBus bus.EventBus
|
||||
services *Services
|
||||
systemSvc *systemsvc.Service
|
||||
agentSettingsController *agentsettingscontroller.Controller
|
||||
agentSettingsRepo settingsstore.Repository
|
||||
agentList taskhandlers.AgentLister
|
||||
@@ -747,6 +749,7 @@ func registerSecondaryRoutes(
|
||||
p.log.Debug("Registered Docker management handlers (HTTP)")
|
||||
|
||||
registerHealthRoutes(p)
|
||||
registerSystemRoutes(p)
|
||||
|
||||
if p.repoCloner != nil {
|
||||
ikHandler := improvekandev.NewHandler(p.taskSvc, p.repoCloner, p.version, p.log)
|
||||
@@ -806,6 +809,18 @@ func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger
|
||||
}
|
||||
}
|
||||
|
||||
// registerSystemRoutes mounts the System pages backend onto /api/v1/system/*.
|
||||
// The system service is constructed upstream (startGatewayAndServe) so the
|
||||
// updates-poller goroutine can be started with the main run context; here we
|
||||
// only register HTTP handlers. The systemSvc field is nil during partial
|
||||
// builds (tests, CLI subcommands) — registration is then a no-op.
|
||||
func registerSystemRoutes(p routeParams) {
|
||||
if p.systemSvc == nil {
|
||||
return
|
||||
}
|
||||
p.systemSvc.RegisterRoutes(p.router, p.log)
|
||||
}
|
||||
|
||||
// registerHealthRoutes sets up the system health endpoint with all health checkers.
|
||||
func registerHealthRoutes(p routeParams) {
|
||||
var githubProvider health.GitHubStatusProvider
|
||||
|
||||
@@ -104,6 +104,9 @@ import (
|
||||
// Secrets
|
||||
"github.com/kandev/kandev/internal/secrets"
|
||||
|
||||
// System pages (status / database / backups / logs / updates / about)
|
||||
systemsvc "github.com/kandev/kandev/internal/system"
|
||||
|
||||
// Database
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
|
||||
@@ -478,7 +481,7 @@ func startAgentInfrastructure(
|
||||
log.Info("Automation scheduler and evaluator started")
|
||||
}
|
||||
|
||||
return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services,
|
||||
return startGatewayAndServe(ctx, cfg, log, eventBus, dbPool, repos, services,
|
||||
agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, addCleanup, runCleanups)
|
||||
}
|
||||
|
||||
@@ -489,6 +492,7 @@ func startGatewayAndServe(
|
||||
cfg *config.Config,
|
||||
log *logger.Logger,
|
||||
eventBus bus.EventBus,
|
||||
dbPool *db.Pool,
|
||||
repos *Repositories,
|
||||
services *Services,
|
||||
agentSettingsController *agentsettingscontroller.Controller,
|
||||
@@ -598,11 +602,27 @@ func startGatewayAndServe(
|
||||
|
||||
services.Task.StartAutoArchiveLoop(ctx)
|
||||
|
||||
// ============================================
|
||||
// SYSTEM PAGES
|
||||
// ============================================
|
||||
// Composed before HTTP routes so the registration pass below can mount
|
||||
// the /api/v1/system/* group; started before the listener so the
|
||||
// updates poller is alive as soon as we accept connections.
|
||||
systemSvc := systemsvc.Provide(cfg, log, dbPool, eventBus, systemsvc.BuildInfo{
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
BuildTime: BuildTime,
|
||||
}, systemsvc.Wiring{
|
||||
OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() },
|
||||
})
|
||||
systemSvc.StartBackground(ctx)
|
||||
gateways.RegisterSystemNotifications(ctx, eventBus, gateway.Hub, log)
|
||||
|
||||
// ============================================
|
||||
// HTTP SERVER
|
||||
// ============================================
|
||||
server := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController,
|
||||
lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup, repoCloner)
|
||||
lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup, repoCloner, systemSvc)
|
||||
|
||||
port := cfg.Server.Port
|
||||
if port == 0 {
|
||||
@@ -1446,6 +1466,7 @@ func buildHTTPServer(
|
||||
hostUtilityMgr *hostutility.Manager,
|
||||
addCleanup func(func() error),
|
||||
repoCloner *repoclone.Cloner,
|
||||
systemSvc *systemsvc.Service,
|
||||
) *http.Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.New()
|
||||
@@ -1471,6 +1492,7 @@ func buildHTTPServer(
|
||||
hostUtilityMgr: hostUtilityMgr,
|
||||
eventBus: eventBus,
|
||||
services: services,
|
||||
systemSvc: systemSvc,
|
||||
agentSettingsController: agentSettingsController,
|
||||
agentSettingsRepo: repos.AgentSettings,
|
||||
agentList: agentRegistry,
|
||||
|
||||
@@ -4,6 +4,7 @@ package logger
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -268,3 +269,23 @@ func (l *Logger) Sugar() *zap.SugaredLogger {
|
||||
func (l *Logger) BufferSnapshot() []buffer.Entry {
|
||||
return buffer.Default().Snapshot()
|
||||
}
|
||||
|
||||
// LogDirectory returns the directory containing the rotating log file managed
|
||||
// by lumberjack, or empty string when the logger is writing to stdout/stderr
|
||||
// (no file rotation configured). Powers the System -> Logs page enumeration.
|
||||
func (l *Logger) LogDirectory() string {
|
||||
if l == nil || l.rotator == nil || l.rotator.Filename == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(l.rotator.Filename)
|
||||
}
|
||||
|
||||
// LogFilename returns the base filename (no directory) of the active
|
||||
// lumberjack log, e.g. "kandev.log". Rotated files use the same base with a
|
||||
// timestamp suffix. Empty when no file logging is configured.
|
||||
func (l *Logger) LogFilename() string {
|
||||
if l == nil || l.rotator == nil || l.rotator.Filename == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(l.rotator.Filename)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,42 @@ func TestWithFields_PropagatesRotator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogDirectory_FileOutputReturnsDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "kandev.log")
|
||||
log, err := NewLogger(LoggingConfig{Level: "info", Format: "json", OutputPath: logPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogger: %v", err)
|
||||
}
|
||||
defer func() { _ = log.Close() }()
|
||||
|
||||
if got := log.LogDirectory(); got != dir {
|
||||
t.Errorf("LogDirectory() = %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogDirectory_StdoutReturnsEmpty(t *testing.T) {
|
||||
for _, path := range []string{"", "stdout", "stderr"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
log, err := NewLogger(LoggingConfig{Level: "info", Format: "json", OutputPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogger: %v", err)
|
||||
}
|
||||
defer func() { _ = log.Close() }()
|
||||
if got := log.LogDirectory(); got != "" {
|
||||
t.Errorf("LogDirectory() = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogDirectory_NilLoggerReturnsEmpty(t *testing.T) {
|
||||
var l *Logger
|
||||
if got := l.LogDirectory(); got != "" {
|
||||
t.Errorf("nil LogDirectory() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerClose_IsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
log, err := NewLogger(LoggingConfig{
|
||||
|
||||
@@ -107,6 +107,13 @@ const (
|
||||
UserSettingsUpdated = "user.settings.updated"
|
||||
)
|
||||
|
||||
// Event types for system maintenance jobs (VACUUM, factory reset, snapshot
|
||||
// create/restore, disk walk). Published by internal/system/jobs.Tracker on
|
||||
// every state transition and broadcast to all WebSocket clients.
|
||||
const (
|
||||
SystemJobUpdate = "system.job.update"
|
||||
)
|
||||
|
||||
// Event types for environments
|
||||
const (
|
||||
EnvironmentCreated = "environment.created"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SystemEventBroadcaster forwards system-maintenance job events
|
||||
// (vacuum, factory reset, snapshot create/restore, disk walk) to every
|
||||
// connected WebSocket client. These events are install-wide and not scoped
|
||||
// to a user/session/task, so they use Hub.Broadcast.
|
||||
type SystemEventBroadcaster struct {
|
||||
hub *Hub
|
||||
subscriptions []bus.Subscription
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// RegisterSystemNotifications wires the system event broadcaster onto the
|
||||
// shared event bus + Hub. Unsubscribes when ctx is cancelled.
|
||||
func RegisterSystemNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *SystemEventBroadcaster {
|
||||
b := &SystemEventBroadcaster{
|
||||
hub: hub,
|
||||
logger: log.WithFields(zap.String("component", "ws-system-broadcaster")),
|
||||
}
|
||||
if eventBus == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
b.subscribe(eventBus, events.SystemJobUpdate, ws.ActionSystemJobUpdate)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
b.Close()
|
||||
}()
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// Close unsubscribes from all event-bus subscriptions.
|
||||
func (b *SystemEventBroadcaster) Close() {
|
||||
for _, sub := range b.subscriptions {
|
||||
if sub != nil && sub.IsValid() {
|
||||
_ = sub.Unsubscribe()
|
||||
}
|
||||
}
|
||||
b.subscriptions = nil
|
||||
}
|
||||
|
||||
func (b *SystemEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) {
|
||||
sub, err := eventBus.Subscribe(subject, func(_ context.Context, event *bus.Event) error {
|
||||
msg, err := ws.NewNotification(action, event.Data)
|
||||
if err != nil {
|
||||
b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
b.hub.Broadcast(msg)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Error("failed to subscribe to events", zap.String("subject", subject), zap.Error(err))
|
||||
return
|
||||
}
|
||||
b.subscriptions = append(b.subscriptions, sub)
|
||||
}
|
||||
@@ -46,6 +46,12 @@ func (c *GitHubChecker) WithRateLimitProvider(p GitHubRateLimitProvider) *GitHub
|
||||
return c
|
||||
}
|
||||
|
||||
// Name returns the user-facing label for this check.
|
||||
func (c *GitHubChecker) Name() string { return "GitHub integration" }
|
||||
|
||||
// Category returns the issue category this checker emits issues under.
|
||||
func (c *GitHubChecker) Category() string { return "github" }
|
||||
|
||||
func (c *GitHubChecker) Check(_ context.Context) []Issue {
|
||||
if c.provider == nil {
|
||||
return []Issue{{
|
||||
@@ -126,6 +132,12 @@ func NewAgentChecker(provider AgentDiscoveryProvider) *AgentChecker {
|
||||
|
||||
const agentCheckTimeout = 10 * time.Second
|
||||
|
||||
// Name returns the user-facing label for this check.
|
||||
func (c *AgentChecker) Name() string { return "AI agent availability" }
|
||||
|
||||
// Category returns the issue category this checker emits issues under.
|
||||
func (c *AgentChecker) Category() string { return "agents" }
|
||||
|
||||
func (c *AgentChecker) Check(ctx context.Context) []Issue {
|
||||
if c.provider == nil {
|
||||
return nil
|
||||
|
||||
@@ -18,10 +18,19 @@ func NewService(log *logger.Logger, checkers ...Checker) *Service {
|
||||
}
|
||||
|
||||
// RunChecks executes all checkers and returns the aggregated result.
|
||||
// The Checks slice mirrors the registered checkers; each entry is marked
|
||||
// passing when its checker produced no warning/error issues.
|
||||
func (s *Service) RunChecks(ctx context.Context) *Response {
|
||||
var issues []Issue
|
||||
issues := []Issue{}
|
||||
checks := make([]CheckSummary, 0, len(s.checkers))
|
||||
for _, c := range s.checkers {
|
||||
issues = append(issues, c.Check(ctx)...)
|
||||
out := c.Check(ctx)
|
||||
issues = append(issues, out...)
|
||||
checks = append(checks, CheckSummary{
|
||||
Name: c.Name(),
|
||||
Category: c.Category(),
|
||||
Passing: !hasBlockingIssue(out),
|
||||
})
|
||||
}
|
||||
healthy := true
|
||||
for _, issue := range issues {
|
||||
@@ -30,8 +39,16 @@ func (s *Service) RunChecks(ctx context.Context) *Response {
|
||||
break
|
||||
}
|
||||
}
|
||||
if issues == nil {
|
||||
issues = []Issue{}
|
||||
}
|
||||
return &Response{Healthy: healthy, Issues: issues}
|
||||
return &Response{Healthy: healthy, Issues: issues, Checks: checks}
|
||||
}
|
||||
|
||||
// hasBlockingIssue reports whether the issue list contains any warning or
|
||||
// error severity entry — info issues do not flip a check to "not passing".
|
||||
func hasBlockingIssue(issues []Issue) bool {
|
||||
for _, i := range issues {
|
||||
if i.Severity == SeverityError || i.Severity == SeverityWarning {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -98,3 +98,6 @@ type staticChecker struct {
|
||||
func (c *staticChecker) Check(_ context.Context) []Issue {
|
||||
return c.issues
|
||||
}
|
||||
|
||||
func (c *staticChecker) Name() string { return "Static check" }
|
||||
func (c *staticChecker) Category() string { return "system" }
|
||||
|
||||
@@ -26,9 +26,25 @@ type Issue struct {
|
||||
type Response struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Issues []Issue `json:"issues"`
|
||||
// Checks lists every system check that ran, with a passing flag derived
|
||||
// from whether the check produced any non-info issues. Surfaced so the
|
||||
// UI can show "what's being monitored" alongside actual issues.
|
||||
Checks []CheckSummary `json:"checks"`
|
||||
}
|
||||
|
||||
// Checker is the interface each health check implements.
|
||||
// CheckSummary describes one health checker for the UI: the user-facing name,
|
||||
// the category (matches Issue.Category — useful for grouping), and a passing
|
||||
// flag set to true when the check produced no warning/error issues.
|
||||
type CheckSummary struct {
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Passing bool `json:"passing"`
|
||||
}
|
||||
|
||||
// Checker is the interface each health check implements. Name and Category
|
||||
// are used by the service to produce the CheckSummary list in Response.
|
||||
type Checker interface {
|
||||
Check(ctx context.Context) []Issue
|
||||
Name() string
|
||||
Category() string
|
||||
}
|
||||
|
||||
@@ -4,10 +4,18 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
metaKeyLatestVersion = "latest_version"
|
||||
metaKeyLatestVersionURL = "latest_version_url"
|
||||
metaKeyLatestVersionCheckedAt = "latest_version_checked_at"
|
||||
)
|
||||
|
||||
const metaTableDDL = `
|
||||
CREATE TABLE IF NOT EXISTS kandev_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -58,6 +66,50 @@ func WriteVersion(db *sqlx.DB, version string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteLatestVersion persists the highest semver tag from the GitHub Releases
|
||||
// poll, its release URL, and the timestamp of the successful poll. The three
|
||||
// values are written under separate keys (latest_version,
|
||||
// latest_version_url, latest_version_checked_at) on the existing key/value
|
||||
// kandev_meta table.
|
||||
func WriteLatestVersion(db *sqlx.DB, version, url string, checkedAt time.Time) error {
|
||||
if err := writeKey(db, metaKeyLatestVersion, version); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeKey(db, metaKeyLatestVersionURL, url); err != nil {
|
||||
return err
|
||||
}
|
||||
ts := strconv.FormatInt(checkedAt.UTC().Unix(), 10)
|
||||
return writeKey(db, metaKeyLatestVersionCheckedAt, ts)
|
||||
}
|
||||
|
||||
// ReadLatestVersion returns the last-known latest release tag, its URL, and
|
||||
// the timestamp of the last successful GitHub poll. Returns zero values when
|
||||
// the keys are absent (e.g. on a fresh install before the first poll
|
||||
// completes) and tolerates a subset of the three keys being missing.
|
||||
func ReadLatestVersion(db *sqlx.DB) (string, string, time.Time, error) {
|
||||
version, err := readKey(db, metaKeyLatestVersion)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
url, err := readKey(db, metaKeyLatestVersionURL)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
tsRaw, err := readKey(db, metaKeyLatestVersionCheckedAt)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
var checkedAt time.Time
|
||||
if tsRaw != "" {
|
||||
secs, perr := strconv.ParseInt(tsRaw, 10, 64)
|
||||
if perr != nil {
|
||||
return "", "", time.Time{}, fmt.Errorf("parse latest_version_checked_at %q: %w", tsRaw, perr)
|
||||
}
|
||||
checkedAt = time.Unix(secs, 0).UTC()
|
||||
}
|
||||
return version, url, checkedAt, nil
|
||||
}
|
||||
|
||||
// hasUserTables returns true when the DB contains at least one table that is
|
||||
// not part of the SQLite internal schema and not kandev_meta itself. This
|
||||
// distinguishes a genuinely fresh DB from a pre-meta DB being upgraded.
|
||||
|
||||
@@ -2,6 +2,7 @@ package persistence
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
@@ -94,6 +95,81 @@ func TestHasUserTables_BeforeAndAfter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAndReadLatestVersion_RoundtripAndUpsert verifies that we can
|
||||
// store the GitHub-polled latest release and read it back, including overwrites.
|
||||
func TestWriteAndReadLatestVersion_RoundtripAndUpsert(t *testing.T) {
|
||||
db := memSQLiteDB(t)
|
||||
if err := ensureMetaTable(db); err != nil {
|
||||
t.Fatalf("ensureMetaTable: %v", err)
|
||||
}
|
||||
|
||||
// On a fresh DB, all three reads should return zero values.
|
||||
v, url, when, err := ReadLatestVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLatestVersion fresh: %v", err)
|
||||
}
|
||||
if v != "" || url != "" || !when.IsZero() {
|
||||
t.Errorf("expected zero values on fresh DB, got v=%q url=%q when=%v", v, url, when)
|
||||
}
|
||||
|
||||
t1 := time.Unix(1_700_000_000, 0).UTC()
|
||||
if err := WriteLatestVersion(db, "v1.2.3", "https://example/r/v1.2.3", t1); err != nil {
|
||||
t.Fatalf("WriteLatestVersion: %v", err)
|
||||
}
|
||||
v, url, when, err = ReadLatestVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLatestVersion after write: %v", err)
|
||||
}
|
||||
if v != "v1.2.3" {
|
||||
t.Errorf("version = %q, want %q", v, "v1.2.3")
|
||||
}
|
||||
if url != "https://example/r/v1.2.3" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
if !when.Equal(t1) {
|
||||
t.Errorf("checkedAt = %v, want %v", when, t1)
|
||||
}
|
||||
|
||||
// Upsert.
|
||||
t2 := time.Unix(1_700_000_100, 0).UTC()
|
||||
if err := WriteLatestVersion(db, "v1.2.4", "https://example/r/v1.2.4", t2); err != nil {
|
||||
t.Fatalf("WriteLatestVersion overwrite: %v", err)
|
||||
}
|
||||
v, url, when, err = ReadLatestVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLatestVersion after overwrite: %v", err)
|
||||
}
|
||||
if v != "v1.2.4" || url != "https://example/r/v1.2.4" || !when.Equal(t2) {
|
||||
t.Errorf("after overwrite got v=%q url=%q when=%v", v, url, when)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadLatestVersion_PartialPersistedState verifies the read tolerates
|
||||
// only a subset of the three keys being present (which can happen if a
|
||||
// previous write was interrupted).
|
||||
func TestReadLatestVersion_PartialPersistedState(t *testing.T) {
|
||||
db := memSQLiteDB(t)
|
||||
if err := ensureMetaTable(db); err != nil {
|
||||
t.Fatalf("ensureMetaTable: %v", err)
|
||||
}
|
||||
if err := writeKey(db, "latest_version", "v9.9.9"); err != nil {
|
||||
t.Fatalf("writeKey: %v", err)
|
||||
}
|
||||
v, url, when, err := ReadLatestVersion(db)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLatestVersion: %v", err)
|
||||
}
|
||||
if v != "v9.9.9" {
|
||||
t.Errorf("version = %q", v)
|
||||
}
|
||||
if url != "" {
|
||||
t.Errorf("url should be empty, got %q", url)
|
||||
}
|
||||
if !when.IsZero() {
|
||||
t.Errorf("checkedAt should be zero, got %v", when)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldBackup verifies the backup decision logic.
|
||||
func TestShouldBackup(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -23,6 +23,15 @@ func snapshotPath(backupDir, fromVersion string) string {
|
||||
return filepath.Join(backupDir, name)
|
||||
}
|
||||
|
||||
// SnapshotSQLite copies the live database to path using VACUUM INTO,
|
||||
// which produces a clean, defragmented snapshot including all WAL frames.
|
||||
// Returns the size of the created file in bytes. Exported for use by
|
||||
// internal/system/backups; the lowercase alias preserves call sites in
|
||||
// this package.
|
||||
func SnapshotSQLite(writer *sqlx.DB, path string) (int64, error) {
|
||||
return snapshotSQLite(writer, path)
|
||||
}
|
||||
|
||||
// snapshotSQLite copies the live database to path using VACUUM INTO,
|
||||
// which produces a clean, defragmented snapshot including all WAL frames.
|
||||
// Returns the size of the created file in bytes.
|
||||
@@ -37,6 +46,14 @@ func snapshotSQLite(writer *sqlx.DB, path string) (int64, error) {
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// PruneBackups is the exported alias of pruneBackups, intended for callers
|
||||
// outside this package (e.g. internal/system/backups retention tests) that
|
||||
// need to verify the auto-snapshot retention policy without copy-pasting
|
||||
// the implementation.
|
||||
func PruneBackups(dir string, keep int) error {
|
||||
return pruneBackups(dir, keep)
|
||||
}
|
||||
|
||||
// pruneBackups retains only the keep newest backup files (sorted by mtime)
|
||||
// inside dir, deleting everything older. Files are matched by the
|
||||
// "kandev-*.db" glob pattern. Non-matching files in the directory are not
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes wires the backups endpoints onto g (typically the
|
||||
// /api/v1/system group).
|
||||
func RegisterRoutes(g *gin.RouterGroup, svc *Service) {
|
||||
g.GET("/backups", HandleList(svc))
|
||||
g.POST("/backups", HandleCreate(svc))
|
||||
g.GET("/backups/:name/download", HandleDownload(svc))
|
||||
g.POST("/backups/:name/restore", HandleRestore(svc))
|
||||
g.DELETE("/backups/:name", HandleDelete(svc))
|
||||
}
|
||||
|
||||
// HandleList returns GET /api/v1/system/backups -> { snapshots: [...] }.
|
||||
func HandleList(svc *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snaps, err := svc.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"snapshots": snaps})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreate returns POST /api/v1/system/backups -> 202 { job_id }.
|
||||
func HandleCreate(svc *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := svc.Create(c.Request.Context())
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": id})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDownload returns GET /api/v1/system/backups/:name/download. Streams
|
||||
// the file as an attachment.
|
||||
func HandleDownload(svc *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if _, err := svc.resolveSnapshotPath(name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||||
return
|
||||
}
|
||||
f, size, err := svc.OpenForDownload(name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
c.Header("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(name, `"`, `\"`)+`"`)
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.DataFromReader(http.StatusOK, size, "application/octet-stream", f, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRestore returns POST /api/v1/system/backups/:name/restore. Body
|
||||
// must include {"confirm":"RESTORE"}.
|
||||
func HandleRestore(svc *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if _, err := svc.resolveSnapshotPath(name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
id, err := svc.Restore(c.Request.Context(), name, body.Confirm)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRestoreConfirm) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": id})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDelete returns DELETE /api/v1/system/backups/:name -> 204.
|
||||
func HandleDelete(svc *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if _, err := svc.resolveSnapshotPath(name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
|
||||
return
|
||||
}
|
||||
if err := svc.Delete(name); err != nil {
|
||||
// Differentiate by cause: pre-reset rejections and not-found
|
||||
// map to client-visible statuses; anything else (filesystem
|
||||
// errors etc.) is a 500 so we don't leak raw storage detail.
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "cannot delete pre-reset"):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "snapshot not found"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
func newRouter(svc *Service) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1/system")
|
||||
RegisterRoutes(g, svc)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHandleList_ReturnsSnapshots(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupsDir, "manual-1.db"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/system/backups", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Snapshots) != 1 || resp.Snapshots[0].Kind != "manual" {
|
||||
t.Errorf("unexpected snapshots: %+v", resp.Snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreate_Returns202WithJobID(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/system/backups", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["job_id"] == "" {
|
||||
t.Errorf("expected job_id, body = %s", w.Body.String())
|
||||
}
|
||||
waitForJob(t, svc.jobs, resp["job_id"], jobs.StateSucceeded)
|
||||
}
|
||||
|
||||
func TestHandleRestore_WrongConfirmReturns400(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := newRouter(svc)
|
||||
|
||||
body := bytes.NewBufferString(`{"confirm":"NOPE"}`)
|
||||
req := httptest.NewRequest("POST", "/api/v1/system/backups/manual-1.db/restore", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDelete_RejectsTraversal(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := newRouter(svc)
|
||||
// gin's :name will refuse a slash, but ensure dot-segments are rejected.
|
||||
req := httptest.NewRequest("DELETE", "/api/v1/system/backups/..", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDelete_PreResetSnapshotReturns403(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
name := "kandev-pre-reset-20260101T000000Z.db"
|
||||
if err := os.WriteFile(filepath.Join(backupsDir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/system/backups/"+name, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDelete_MissingSnapshotReturns404(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/system/backups/manual-missing.db", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDelete_ManualSnapshotReturns204(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupsDir, "manual-9.db"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/system/backups/manual-9.db", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", w.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(backupsDir, "manual-9.db")); !os.IsNotExist(err) {
|
||||
t.Errorf("expected file removed, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDownload_StreamsFile(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
if err := os.MkdirAll(filepath.Join(dataDir, "backups"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dataDir, "backups", "manual-7.db"), []byte("payload"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/system/backups/manual-7.db/download", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Header().Get("Content-Disposition"), "manual-7.db") {
|
||||
t.Errorf("missing attachment header: %q", w.Header().Get("Content-Disposition"))
|
||||
}
|
||||
if w.Body.String() != "payload" {
|
||||
t.Errorf("body = %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity-check the round-trip: create via handler, list via handler, file
|
||||
// present on disk under manual- prefix.
|
||||
func TestHandlerRoundTrip_CreateThenList(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/system/backups", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
var created map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
waitForJob(t, svc.jobs, created["job_id"], jobs.StateSucceeded)
|
||||
|
||||
// Confirm file appears on disk.
|
||||
entries, _ := os.ReadDir(filepath.Join(dataDir, "backups"))
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "manual-") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("manual snapshot not created")
|
||||
}
|
||||
|
||||
// And shows up in list.
|
||||
req2 := httptest.NewRequest("GET", "/api/v1/system/backups", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w2, req2)
|
||||
var listResp struct {
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &listResp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(listResp.Snapshots) < 1 {
|
||||
t.Errorf("expected >=1 snapshot, got %+v", listResp.Snapshots)
|
||||
}
|
||||
_ = context.Background()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package backups manages user-facing SQLite snapshots stored under
|
||||
// <data-dir>/backups/. The package distinguishes two kinds of snapshots
|
||||
// purely by filename prefix:
|
||||
//
|
||||
// - "kandev-<...>.db" -- AUTO snapshots written by the persistence layer
|
||||
// before pre-migration upgrades. Subject to the keep-newest-2 retention
|
||||
// in persistence.PruneBackups.
|
||||
//
|
||||
// - "manual-<unix-ts>.db" -- MANUAL snapshots created by the user via the
|
||||
// System -> Backups UI. NEVER auto-pruned. Survive arbitrary calls to
|
||||
// persistence.PruneBackups because that function only matches the
|
||||
// "kandev-" prefix.
|
||||
//
|
||||
// retention.go documents the policy and exposes the auto/manual classifier
|
||||
// used by List(). The actual pruning of auto snapshots lives in
|
||||
// internal/persistence; this package never deletes auto files implicitly.
|
||||
package backups
|
||||
|
||||
import "strings"
|
||||
|
||||
// Kind constants exposed in API responses.
|
||||
const (
|
||||
KindAuto = "auto"
|
||||
KindManual = "manual"
|
||||
)
|
||||
|
||||
// autoPrefix and manualPrefix are the filename markers that distinguish
|
||||
// the two snapshot families. ManualPrefix is also used when creating new
|
||||
// manual snapshots so they avoid persistence.PruneBackups's "kandev-"
|
||||
// glob.
|
||||
const (
|
||||
autoPrefix = "kandev-"
|
||||
manualPrefix = "manual-"
|
||||
dbSuffix = ".db"
|
||||
)
|
||||
|
||||
// preResetPrefix marks recovery snapshots written by the Factory Reset
|
||||
// flow. These must never be deleted via the regular Delete handler;
|
||||
// they are the user's lifeline if a reset goes wrong.
|
||||
const preResetPrefix = "kandev-pre-reset-"
|
||||
|
||||
// classify maps a filename to its snapshot Kind. Returns "" when the
|
||||
// name is not a recognised snapshot (no .db suffix or unknown prefix).
|
||||
func classify(name string) string {
|
||||
if !strings.HasSuffix(name, dbSuffix) {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(name, manualPrefix):
|
||||
return KindManual
|
||||
case strings.HasPrefix(name, autoPrefix):
|
||||
return KindAuto
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isPreResetSnapshot returns true if the file is a Factory Reset recovery
|
||||
// snapshot and therefore not deletable via the regular Delete endpoint.
|
||||
func isPreResetSnapshot(name string) bool {
|
||||
return strings.HasPrefix(name, preResetPrefix)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
// Snapshot is the public representation of a backup file on disk.
|
||||
type Snapshot struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ModTime time.Time `json:"mtime"`
|
||||
Kind string `json:"kind"` // "auto" | "manual"
|
||||
}
|
||||
|
||||
// RestoreConfirmToken is the literal string the client must send in the
|
||||
// confirm field for Restore to proceed. Anything else is rejected with a
|
||||
// 400 by the handler.
|
||||
const RestoreConfirmToken = "RESTORE"
|
||||
|
||||
// errRestoreConfirm is exported so handlers can map it to HTTP 400.
|
||||
var errRestoreConfirm = errors.New("restore requires confirm=RESTORE")
|
||||
|
||||
// ErrInvalidName is returned for filenames that contain path separators,
|
||||
// "..", or absolute prefixes.
|
||||
var ErrInvalidName = errors.New("invalid backup name")
|
||||
|
||||
// Service owns access to the <data-dir>/backups directory and exposes the
|
||||
// list/create/restore/delete/download API.
|
||||
//
|
||||
// Restore intentionally does not attempt to re-exec the backend: the staged
|
||||
// DB file is written in place and the user is told (via the frontend dialog)
|
||||
// to quit and relaunch Kandev to load the restored data. The previous
|
||||
// syscall.Exec approach was brittle under desktop launchers and `make dev`
|
||||
// watchers, and left the web UI disconnected from a fresh backend.
|
||||
type Service struct {
|
||||
dataDir string
|
||||
pool *db.Pool
|
||||
jobs *jobs.Tracker
|
||||
log *logger.Logger
|
||||
|
||||
// failWritesForTest, when true, causes Restore's staged-write step to
|
||||
// fail before kandev.db is touched. Only set by tests.
|
||||
failWritesForTest bool
|
||||
}
|
||||
|
||||
// NewService constructs a Service. The backups directory under dataDir is
|
||||
// created lazily by methods that need it.
|
||||
func NewService(dataDir string, pool *db.Pool, tracker *jobs.Tracker, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
dataDir: dataDir,
|
||||
pool: pool,
|
||||
jobs: tracker,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// backupsDir returns the absolute path to the snapshots directory.
|
||||
func (s *Service) backupsDir() string {
|
||||
return filepath.Join(s.dataDir, "backups")
|
||||
}
|
||||
|
||||
// dbPath returns the absolute path to the live SQLite database file.
|
||||
func (s *Service) dbPath() string {
|
||||
return filepath.Join(s.dataDir, "kandev.db")
|
||||
}
|
||||
|
||||
// ensureBackupsDir mkdirs the backups directory.
|
||||
func (s *Service) ensureBackupsDir() error {
|
||||
return os.MkdirAll(s.backupsDir(), 0o755)
|
||||
}
|
||||
|
||||
// List enumerates the snapshots in <data-dir>/backups, classifying each
|
||||
// .db file as auto or manual. Non-.db files and unrecognised prefixes are
|
||||
// skipped silently. Always returns a non-nil slice.
|
||||
func (s *Service) List() ([]Snapshot, error) {
|
||||
out := make([]Snapshot, 0)
|
||||
dir := s.backupsDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read backups dir: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
kind := classify(e.Name())
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Snapshot{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(dir, e.Name()),
|
||||
SizeBytes: info.Size(),
|
||||
ModTime: info.ModTime().UTC(),
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Create starts a job that writes a manual snapshot via VACUUM INTO and
|
||||
// returns the job ID immediately.
|
||||
func (s *Service) Create(ctx context.Context) string {
|
||||
return s.jobs.Start(ctx, "backup-create", func(ctx context.Context) (map[string]interface{}, error) {
|
||||
return s.runCreate(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) runCreate(_ context.Context) (map[string]interface{}, error) {
|
||||
if err := s.ensureBackupsDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Nanosecond precision so double-clicks or concurrent /backups POSTs do
|
||||
// not collide on the same filename and silently overwrite one job's
|
||||
// snapshot with another.
|
||||
name := fmt.Sprintf("%s%d%s", manualPrefix, time.Now().UTC().UnixNano(), dbSuffix)
|
||||
path := filepath.Join(s.backupsDir(), name)
|
||||
size, err := persistence.SnapshotSQLite(s.pool.Writer(), path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"name": name,
|
||||
"path": path,
|
||||
"size_bytes": size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Restore validates the confirm token, then runs the restore as a job.
|
||||
// Returns the job ID on success, or an error if the token is wrong.
|
||||
func (s *Service) Restore(ctx context.Context, name, confirm string) (string, error) {
|
||||
if confirm != RestoreConfirmToken {
|
||||
return "", errRestoreConfirm
|
||||
}
|
||||
abs, err := s.resolveSnapshotPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := s.jobs.Start(ctx, "restore", func(ctx context.Context) (map[string]interface{}, error) {
|
||||
return s.runRestore(ctx, abs)
|
||||
})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *Service) runRestore(_ context.Context, snapshotPath string) (map[string]interface{}, error) {
|
||||
if _, err := os.Stat(snapshotPath); err != nil {
|
||||
return nil, fmt.Errorf("snapshot not found: %w", err)
|
||||
}
|
||||
stagedPath := s.dbPath() + ".new"
|
||||
if err := s.writeStagedRestore(snapshotPath, stagedPath); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(stagedPath, s.dbPath()); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return nil, fmt.Errorf("atomic rename failed: %w", err)
|
||||
}
|
||||
// Intentionally no auto-restart. The frontend dialog reads
|
||||
// restart_required from the job result and prompts the user to quit and
|
||||
// relaunch the app so the new DB file is loaded fresh.
|
||||
return map[string]interface{}{
|
||||
"restored_from": filepath.Base(snapshotPath),
|
||||
"restart_required": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeStagedRestore copies snapshotPath to stagedPath. Honors
|
||||
// failWritesForTest so tests can simulate a mid-restore failure that
|
||||
// leaves the original DB untouched.
|
||||
func (s *Service) writeStagedRestore(snapshotPath, stagedPath string) error {
|
||||
if s.failWritesForTest {
|
||||
return errors.New("simulated write failure")
|
||||
}
|
||||
src, err := os.Open(snapshotPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open snapshot: %w", err)
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
dst, err := os.OpenFile(stagedPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create staged db: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
_ = dst.Close()
|
||||
return fmt.Errorf("copy snapshot: %w", err)
|
||||
}
|
||||
if err := dst.Sync(); err != nil {
|
||||
_ = dst.Close()
|
||||
return fmt.Errorf("sync staged db: %w", err)
|
||||
}
|
||||
return dst.Close()
|
||||
}
|
||||
|
||||
// Delete removes a snapshot file. Refuses to delete pre-reset recovery
|
||||
// snapshots.
|
||||
func (s *Service) Delete(name string) error {
|
||||
abs, err := s.resolveSnapshotPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isPreResetSnapshot(name) {
|
||||
return fmt.Errorf("cannot delete pre-reset recovery snapshot %q", name)
|
||||
}
|
||||
if err := os.Remove(abs); err != nil {
|
||||
return fmt.Errorf("delete snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenForDownload validates the name and returns an open *os.File plus its
|
||||
// size for the handler to stream. The caller owns closing the file.
|
||||
func (s *Service) OpenForDownload(name string) (*os.File, int64, error) {
|
||||
abs, err := s.resolveSnapshotPath(name)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("open snapshot: %w", err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, 0, fmt.Errorf("stat snapshot: %w", err)
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
// resolveSnapshotPath validates that name is a bare filename (no
|
||||
// separators, no "..", no absolute prefix), confirms it matches a
|
||||
// recognised snapshot prefix (so unrelated files dropped into the backups
|
||||
// directory cannot be restored/downloaded/deleted), and that it resolves
|
||||
// inside the backups directory. Returns the absolute path.
|
||||
func (s *Service) resolveSnapshotPath(name string) (string, error) {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
if filepath.IsAbs(name) {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
// Defensive: filepath.Clean strips any tricks before we join.
|
||||
clean := filepath.Clean(name)
|
||||
if clean != name {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
// Allow-list by prefix + suffix. classify() returns "" for anything
|
||||
// that isn't a manual/auto snapshot; pre-reset recovery snapshots use
|
||||
// the auto prefix and are accepted here too (Delete blocks them later).
|
||||
if classify(name) == "" && !isPreResetSnapshot(name) {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
abs := filepath.Join(s.backupsDir(), clean)
|
||||
// Confirm abs is still inside backupsDir.
|
||||
rel, err := filepath.Rel(s.backupsDir(), abs)
|
||||
if err != nil || strings.HasPrefix(rel, "..") || strings.ContainsAny(rel, "/\\") {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stderr"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
func newTestPool(t *testing.T) (*db.Pool, string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
dbPath := filepath.Join(dataDir, "kandev.db")
|
||||
|
||||
writer, err := sqlx.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if _, err := writer.Exec(`CREATE TABLE things (id TEXT PRIMARY KEY, name TEXT)`); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := writer.Exec(`INSERT INTO things VALUES ('1','hello')`); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
pool := db.NewPool(writer, writer)
|
||||
return pool, dataDir
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T) (*Service, string) {
|
||||
t.Helper()
|
||||
pool, dataDir := newTestPool(t)
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(dataDir, pool, tracker, newTestLogger(t))
|
||||
return svc, dataDir
|
||||
}
|
||||
|
||||
// waitForJob polls the tracker for a terminal state. Reused from
|
||||
// jobs_test patterns to avoid sleeping forever in CI.
|
||||
func waitForJob(t *testing.T, tracker *jobs.Tracker, id string, want jobs.State) *jobs.Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
j := tracker.Get(id)
|
||||
if j != nil && j.State == want {
|
||||
return j
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job %s did not reach %s in 3s; last = %+v", id, want, tracker.Get(id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestList_EmptyDirReturnsEmptySlice(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
got, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil empty slice")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected 0 snapshots, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_ClassifiesAutoAndManual(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
seed := []string{
|
||||
"kandev-v0.1.0-20260101T000000Z.db",
|
||||
"manual-1700000000.db",
|
||||
"random.txt",
|
||||
}
|
||||
for _, n := range seed {
|
||||
if err := os.WriteFile(filepath.Join(backupsDir, n), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 snapshots (only .db), got %d: %+v", len(got), got)
|
||||
}
|
||||
|
||||
kinds := map[string]string{}
|
||||
for _, s := range got {
|
||||
kinds[s.Name] = s.Kind
|
||||
}
|
||||
if kinds["kandev-v0.1.0-20260101T000000Z.db"] != "auto" {
|
||||
t.Errorf("kandev-* should be auto, got %q", kinds["kandev-v0.1.0-20260101T000000Z.db"])
|
||||
}
|
||||
if kinds["manual-1700000000.db"] != "manual" {
|
||||
t.Errorf("manual-* should be manual, got %q", kinds["manual-1700000000.db"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ProducesManualVacuumIntoFile(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
id := svc.Create(context.Background())
|
||||
if id == "" {
|
||||
t.Fatal("expected non-empty job id")
|
||||
}
|
||||
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
||||
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
entries, err := os.ReadDir(backupsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
var manualFile string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "manual-") && strings.HasSuffix(e.Name(), ".db") {
|
||||
manualFile = filepath.Join(backupsDir, e.Name())
|
||||
}
|
||||
}
|
||||
if manualFile == "" {
|
||||
t.Fatalf("manual-*.db not produced; dir contents: %+v", entries)
|
||||
}
|
||||
|
||||
// Verify the snapshot is a valid SQLite file and contains seeded row.
|
||||
snap, err := sqlx.Open("sqlite3", manualFile)
|
||||
if err != nil {
|
||||
t.Fatalf("open snapshot: %v", err)
|
||||
}
|
||||
defer func() { _ = snap.Close() }()
|
||||
var name string
|
||||
if err := snap.QueryRow(`SELECT name FROM things WHERE id='1'`).Scan(&name); err != nil {
|
||||
t.Fatalf("read snapshot: %v", err)
|
||||
}
|
||||
if name != "hello" {
|
||||
t.Errorf("snapshot row = %q, want %q", name, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
// Core retention property: persistence.PruneBackups(dir, 0) must remove all
|
||||
// auto snapshots but must NOT touch manual snapshots. This is the contract
|
||||
// that lets manual snapshots survive the existing pre-migration pruning.
|
||||
func TestCreate_ManualSurvivesPruneBackups(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
|
||||
// Drop a synthetic auto-snapshot alongside the manual one.
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
autoFile := filepath.Join(backupsDir, "kandev-v0.1.0-20260101T000000Z.db")
|
||||
if err := os.WriteFile(autoFile, []byte("auto"), 0o644); err != nil {
|
||||
t.Fatalf("seed auto: %v", err)
|
||||
}
|
||||
|
||||
id := svc.Create(context.Background())
|
||||
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
||||
|
||||
if err := persistence.PruneBackups(backupsDir, 0); err != nil {
|
||||
t.Fatalf("PruneBackups: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(autoFile); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("auto snapshot should have been pruned, err=%v", err)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(backupsDir)
|
||||
var manualSurvived bool
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "manual-") && strings.HasSuffix(e.Name(), ".db") {
|
||||
manualSurvived = true
|
||||
}
|
||||
}
|
||||
if !manualSurvived {
|
||||
t.Errorf("manual snapshot was incorrectly pruned; remaining: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestore_WrongConfirmReturnsError(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
_, err := svc.Restore(context.Background(), "manual-1.db", "WRONG")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong confirm token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestore_SuccessStagesFileAndFlagsRestartRequired(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// Make a valid SQLite snapshot to restore.
|
||||
id := svc.Create(context.Background())
|
||||
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
||||
|
||||
entries, _ := os.ReadDir(backupsDir)
|
||||
var snapName string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "manual-") {
|
||||
snapName = e.Name()
|
||||
}
|
||||
}
|
||||
if snapName == "" {
|
||||
t.Fatal("no manual snapshot to restore")
|
||||
}
|
||||
|
||||
jobID, err := svc.Restore(context.Background(), snapName, "RESTORE")
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
waitForJob(t, svc.jobs, jobID, jobs.StateSucceeded)
|
||||
|
||||
// The DB must be in place at the canonical path post-restore.
|
||||
if _, err := os.Stat(filepath.Join(dataDir, "kandev.db")); err != nil {
|
||||
t.Errorf("expected kandev.db at canonical path: %v", err)
|
||||
}
|
||||
|
||||
// The job result must flag restart_required=true so the frontend dialog
|
||||
// knows to prompt the user to quit and relaunch.
|
||||
job := svc.jobs.Get(jobID)
|
||||
if job == nil {
|
||||
t.Fatalf("job not found")
|
||||
}
|
||||
if got, _ := job.Result["restart_required"].(bool); !got {
|
||||
t.Errorf("restart_required missing or false in job result: %+v", job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestore_WriteFailurePreservesOriginal(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
id := svc.Create(context.Background())
|
||||
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
||||
|
||||
entries, _ := os.ReadDir(backupsDir)
|
||||
var snapName string
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "manual-") {
|
||||
snapName = e.Name()
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the original kandev.db bytes (it exists because newTestPool
|
||||
// opened sqlite at that path).
|
||||
originalPath := filepath.Join(dataDir, "kandev.db")
|
||||
originalBytes, err := os.ReadFile(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read original: %v", err)
|
||||
}
|
||||
|
||||
// Inject a failure in the staged-write step.
|
||||
svc.failWritesForTest = true
|
||||
|
||||
jobID, err := svc.Restore(context.Background(), snapName, "RESTORE")
|
||||
if err != nil {
|
||||
t.Fatalf("Restore submission: %v", err)
|
||||
}
|
||||
waitForJob(t, svc.jobs, jobID, jobs.StateFailed)
|
||||
|
||||
got, err := os.ReadFile(originalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read after fail: %v", err)
|
||||
}
|
||||
if string(got) != string(originalBytes) {
|
||||
t.Error("original kandev.db was modified despite failed restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_RemovesManualFile(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
target := filepath.Join(backupsDir, "manual-1.db")
|
||||
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := svc.Delete("manual-1.db"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("expected file removed, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_RefusesPreResetSnapshot(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
target := filepath.Join(backupsDir, "kandev-pre-reset-20260101T000000Z.db")
|
||||
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
err := svc.Delete("kandev-pre-reset-20260101T000000Z.db")
|
||||
if err == nil {
|
||||
t.Fatal("expected error refusing pre-reset deletion")
|
||||
}
|
||||
if _, statErr := os.Stat(target); statErr != nil {
|
||||
t.Errorf("pre-reset file unexpectedly removed: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenForDownload_RejectsTraversal(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
cases := []string{"../etc/passwd", "..", "foo/bar.db", "/abs/path.db"}
|
||||
for _, name := range cases {
|
||||
f, _, err := svc.OpenForDownload(name)
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
t.Errorf("OpenForDownload(%q) succeeded; expected error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSnapshotPath_RejectsNonSnapshotFile(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
for _, name := range []string{"config.db", "secrets.db", "kandev.db"} {
|
||||
if _, err := svc.resolveSnapshotPath(name); err == nil {
|
||||
t.Errorf("resolveSnapshotPath(%q) succeeded, want rejection", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenForDownload_ValidName(t *testing.T) {
|
||||
svc, dataDir := newTestService(t)
|
||||
backupsDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
target := filepath.Join(backupsDir, "manual-42.db")
|
||||
if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
f, size, err := svc.OpenForDownload("manual-42.db")
|
||||
if err != nil {
|
||||
t.Fatalf("OpenForDownload: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if size != int64(len("hello")) {
|
||||
t.Errorf("size = %d, want %d", size, len("hello"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Vacuum runs VACUUM via a tracked job and returns the job ID. The job
|
||||
// result includes "size_before" and "size_after" bytes; "reclaimed_bytes"
|
||||
// is the difference (>= 0).
|
||||
func (s *Service) Vacuum(ctx context.Context) string {
|
||||
return s.jobs.Start(ctx, "vacuum", func(jobCtx context.Context) (map[string]interface{}, error) {
|
||||
return s.runVacuum(jobCtx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) runVacuum(_ context.Context) (map[string]interface{}, error) {
|
||||
if s.pool == nil {
|
||||
return nil, fmt.Errorf("vacuum: no database pool")
|
||||
}
|
||||
before, _ := readDBSize(s.pool.Reader())
|
||||
if _, err := s.pool.Writer().Exec("VACUUM"); err != nil {
|
||||
return nil, fmt.Errorf("vacuum: %w", err)
|
||||
}
|
||||
after, _ := readDBSize(s.pool.Reader())
|
||||
reclaimed := before - after
|
||||
if reclaimed < 0 {
|
||||
reclaimed = 0
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"size_before": before,
|
||||
"size_after": after,
|
||||
"reclaimed_bytes": reclaimed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Optimize runs PRAGMA optimize via a tracked job. PRAGMA optimize is
|
||||
// cheap and idempotent; we still track it so the UI can show progress
|
||||
// consistently with VACUUM.
|
||||
func (s *Service) Optimize(ctx context.Context) string {
|
||||
return s.jobs.Start(ctx, "optimize", func(jobCtx context.Context) (map[string]interface{}, error) {
|
||||
return s.runOptimize(jobCtx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) runOptimize(_ context.Context) (map[string]interface{}, error) {
|
||||
if s.pool == nil {
|
||||
return nil, fmt.Errorf("optimize: no database pool")
|
||||
}
|
||||
if _, err := s.pool.Writer().Exec("PRAGMA optimize"); err != nil {
|
||||
return nil, fmt.Errorf("pragma optimize: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
|
||||
// HandleVacuum returns a gin handler for POST /api/v1/system/database/vacuum.
|
||||
func HandleVacuum(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
respondAccepted(c, s.Vacuum(ctxOrBackground(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOptimize returns a gin handler for POST /api/v1/system/database/optimize.
|
||||
func HandleOptimize(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
respondAccepted(c, s.Optimize(ctxOrBackground(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// resetRequest is the JSON body for POST /api/v1/system/database/reset.
|
||||
type resetRequest struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
|
||||
// HandleReset returns a gin handler for POST /api/v1/system/database/reset.
|
||||
// Validates the body's confirm == "RESET" before spawning the reset job.
|
||||
func HandleReset(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req resetRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
jobID, err := s.FactoryReset(ctxOrBackground(c), req.Confirm)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
respondAccepted(c, jobID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
// httpGet builds a GET request and is shared by stats/maintenance/reset tests.
|
||||
func httpGet(t *testing.T, path string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest: %v", err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// httpPost builds a POST request with an optional JSON body.
|
||||
func httpPost(t *testing.T, path string, body string) *http.Request {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body == "" {
|
||||
reader = bytes.NewReader(nil)
|
||||
} else {
|
||||
reader = bytes.NewReader([]byte(body))
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, path, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest: %v", err)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func serveHTTP(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool { return strings.Contains(haystack, needle) }
|
||||
|
||||
func TestVacuum_StartsJobAndShrinksDB(t *testing.T) {
|
||||
svc, tracker, _, _ := newTestService(t)
|
||||
|
||||
before, err := svc.Stats()
|
||||
if err != nil {
|
||||
t.Fatalf("stats before: %v", err)
|
||||
}
|
||||
|
||||
id := svc.Vacuum(context.Background())
|
||||
if id == "" {
|
||||
t.Fatal("Vacuum returned empty job id")
|
||||
}
|
||||
job := waitForState(t, tracker, id, jobs.StateSucceeded)
|
||||
if job.Result["reclaimed_bytes"] == nil {
|
||||
t.Errorf("expected reclaimed_bytes in result, got %+v", job.Result)
|
||||
}
|
||||
|
||||
after, err := svc.Stats()
|
||||
if err != nil {
|
||||
t.Fatalf("stats after: %v", err)
|
||||
}
|
||||
// DB file must still exist (Stats reads from it).
|
||||
if after.Path != before.Path {
|
||||
t.Errorf("path changed: %s -> %s", before.Path, after.Path)
|
||||
}
|
||||
if after.SizeBytes <= 0 {
|
||||
t.Errorf("size after vacuum = %d, want > 0", after.SizeBytes)
|
||||
}
|
||||
// schema_version preserved across vacuum.
|
||||
if after.SchemaVersion != "v0.99.0" {
|
||||
t.Errorf("schema_version lost after vacuum: %q", after.SchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimize_StartsJobAndSucceeds(t *testing.T) {
|
||||
svc, tracker, _, _ := newTestService(t)
|
||||
|
||||
id := svc.Optimize(context.Background())
|
||||
if id == "" {
|
||||
t.Fatal("Optimize returned empty job id")
|
||||
}
|
||||
job := waitForState(t, tracker, id, jobs.StateSucceeded)
|
||||
if job.State != jobs.StateSucceeded {
|
||||
t.Errorf("state = %s, want succeeded", job.State)
|
||||
}
|
||||
|
||||
// Verify a follow-up query against the DB still works (sanity).
|
||||
stats, err := svc.Stats()
|
||||
if err != nil {
|
||||
t.Fatalf("post-optimize stats: %v", err)
|
||||
}
|
||||
if stats.SchemaVersion != "v0.99.0" {
|
||||
t.Errorf("schema_version lost after optimize: %q", stats.SchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVacuum_Returns202WithJobID(t *testing.T) {
|
||||
svc, _, _, _ := newTestService(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/vacuum", HandleVacuum(svc))
|
||||
|
||||
w := serveHTTP(r, httpPost(t, "/vacuum", ""))
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !contains(w.Body.String(), `"job_id"`) {
|
||||
t.Errorf("body missing job_id: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOptimize_Returns202WithJobID(t *testing.T) {
|
||||
svc, _, _, _ := newTestService(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/optimize", HandleOptimize(svc))
|
||||
|
||||
w := serveHTTP(r, httpPost(t, "/optimize", ""))
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !contains(w.Body.String(), `"job_id"`) {
|
||||
t.Errorf("body missing job_id: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// resetConfirmToken is the literal value the client must POST as
|
||||
// {"confirm": "..."} to authorise a factory reset. Anything else is
|
||||
// rejected with a 400 / typed error.
|
||||
const resetConfirmToken = "RESET"
|
||||
|
||||
// ErrResetNotConfirmed is returned when the confirm value supplied to
|
||||
// FactoryReset does not equal the resetConfirmToken sentinel.
|
||||
var ErrResetNotConfirmed = errors.New("factory reset requires confirm=\"RESET\"")
|
||||
|
||||
// FactoryReset orchestrates a destructive wipe of the kandev install. The
|
||||
// caller must pass confirm == "RESET" — anything else returns
|
||||
// ErrResetNotConfirmed without starting a job. On success the job ID is
|
||||
// returned immediately; the heavy work runs asynchronously via the jobs
|
||||
// tracker.
|
||||
//
|
||||
// The job:
|
||||
// 1. Calls s.OrchestratorShutdown (if set) to stop running executions.
|
||||
// 2. Snapshots the live DB to <dataDir>/backups/kandev-pre-reset-<unix>.db.
|
||||
// 3. Drops every user table from the SQLite schema (kandev_meta is kept).
|
||||
// 4. os.RemoveAll on worktrees/repos/sessions/tasks/quick-chat subdirs.
|
||||
//
|
||||
// On success the result map exposes {"snapshot_path", "tables_dropped",
|
||||
// "restart_required": true}. The frontend dialog uses restart_required to
|
||||
// prompt the user to quit and relaunch Kandev — no auto re-exec.
|
||||
func (s *Service) FactoryReset(ctx context.Context, confirm string) (string, error) {
|
||||
if confirm != resetConfirmToken {
|
||||
return "", ErrResetNotConfirmed
|
||||
}
|
||||
return s.jobs.Start(ctx, "factory-reset", func(jobCtx context.Context) (map[string]interface{}, error) {
|
||||
return s.runFactoryReset(jobCtx)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *Service) runFactoryReset(_ context.Context) (map[string]interface{}, error) {
|
||||
if s.OrchestratorShutdown != nil {
|
||||
s.OrchestratorShutdown()
|
||||
}
|
||||
|
||||
snapshotPath, err := s.createPreResetSnapshot()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pre-reset snapshot: %w", err)
|
||||
}
|
||||
|
||||
dropped, err := dropUserTables(s.pool.Writer())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("drop tables: %w", err)
|
||||
}
|
||||
|
||||
if err := s.wipeSubdirs(); err != nil {
|
||||
return nil, fmt.Errorf("wipe subdirs: %w", err)
|
||||
}
|
||||
|
||||
// Intentionally no auto-restart. The frontend dialog reads
|
||||
// restart_required from the job result and asks the user to quit and
|
||||
// relaunch the app.
|
||||
return map[string]interface{}{
|
||||
"snapshot_path": snapshotPath,
|
||||
"tables_dropped": dropped,
|
||||
"restart_required": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createPreResetSnapshot performs VACUUM INTO into a stable, time-stamped
|
||||
// path inside the backups directory. The "kandev-" prefix is kept so the
|
||||
// existing backup retention regex continues to match the file.
|
||||
func (s *Service) createPreResetSnapshot() (string, error) {
|
||||
if s.pool == nil {
|
||||
return "", fmt.Errorf("no database pool")
|
||||
}
|
||||
backupDir := filepath.Join(s.dataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("mkdir backups: %w", err)
|
||||
}
|
||||
name := fmt.Sprintf("kandev-pre-reset-%s.db", strconv.FormatInt(time.Now().UTC().Unix(), 10))
|
||||
path := filepath.Join(backupDir, name)
|
||||
if _, err := s.pool.Writer().Exec(`VACUUM INTO ?`, path); err != nil {
|
||||
return "", fmt.Errorf("vacuum into %s: %w", path, err)
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Info("pre-reset snapshot created", zap.String("path", path))
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// dropUserTables drops every row in sqlite_master that is a non-internal
|
||||
// table other than kandev_meta. Returns the count of tables dropped.
|
||||
func dropUserTables(writer *sqlx.DB) (int, error) {
|
||||
rows, err := writer.Query(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name != 'kandev_meta'
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("enumerate user tables: %w", err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if scanErr := rows.Scan(&n); scanErr != nil {
|
||||
_ = rows.Close()
|
||||
return 0, fmt.Errorf("scan table name: %w", scanErr)
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
if closeErr := rows.Close(); closeErr != nil {
|
||||
return 0, fmt.Errorf("close rows: %w", closeErr)
|
||||
}
|
||||
|
||||
// FK enforcement is on for the writer pool — defer constraint checks so
|
||||
// drop order doesn't matter. SQLite ignores PRAGMA foreign_keys inside a
|
||||
// transaction, so toggle it at the connection level.
|
||||
if _, err := writer.Exec("PRAGMA foreign_keys = OFF"); err != nil {
|
||||
return 0, fmt.Errorf("disable foreign keys: %w", err)
|
||||
}
|
||||
defer func() { _, _ = writer.Exec("PRAGMA foreign_keys = ON") }()
|
||||
|
||||
for _, n := range names {
|
||||
if _, err := writer.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %q", n)); err != nil {
|
||||
return 0, fmt.Errorf("drop table %s: %w", n, err)
|
||||
}
|
||||
}
|
||||
return len(names), nil
|
||||
}
|
||||
|
||||
// wipeSubdirs removes the worktrees/repos/sessions/tasks/quick-chat
|
||||
// subdirs configured at construction. Empty paths are skipped so partial
|
||||
// wiring is harmless in tests.
|
||||
func (s *Service) wipeSubdirs() error {
|
||||
targets := []string{
|
||||
s.dirs.Worktrees,
|
||||
s.dirs.Repos,
|
||||
s.dirs.Sessions,
|
||||
s.dirs.Tasks,
|
||||
s.dirs.QuickChat,
|
||||
}
|
||||
for _, t := range targets {
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(t); err != nil {
|
||||
return fmt.Errorf("remove %s: %w", t, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
func TestFactoryReset_WrongConfirm_ReturnsError(t *testing.T) {
|
||||
svc, tracker, _, _ := newTestService(t)
|
||||
|
||||
id, err := svc.FactoryReset(context.Background(), "WRONG")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong confirm, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrResetNotConfirmed) {
|
||||
t.Errorf("err = %v, want ErrResetNotConfirmed", err)
|
||||
}
|
||||
if id != "" {
|
||||
t.Errorf("expected empty id when not confirmed, got %q", id)
|
||||
}
|
||||
if len(tracker.List()) != 0 {
|
||||
t.Errorf("no jobs should be started when confirm fails; got %d", len(tracker.List()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryReset_Confirmed_RunsFullSequence(t *testing.T) {
|
||||
svc, tracker, _, dataDir := newTestService(t)
|
||||
|
||||
var shutdownCalls atomic.Int32
|
||||
svc.OrchestratorShutdown = func() { shutdownCalls.Add(1) }
|
||||
|
||||
id, err := svc.FactoryReset(context.Background(), "RESET")
|
||||
if err != nil {
|
||||
t.Fatalf("FactoryReset: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("expected non-empty job id")
|
||||
}
|
||||
job := waitForState(t, tracker, id, jobs.StateSucceeded)
|
||||
if job.State != jobs.StateSucceeded {
|
||||
t.Fatalf("state = %s, want succeeded; message=%s", job.State, job.Message)
|
||||
}
|
||||
|
||||
// Snapshot path should be in the result map and the file must exist.
|
||||
rawPath, ok := job.Result["snapshot_path"]
|
||||
if !ok {
|
||||
t.Fatalf("result missing snapshot_path: %+v", job.Result)
|
||||
}
|
||||
snapshotPath, _ := rawPath.(string)
|
||||
if snapshotPath == "" {
|
||||
t.Fatalf("snapshot_path empty")
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Base(snapshotPath), "kandev-pre-reset-") {
|
||||
t.Errorf("snapshot filename %q should start with kandev-pre-reset-", filepath.Base(snapshotPath))
|
||||
}
|
||||
if _, err := os.Stat(snapshotPath); err != nil {
|
||||
t.Errorf("snapshot file missing: %v", err)
|
||||
}
|
||||
// Path must live inside <dataDir>/backups
|
||||
if filepath.Dir(snapshotPath) != filepath.Join(dataDir, "backups") {
|
||||
t.Errorf("snapshot dir = %s, want %s", filepath.Dir(snapshotPath), filepath.Join(dataDir, "backups"))
|
||||
}
|
||||
|
||||
// tables_dropped must reflect both seeded user tables.
|
||||
dropped, _ := job.Result["tables_dropped"].(int)
|
||||
if dropped < 2 {
|
||||
t.Errorf("tables_dropped = %d, want >= 2 (users, sessions_t)", dropped)
|
||||
}
|
||||
|
||||
// Verify user tables are gone, kandev_meta is kept.
|
||||
rows, err := svc.pool.Reader().Query(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("query sqlite_master: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var remaining []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if scanErr := rows.Scan(&n); scanErr != nil {
|
||||
t.Fatalf("scan: %v", scanErr)
|
||||
}
|
||||
remaining = append(remaining, n)
|
||||
}
|
||||
if len(remaining) != 1 || remaining[0] != "kandev_meta" {
|
||||
t.Errorf("remaining tables = %v, want [kandev_meta]", remaining)
|
||||
}
|
||||
|
||||
// Wiped subdirs must be gone.
|
||||
for _, p := range []string{svc.dirs.Worktrees, svc.dirs.Repos, svc.dirs.Sessions, svc.dirs.Tasks, svc.dirs.QuickChat} {
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Errorf("subdir %s still exists (err=%v)", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// restart_required must be set so the frontend dialog prompts the user
|
||||
// to quit and relaunch Kandev (no auto re-exec).
|
||||
if got, _ := job.Result["restart_required"].(bool); !got {
|
||||
t.Errorf("restart_required missing or false in job result: %+v", job.Result)
|
||||
}
|
||||
if shutdownCalls.Load() != 1 {
|
||||
t.Errorf("OrchestratorShutdown called %d times, want 1", shutdownCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReset_WrongConfirm_Returns400(t *testing.T) {
|
||||
svc, _, _, _ := newTestService(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/reset", HandleReset(svc))
|
||||
|
||||
w := serveHTTP(r, httpPost(t, "/reset", `{"confirm":"NOPE"}`))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReset_Confirmed_Returns202WithJobID(t *testing.T) {
|
||||
svc, tracker, _, _ := newTestService(t)
|
||||
// FactoryReset no longer re-execs; just install a no-op shutdown so the
|
||||
// orchestrator hook fires without side effects.
|
||||
svc.OrchestratorShutdown = func() {}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/reset", HandleReset(svc))
|
||||
|
||||
w := serveHTTP(r, httpPost(t, "/reset", `{"confirm":"RESET"}`))
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !contains(w.Body.String(), `"job_id"`) {
|
||||
t.Errorf("body missing job_id: %s", w.Body.String())
|
||||
}
|
||||
// Make sure the spawned job finishes before the test exits so the temp dir cleanup
|
||||
// doesn't race with VACUUM INTO.
|
||||
for _, j := range tracker.List() {
|
||||
waitForState(t, tracker, j.ID, jobs.StateSucceeded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package database serves the System -> Database page. It exposes read-only
|
||||
// SQLite stats (path, size, WAL size, schema version, last-backup timestamp)
|
||||
// and three maintenance operations: VACUUM, PRAGMA optimize, and Factory
|
||||
// Reset. Long-running operations are tracked via the jobs.Tracker so the
|
||||
// frontend can observe progress over the event bus.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
// Stats is the read-only SQLite-state payload returned to the frontend.
|
||||
//
|
||||
// LastBackupAt is a pointer so the JSON shape is `null` when no backup
|
||||
// exists. Serialising a zero time.Time as "0001-01-01T00:00:00Z" would
|
||||
// defeat the frontend's "Never" fallback in database-stats-card.tsx.
|
||||
type Stats struct {
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
WALSizeBytes int64 `json:"wal_size_bytes"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
LastBackupAt *time.Time `json:"last_backup_at"`
|
||||
}
|
||||
|
||||
// ResetDirs lists the on-disk directories factory-reset wipes. The Service
|
||||
// only needs paths to call os.RemoveAll on — the construction site (cmd/kandev)
|
||||
// fills these in from the resolved data/home dirs.
|
||||
type ResetDirs struct {
|
||||
Worktrees string
|
||||
Repos string
|
||||
Sessions string
|
||||
Tasks string
|
||||
QuickChat string
|
||||
}
|
||||
|
||||
// Service is the maintenance + stats facade for the System -> Database page.
|
||||
//
|
||||
// FactoryReset does not auto-restart the backend. The job result includes
|
||||
// restart_required=true; the frontend dialog reads it and asks the user to
|
||||
// quit and relaunch Kandev. The previous syscall.Exec approach was brittle
|
||||
// under desktop launchers and `make dev` watchers.
|
||||
type Service struct {
|
||||
pool *db.Pool
|
||||
dataDir string
|
||||
dbPath string
|
||||
dirs ResetDirs
|
||||
jobs *jobs.Tracker
|
||||
log *logger.Logger
|
||||
|
||||
// OrchestratorShutdown stops the orchestrator and active executions before
|
||||
// the factory-reset job runs. Wired by cmd/kandev. Tests pass a no-op.
|
||||
OrchestratorShutdown func()
|
||||
}
|
||||
|
||||
// NewService constructs a Service. dataDir is the resolved kandev data
|
||||
// directory (the SQLite file lives at <dataDir>/kandev.db, backups under
|
||||
// <dataDir>/backups). dirs lists the on-disk subtrees factory-reset wipes.
|
||||
func NewService(pool *db.Pool, dataDir string, dirs ResetDirs, j *jobs.Tracker, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
pool: pool,
|
||||
dataDir: dataDir,
|
||||
dbPath: filepath.Join(dataDir, "kandev.db"),
|
||||
dirs: dirs,
|
||||
jobs: j,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Path returns the absolute SQLite file path the service is bound to.
|
||||
func (s *Service) Path() string { return s.dbPath }
|
||||
|
||||
// Stats returns the current SQLite stats. The size is computed from
|
||||
// PRAGMA page_count * page_size (cheaper than os.Stat on hot writes and
|
||||
// more accurate during a VACUUM that creates a sibling file).
|
||||
func (s *Service) Stats() (Stats, error) {
|
||||
out := Stats{Path: s.dbPath}
|
||||
|
||||
if s.pool != nil {
|
||||
size, err := readDBSize(s.pool.Reader())
|
||||
if err != nil {
|
||||
return Stats{}, err
|
||||
}
|
||||
out.SizeBytes = size
|
||||
|
||||
version, err := readSchemaVersion(s.pool.Reader())
|
||||
if err != nil {
|
||||
return Stats{}, err
|
||||
}
|
||||
out.SchemaVersion = version
|
||||
}
|
||||
|
||||
if wal, err := walSize(s.dbPath); err == nil {
|
||||
out.WALSizeBytes = wal
|
||||
}
|
||||
|
||||
if last := lastBackupAt(filepath.Join(s.dataDir, "backups")); !last.IsZero() {
|
||||
out.LastBackupAt = &last
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readDBSize returns the database size in bytes via PRAGMA page_count *
|
||||
// page_size. Both PRAGMAs return a single integer row.
|
||||
func readDBSize(d interface {
|
||||
QueryRow(query string, args ...interface{}) *sql.Row
|
||||
}) (int64, error) {
|
||||
var pages, pageSize int64
|
||||
if err := d.QueryRow("PRAGMA page_count").Scan(&pages); err != nil {
|
||||
return 0, fmt.Errorf("pragma page_count: %w", err)
|
||||
}
|
||||
if err := d.QueryRow("PRAGMA page_size").Scan(&pageSize); err != nil {
|
||||
return 0, fmt.Errorf("pragma page_size: %w", err)
|
||||
}
|
||||
return pages * pageSize, nil
|
||||
}
|
||||
|
||||
// readSchemaVersion reads the binary version recorded by
|
||||
// cmd/kandev/storage.go:recordSchemaVersion. Missing key returns "" with
|
||||
// no error (fresh DB on first boot).
|
||||
func readSchemaVersion(d interface {
|
||||
QueryRow(query string, args ...interface{}) *sql.Row
|
||||
}) (string, error) {
|
||||
var value string
|
||||
err := d.QueryRow(`SELECT value FROM kandev_meta WHERE key = ?`, "kandev_version").Scan(&value)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read kandev_version: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// walSize stats <dbPath>-wal and returns its size in bytes; a missing WAL
|
||||
// file is treated as zero (the DB may have been just checkpointed).
|
||||
func walSize(dbPath string) (int64, error) {
|
||||
info, err := os.Stat(dbPath + "-wal")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// lastBackupAt returns the mtime of the newest file in backupDir, or the
|
||||
// zero time if the directory is missing/empty. A directory read error is
|
||||
// treated as "no backups" — the Stats endpoint is best-effort.
|
||||
func lastBackupAt(backupDir string) time.Time {
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
mtimes := make([]time.Time, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mtimes = append(mtimes, fi.ModTime())
|
||||
}
|
||||
if len(mtimes) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
sort.Slice(mtimes, func(i, j int) bool { return mtimes[i].After(mtimes[j]) })
|
||||
return mtimes[0]
|
||||
}
|
||||
|
||||
// HandleStats returns a gin handler for GET /api/v1/system/database.
|
||||
func HandleStats(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
stats, err := s.Stats()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
}
|
||||
|
||||
// startJobResponse is the canonical 202 payload for POST endpoints that
|
||||
// spawn a background job.
|
||||
type startJobResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
|
||||
func respondAccepted(c *gin.Context, jobID string) {
|
||||
c.JSON(http.StatusAccepted, startJobResponse{JobID: jobID})
|
||||
}
|
||||
|
||||
// ctxOrBackground returns the context from the gin request when available,
|
||||
// falling back to context.Background() for direct service callers.
|
||||
func ctxOrBackground(c *gin.Context) context.Context {
|
||||
if c == nil || c.Request == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return c.Request.Context()
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stderr"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
// stubBus is a minimal in-memory EventBus that records published events.
|
||||
type stubBus struct {
|
||||
mu sync.Mutex
|
||||
events []*bus.Event
|
||||
}
|
||||
|
||||
func (s *stubBus) Publish(_ context.Context, _ string, event *bus.Event) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.events = append(s.events, event)
|
||||
return nil
|
||||
}
|
||||
func (s *stubBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) { return nil, nil }
|
||||
func (s *stubBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubBus) Close() {}
|
||||
func (s *stubBus) IsConnected() bool { return true }
|
||||
|
||||
var _ bus.EventBus = (*stubBus)(nil)
|
||||
var _ = events.SystemJobUpdate
|
||||
|
||||
// newTestPool opens a temp SQLite at <dataDir>/kandev.db and seeds it with a
|
||||
// kandev_meta row plus a couple of user tables and rows so VACUUM has
|
||||
// something to reclaim.
|
||||
func newTestPool(t *testing.T, dataDir string) (*db.Pool, string) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(dataDir, "kandev.db")
|
||||
writerRaw, err := db.OpenSQLite(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLite: %v", err)
|
||||
}
|
||||
readerRaw, err := db.OpenSQLiteReader(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLiteReader: %v", err)
|
||||
}
|
||||
writer := sqlx.NewDb(writerRaw, "sqlite3")
|
||||
reader := sqlx.NewDb(readerRaw, "sqlite3")
|
||||
pool := db.NewPool(writer, reader)
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`,
|
||||
`INSERT OR REPLACE INTO kandev_meta (key, value) VALUES ('kandev_version', 'v0.99.0')`,
|
||||
`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, payload BLOB)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions_t (id INTEGER PRIMARY KEY, data TEXT)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := writer.Exec(s); err != nil {
|
||||
t.Fatalf("seed %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
// Insert ~100KB of churn to give VACUUM something to reclaim.
|
||||
for i := 0; i < 200; i++ {
|
||||
blob := make([]byte, 1024)
|
||||
for j := range blob {
|
||||
blob[j] = byte(j % 256)
|
||||
}
|
||||
if _, err := writer.Exec(`INSERT INTO users (name, payload) VALUES (?, ?)`, "row", blob); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := writer.Exec(`DELETE FROM users WHERE id % 2 = 0`); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
return pool, dbPath
|
||||
}
|
||||
|
||||
// newTestService wires a Service with a Tracker over the stubBus.
|
||||
func newTestService(t *testing.T) (*Service, *jobs.Tracker, *stubBus, string) {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
dataDir := filepath.Join(tmp, "data")
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
pool, _ := newTestPool(t, dataDir)
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
|
||||
stub := &stubBus{}
|
||||
log := newTestLogger(t)
|
||||
tracker := jobs.NewTracker(stub, log)
|
||||
dirs := ResetDirs{
|
||||
Worktrees: filepath.Join(tmp, "worktrees"),
|
||||
Repos: filepath.Join(tmp, "repos"),
|
||||
Sessions: filepath.Join(tmp, "sessions"),
|
||||
Tasks: filepath.Join(tmp, "tasks"),
|
||||
QuickChat: filepath.Join(tmp, "quick-chat"),
|
||||
}
|
||||
for _, d := range []string{dirs.Worktrees, dirs.Repos, dirs.Sessions, dirs.Tasks, dirs.QuickChat} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
// Write a sentinel file so we can prove RemoveAll wiped it.
|
||||
if err := os.WriteFile(filepath.Join(d, "sentinel"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write sentinel: %v", err)
|
||||
}
|
||||
}
|
||||
svc := NewService(pool, dataDir, dirs, tracker, log)
|
||||
return svc, tracker, stub, dataDir
|
||||
}
|
||||
|
||||
// waitForState mirrors jobs.waitForState — wait until the tracker reports
|
||||
// the target state for the given id, or fail after 2s.
|
||||
func waitForState(t *testing.T, tracker *jobs.Tracker, id string, target jobs.State) *jobs.Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
j := tracker.Get(id)
|
||||
if j != nil && j.State == target {
|
||||
return j
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job %s did not reach state %s within 2s; last = %+v", id, target, tracker.Get(id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStats_ReturnsPathSizeAndSchemaVersion(t *testing.T) {
|
||||
svc, _, _, dataDir := newTestService(t)
|
||||
|
||||
stats, err := svc.Stats()
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
wantPath := filepath.Join(dataDir, "kandev.db")
|
||||
if stats.Path != wantPath {
|
||||
t.Errorf("Path = %q, want %q", stats.Path, wantPath)
|
||||
}
|
||||
if stats.SizeBytes <= 0 {
|
||||
t.Errorf("SizeBytes = %d, want > 0", stats.SizeBytes)
|
||||
}
|
||||
if stats.SchemaVersion != "v0.99.0" {
|
||||
t.Errorf("SchemaVersion = %q, want v0.99.0", stats.SchemaVersion)
|
||||
}
|
||||
if stats.LastBackupAt != nil {
|
||||
t.Errorf("LastBackupAt = %v, want nil (no backups yet)", *stats.LastBackupAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_LastBackupAtPicksNewestFile(t *testing.T) {
|
||||
svc, _, _, dataDir := newTestService(t)
|
||||
|
||||
backupDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir backups: %v", err)
|
||||
}
|
||||
older := filepath.Join(backupDir, "kandev-old.db")
|
||||
newer := filepath.Join(backupDir, "kandev-new.db")
|
||||
if err := os.WriteFile(older, []byte("a"), 0o644); err != nil {
|
||||
t.Fatalf("write older: %v", err)
|
||||
}
|
||||
earlier := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(older, earlier, earlier); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(newer, []byte("b"), 0o644); err != nil {
|
||||
t.Fatalf("write newer: %v", err)
|
||||
}
|
||||
|
||||
stats, err := svc.Stats()
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.LastBackupAt == nil {
|
||||
t.Fatalf("LastBackupAt should be set when a backup file exists")
|
||||
}
|
||||
if !stats.LastBackupAt.After(earlier.Add(time.Hour)) {
|
||||
t.Errorf("LastBackupAt %v should be more recent than %v", *stats.LastBackupAt, earlier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStats_Returns200JSON(t *testing.T) {
|
||||
svc, _, _, _ := newTestService(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/db", HandleStats(svc))
|
||||
|
||||
req := httpGet(t, "/db")
|
||||
w := serveHTTP(r, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !contains(body, `"path"`) || !contains(body, `"schema_version"`) {
|
||||
t.Errorf("body missing fields: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HandleGet serves GET /api/v1/system/disk-usage. Returns the cached
|
||||
// breakdown (or null while computing) plus the Computing flag so the
|
||||
// client can render a spinner without polling on a fixed interval.
|
||||
func HandleGet(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result := s.Get(c.Request.Context())
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRefresh serves POST /api/v1/system/disk-usage/refresh and
|
||||
// always kicks an async walk regardless of cache TTL. Returns
|
||||
// 202 Accepted with the job_id so the client can correlate the
|
||||
// subsequent system.job.update events.
|
||||
func HandleRefresh(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
jobID := s.Refresh(c.Request.Context())
|
||||
c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOpenFolder serves POST /api/v1/system/disk-usage/open and reveals the
|
||||
// configured data directory in the host OS file explorer. The path is always
|
||||
// the resolved home directory the disk walker uses — there is no user input,
|
||||
// so no shell-escaping concerns.
|
||||
func HandleOpenFolder(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := s.HomeDir()
|
||||
if path == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "data directory is not configured"})
|
||||
return
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", path)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", path)
|
||||
case "windows":
|
||||
cmd = exec.Command("explorer", path)
|
||||
default:
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": fmt.Sprintf("unsupported platform: %s", runtime.GOOS)})
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Reap the helper in the background so we don't leak zombies; we don't
|
||||
// block the HTTP response on the file manager opening.
|
||||
go func() { _ = cmd.Wait() }()
|
||||
c.JSON(http.StatusOK, gin.H{"path": path})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package disk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func newDiskRouter(svc *Service) *gin.Engine {
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1/system")
|
||||
g.POST("/disk-usage/open", HandleOpenFolder(svc))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHandleOpenFolder_NoHomeDirReturns503(t *testing.T) {
|
||||
svc := NewService("", jobs.NewTracker(nil, logger.Default()), logger.Default())
|
||||
r := newDiskRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/disk-usage/open", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenFolder_ReturnsPathOnSupportedOS(t *testing.T) {
|
||||
switch runtime.GOOS {
|
||||
case "linux", "darwin", "windows":
|
||||
default:
|
||||
t.Skipf("open-folder handler not exercised on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
svc := NewService(home, jobs.NewTracker(nil, logger.Default()), logger.Default())
|
||||
r := newDiskRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/disk-usage/open", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
want, _ := filepath.Abs(home)
|
||||
if resp.Path != want {
|
||||
t.Errorf("path = %q, want %q", resp.Path, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOpenFolder_UnsupportedPlatformReturns501(t *testing.T) {
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
|
||||
t.Skip("only runs on unsupported GOOS")
|
||||
}
|
||||
home := t.TempDir()
|
||||
svc := NewService(home, jobs.NewTracker(nil, logger.Default()), logger.Default())
|
||||
r := newDiskRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/disk-usage/open", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Package disk computes a lazy, cached breakdown of the on-disk kandev data
|
||||
// footprint. Service exposes Get (returns cached value, kicks async refresh
|
||||
// when missing or stale) and Refresh (forces a recompute). The walk runs as
|
||||
// a tracked job so the WebSocket gateway streams progress to clients.
|
||||
package disk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
// cacheTTL controls how long a cached breakdown is considered fresh. After
|
||||
// it expires, the next Get returns the stale value AND kicks a background
|
||||
// refresh.
|
||||
const cacheTTL = 2 * time.Hour
|
||||
|
||||
// jobKind is the Kind string used when tracking disk-walk jobs.
|
||||
const jobKind = "disk-walk"
|
||||
|
||||
// Breakdown is the per-subdir disk footprint plus aggregated total and any
|
||||
// warnings collected from permission errors during the walk. ComputedAt is
|
||||
// the wall-clock time the walk finished.
|
||||
type Breakdown struct {
|
||||
DataDir int64 `json:"data_dir"`
|
||||
Worktrees int64 `json:"worktrees"`
|
||||
Repos int64 `json:"repos"`
|
||||
Sessions int64 `json:"sessions"`
|
||||
Tasks int64 `json:"tasks"`
|
||||
QuickChat int64 `json:"quick_chat"`
|
||||
Backups int64 `json:"backups"`
|
||||
Total int64 `json:"total"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
ComputedAt time.Time `json:"computed_at"`
|
||||
}
|
||||
|
||||
// GetResult is what Service.Get returns: the cached breakdown (or nil when
|
||||
// none has been computed yet) plus a Computing flag that signals "a
|
||||
// background walk is in flight, expect a fresh value soon". HomeDir is the
|
||||
// absolute path the breakdown was computed against, so the UI can show
|
||||
// "where" the data lives.
|
||||
type GetResult struct {
|
||||
Data *Breakdown `json:"data"`
|
||||
Computing bool `json:"computing"`
|
||||
HomeDir string `json:"home_dir"`
|
||||
}
|
||||
|
||||
// Service owns the in-memory cache and coordinates lazy refresh through the
|
||||
// shared jobs.Tracker. It is safe for concurrent use.
|
||||
type Service struct {
|
||||
homeDir string
|
||||
jobs *jobs.Tracker
|
||||
log *logger.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
value *Breakdown
|
||||
computing bool
|
||||
activeJobID string
|
||||
}
|
||||
|
||||
// NewService constructs a Service for the given home directory. The home
|
||||
// directory should be cfg.ResolvedHomeDir().
|
||||
func NewService(homeDir string, tracker *jobs.Tracker, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
homeDir: homeDir,
|
||||
jobs: tracker,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the current cached breakdown and may kick off an async walk:
|
||||
// - cold (no value, not computing) -> start walk, return {nil, true}
|
||||
// - cold (no value, computing) -> return {nil, true}
|
||||
// - fresh (value, < TTL) -> return {value, false}
|
||||
// - stale (value, >= TTL) -> start refresh, return {value, true}
|
||||
func (s *Service) Get(ctx context.Context) GetResult {
|
||||
s.mu.Lock()
|
||||
value := s.value
|
||||
computing := s.computing
|
||||
stale := value != nil && time.Since(value.ComputedAt) >= cacheTTL
|
||||
shouldKick := !computing && (value == nil || stale)
|
||||
if shouldKick {
|
||||
s.computing = true
|
||||
// Dispatch inside the lock so activeJobID is published before any
|
||||
// concurrent Refresh can observe (computing==true, activeJobID=="")
|
||||
// and start a duplicate walk.
|
||||
s.startWalk(ctx)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if value == nil {
|
||||
return GetResult{Data: nil, Computing: true, HomeDir: s.homeDir}
|
||||
}
|
||||
return GetResult{Data: value, Computing: computing || stale, HomeDir: s.homeDir}
|
||||
}
|
||||
|
||||
// HomeDir returns the absolute path the disk-usage service walks. Exposed so
|
||||
// the "open folder" handler can reveal it in the host OS file explorer.
|
||||
func (s *Service) HomeDir() string {
|
||||
return s.homeDir
|
||||
}
|
||||
|
||||
// Refresh kicks off a walk and returns the job ID. If a walk is already in
|
||||
// flight the in-flight job ID is returned and no new walk is started — a
|
||||
// concurrent recompute would race on the cache and waste IO.
|
||||
//
|
||||
// Single-flight is preserved across the brief window between
|
||||
// `computing = true` and `activeJobID = <id>` by holding the mutex through
|
||||
// the kickJob call: a second Refresh that arrives while we are dispatching
|
||||
// blocks here, then observes computing==true on the next lock and coalesces
|
||||
// onto our job ID.
|
||||
func (s *Service) Refresh(ctx context.Context) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.computing && s.activeJobID != "" {
|
||||
return s.activeJobID
|
||||
}
|
||||
s.computing = true
|
||||
return s.kickJobLocked(ctx)
|
||||
}
|
||||
|
||||
// startWalk schedules an async walk; intended for Get's lazy path where we
|
||||
// only want the side-effect (no job ID returned). Caller must hold s.mu.
|
||||
func (s *Service) startWalk(ctx context.Context) {
|
||||
_ = s.kickJobLocked(ctx)
|
||||
}
|
||||
|
||||
// kickJobLocked hands the walk off to the jobs.Tracker so progress flows
|
||||
// through the standard system.job.update channel. The fn updates the cache
|
||||
// before returning so observers reading Service state after the job
|
||||
// completes see the fresh value. The active job ID is tracked so concurrent
|
||||
// Refresh callers can be coalesced onto the in-flight walk.
|
||||
//
|
||||
// Locked-variant: caller must already hold s.mu so the activeJobID
|
||||
// assignment happens before the lock is released. This closes the race
|
||||
// where a second Refresh could observe (computing==true, activeJobID=="")
|
||||
// and start a duplicate walk.
|
||||
func (s *Service) kickJobLocked(ctx context.Context) string {
|
||||
id := s.jobs.Start(ctx, jobKind, func(jobCtx context.Context) (map[string]interface{}, error) {
|
||||
breakdown := s.computeBreakdown(jobCtx)
|
||||
s.storeBreakdown(breakdown)
|
||||
return map[string]interface{}{"total_bytes": breakdown.Total}, nil
|
||||
})
|
||||
s.activeJobID = id
|
||||
return id
|
||||
}
|
||||
|
||||
// computeBreakdown walks every configured subdir, aggregating sizes and
|
||||
// warnings into a fresh Breakdown stamped with the wall-clock completion
|
||||
// time.
|
||||
func (s *Service) computeBreakdown(_ context.Context) *Breakdown {
|
||||
b := &Breakdown{}
|
||||
for _, sd := range subdirsFor(s.homeDir) {
|
||||
res := walkSubdir(sd.path)
|
||||
assignSubdir(b, sd.name, res.bytes)
|
||||
b.Total += res.bytes
|
||||
if len(res.warnings) > 0 {
|
||||
b.Warnings = append(b.Warnings, res.warnings...)
|
||||
}
|
||||
}
|
||||
b.ComputedAt = time.Now().UTC()
|
||||
if s.log != nil {
|
||||
s.log.Debug("disk walk completed",
|
||||
zap.Int64("total_bytes", b.Total),
|
||||
zap.Int("warnings", len(b.Warnings)),
|
||||
)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// assignSubdir routes the per-subdir size into the matching Breakdown
|
||||
// field. Kept as a small switch so adding a new subdir is a one-liner here
|
||||
// plus one line in subdirsFor.
|
||||
func assignSubdir(b *Breakdown, name string, size int64) {
|
||||
switch name {
|
||||
case "data_dir":
|
||||
b.DataDir = size
|
||||
case "worktrees":
|
||||
b.Worktrees = size
|
||||
case "repos":
|
||||
b.Repos = size
|
||||
case "sessions":
|
||||
b.Sessions = size
|
||||
case "tasks":
|
||||
b.Tasks = size
|
||||
case "quick_chat":
|
||||
b.QuickChat = size
|
||||
case "backups":
|
||||
b.Backups = size
|
||||
}
|
||||
}
|
||||
|
||||
// storeBreakdown publishes a freshly computed breakdown into the cache and
|
||||
// clears the computing flag.
|
||||
func (s *Service) storeBreakdown(b *Breakdown) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.value = b
|
||||
s.computing = false
|
||||
s.activeJobID = ""
|
||||
}
|
||||
|
||||
// setComputedAt is a test-only helper that rewinds the cached value's
|
||||
// ComputedAt so tests can observe the TTL-expired branch of Get without
|
||||
// sleeping for two hours.
|
||||
func (s *Service) setComputedAt(t time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.value != nil {
|
||||
s.value.ComputedAt = t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Tests for the lazy-cached disk-usage Service. Covers the four branches of
|
||||
// Get described in the spec (cold-not-computing, cold-computing, fresh,
|
||||
// stale) plus Refresh's force-walk semantics. Uses the in-process
|
||||
// jobs.Tracker with a stub event bus.
|
||||
package disk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
// stubBus is a minimal in-memory EventBus used by the jobs.Tracker. We only
|
||||
// need Publish to succeed; the other methods are no-ops to satisfy the
|
||||
// interface.
|
||||
type stubBus struct {
|
||||
mu sync.Mutex
|
||||
events []*bus.Event
|
||||
}
|
||||
|
||||
func (s *stubBus) Publish(_ context.Context, _ string, event *bus.Event) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.events = append(s.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubBus) Close() {}
|
||||
func (s *stubBus) IsConnected() bool { return true }
|
||||
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stderr"})
|
||||
if err != nil {
|
||||
t.Fatalf("new logger: %v", err)
|
||||
}
|
||||
return log
|
||||
}
|
||||
|
||||
// newServiceWithTmpHome builds a Service rooted at a fresh tmp dir with a
|
||||
// known layout: data/ + worktrees/ + repos/ + data/backups/. The remaining
|
||||
// subdirs (sessions/tasks/quick-chat) are intentionally absent to exercise
|
||||
// the missing-root path.
|
||||
func newServiceWithTmpHome(t *testing.T) (*Service, string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
writeFile(t, filepath.Join(home, "data", "kandev.db"), []byte("0123456789")) // 10
|
||||
writeFile(t, filepath.Join(home, "data", "backups", "auto.db"), []byte("AAAA")) // 4
|
||||
writeFile(t, filepath.Join(home, "worktrees", "ws1", "file"), []byte("BB")) // 2
|
||||
writeFile(t, filepath.Join(home, "repos", "r", "x"), []byte("CCC")) // 3
|
||||
tracker := jobs.NewTracker(&stubBus{}, newTestLogger(t))
|
||||
return NewService(home, tracker, newTestLogger(t)), home
|
||||
}
|
||||
|
||||
// waitForBreakdown polls until the Service caches a value or the deadline
|
||||
// fires; mirrors the jobs_test.go waitForState helper.
|
||||
func waitForBreakdown(t *testing.T, svc *Service) *Breakdown {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
svc.mu.Lock()
|
||||
v := svc.value
|
||||
svc.mu.Unlock()
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("breakdown not cached within 2s")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGet_ColdReturnsComputingAndKicksWalk(t *testing.T) {
|
||||
svc, _ := newServiceWithTmpHome(t)
|
||||
|
||||
first := svc.Get(context.Background())
|
||||
if first.Data != nil {
|
||||
t.Errorf("first Get.Data = %+v, want nil", first.Data)
|
||||
}
|
||||
if !first.Computing {
|
||||
t.Error("first Get.Computing = false, want true")
|
||||
}
|
||||
|
||||
got := waitForBreakdown(t, svc)
|
||||
// data 10 + backups 4 + worktrees 2 + repos 3 = 19. Note that data
|
||||
// includes backups (because backups lives under data/) so they are
|
||||
// counted twice intentionally — backups is its own bucket but also
|
||||
// physically nested in data_dir on disk.
|
||||
const wantTotal = int64(10 + 4 + 2 + 3 + 4) // data=14 (10+4), worktrees=2, repos=3, backups=4
|
||||
if got.Total != wantTotal {
|
||||
t.Errorf("Total = %d, want %d (data 14 + worktrees 2 + repos 3 + backups 4)", got.Total, wantTotal)
|
||||
}
|
||||
if got.DataDir != 14 {
|
||||
t.Errorf("DataDir = %d, want 14", got.DataDir)
|
||||
}
|
||||
if got.Backups != 4 {
|
||||
t.Errorf("Backups = %d, want 4", got.Backups)
|
||||
}
|
||||
if got.Worktrees != 2 {
|
||||
t.Errorf("Worktrees = %d, want 2", got.Worktrees)
|
||||
}
|
||||
if got.Repos != 3 {
|
||||
t.Errorf("Repos = %d, want 3", got.Repos)
|
||||
}
|
||||
if got.Sessions != 0 || got.Tasks != 0 || got.QuickChat != 0 {
|
||||
t.Errorf("expected missing subdirs to be zero, got sessions=%d tasks=%d quick=%d",
|
||||
got.Sessions, got.Tasks, got.QuickChat)
|
||||
}
|
||||
if got.ComputedAt.IsZero() {
|
||||
t.Error("ComputedAt is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_FreshReturnsCachedWithoutRecomputing(t *testing.T) {
|
||||
svc, _ := newServiceWithTmpHome(t)
|
||||
svc.Get(context.Background())
|
||||
first := waitForBreakdown(t, svc)
|
||||
|
||||
// Capture the result-publishing event count before the second Get so
|
||||
// we can prove no new walk was scheduled.
|
||||
second := svc.Get(context.Background())
|
||||
if second.Data == nil {
|
||||
t.Fatal("second Get.Data = nil, want cached breakdown")
|
||||
}
|
||||
if second.Computing {
|
||||
t.Error("second Get.Computing = true, want false (fresh cache)")
|
||||
}
|
||||
if !second.Data.ComputedAt.Equal(first.ComputedAt) {
|
||||
t.Errorf("second ComputedAt = %s, want unchanged %s", second.Data.ComputedAt, first.ComputedAt)
|
||||
}
|
||||
|
||||
// Give any rogue background walk a chance to overwrite the cache. If
|
||||
// the implementation kicked one off, ComputedAt would shift.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
svc.mu.Lock()
|
||||
final := svc.value
|
||||
svc.mu.Unlock()
|
||||
if !final.ComputedAt.Equal(first.ComputedAt) {
|
||||
t.Errorf("cache was rewritten after fresh Get; ComputedAt drifted from %s to %s",
|
||||
first.ComputedAt, final.ComputedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_StaleReturnsValueAndKicksRefresh(t *testing.T) {
|
||||
svc, _ := newServiceWithTmpHome(t)
|
||||
svc.Get(context.Background())
|
||||
first := waitForBreakdown(t, svc)
|
||||
|
||||
// Rewind ComputedAt past the 2h TTL.
|
||||
stale := time.Now().Add(-3 * time.Hour)
|
||||
svc.setComputedAt(stale)
|
||||
|
||||
// Add a new file so the refreshed walk produces a different Total.
|
||||
writeFile(t, filepath.Join(svc.homeDir, "tasks", "t1", "f"), []byte("DDDDD")) // 5
|
||||
|
||||
res := svc.Get(context.Background())
|
||||
if res.Data == nil {
|
||||
t.Fatal("stale Get.Data = nil, want stale value")
|
||||
}
|
||||
if !res.Computing {
|
||||
t.Error("stale Get.Computing = false, want true (refresh kicked)")
|
||||
}
|
||||
|
||||
// Wait for the refresh to land — Total should grow by the new file.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
svc.mu.Lock()
|
||||
v := svc.value
|
||||
svc.mu.Unlock()
|
||||
if v != nil && v.Total > first.Total {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("refresh did not update cache; first Total=%d still current", first.Total)
|
||||
}
|
||||
|
||||
func TestRefresh_AlwaysKicksWalkAndReturnsJobID(t *testing.T) {
|
||||
svc, _ := newServiceWithTmpHome(t)
|
||||
id := svc.Refresh(context.Background())
|
||||
if id == "" {
|
||||
t.Fatal("Refresh returned empty job ID")
|
||||
}
|
||||
if got := svc.jobs.Get(id); got == nil {
|
||||
t.Errorf("tracker has no record of job %s", id)
|
||||
}
|
||||
waitForBreakdown(t, svc)
|
||||
}
|
||||
|
||||
func TestGet_ColdComputingReturnsNilDataAndComputingTrue(t *testing.T) {
|
||||
// Directly exercises the "cold-computing" branch of Get (value==nil,
|
||||
// computing==true) by setting the flag under the mutex before calling Get.
|
||||
// This avoids a timing dependency: the previous approach called Get twice
|
||||
// and hoped the background walk hadn't finished yet, which is unreliable on
|
||||
// fast hardware with a small tmp dir.
|
||||
svc, _ := newServiceWithTmpHome(t)
|
||||
|
||||
svc.mu.Lock()
|
||||
svc.computing = true
|
||||
svc.mu.Unlock()
|
||||
|
||||
res := svc.Get(context.Background())
|
||||
if res.Data != nil {
|
||||
t.Errorf("Get.Data = %+v, want nil", res.Data)
|
||||
}
|
||||
if !res.Computing {
|
||||
t.Error("Get.Computing = false, want true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package disk computes a lazy, cached breakdown of the on-disk kandev data
|
||||
// footprint (data dir, worktrees, repos, sessions, tasks, quick-chat, and
|
||||
// backups) for the System -> Status page. The walk is tolerant of permission
|
||||
// errors and surfaces them as Warnings on the returned Breakdown.
|
||||
package disk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// subdir describes a logical bucket of the breakdown — its display name (the
|
||||
// JSON / breakdown field) and the absolute path on disk it covers.
|
||||
type subdir struct {
|
||||
name string
|
||||
path string
|
||||
}
|
||||
|
||||
// subdirsFor returns the list of subdirectories that contribute to the
|
||||
// breakdown for a given home directory. The backups subdir lives under
|
||||
// <home>/data/backups; the rest are direct children of <home>.
|
||||
func subdirsFor(homeDir string) []subdir {
|
||||
return []subdir{
|
||||
{name: "data_dir", path: filepath.Join(homeDir, "data")},
|
||||
{name: "worktrees", path: filepath.Join(homeDir, "worktrees")},
|
||||
{name: "repos", path: filepath.Join(homeDir, "repos")},
|
||||
{name: "sessions", path: filepath.Join(homeDir, "sessions")},
|
||||
{name: "tasks", path: filepath.Join(homeDir, "tasks")},
|
||||
{name: "quick_chat", path: filepath.Join(homeDir, "quick-chat")},
|
||||
{name: "backups", path: filepath.Join(homeDir, "data", "backups")},
|
||||
}
|
||||
}
|
||||
|
||||
// walkResult is the per-subdir output of walkSubdir.
|
||||
type walkResult struct {
|
||||
bytes int64
|
||||
warnings []string
|
||||
}
|
||||
|
||||
// walkSubdir recursively sums regular file sizes under root. Permission
|
||||
// errors on subdirectories are recorded into warnings and the walk
|
||||
// continues with the next sibling. A missing root (os.IsNotExist) yields a
|
||||
// zero-byte result with no warning — empty installs are normal.
|
||||
func walkSubdir(root string) walkResult {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return walkResult{}
|
||||
}
|
||||
return walkResult{warnings: []string{fmt.Sprintf("%s: %s", root, err)}}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
// Treat a regular file as its own size; uncommon but cheap to handle.
|
||||
return walkResult{bytes: info.Size()}
|
||||
}
|
||||
|
||||
var result walkResult
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf("%s: %s", path, walkErr))
|
||||
if d != nil && d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
fi, infoErr := d.Info()
|
||||
if infoErr != nil {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf("%s: %s", path, infoErr))
|
||||
return nil
|
||||
}
|
||||
if fi.Mode().IsRegular() {
|
||||
result.bytes += fi.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf("%s: %s", root, err))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Tests for the per-subdirectory disk walker. Covers happy-path sizing
|
||||
// across all configured subdirs and graceful handling of a chmod-000
|
||||
// subdirectory (which should produce a warning string while still totalling
|
||||
// the readable siblings).
|
||||
package disk
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeFile creates parents and a regular file with the given content.
|
||||
func writeFile(t *testing.T, path string, content []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkSubdir_MissingRootReturnsZero(t *testing.T) {
|
||||
res := walkSubdir(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if res.bytes != 0 {
|
||||
t.Errorf("expected 0 bytes for missing root, got %d", res.bytes)
|
||||
}
|
||||
if len(res.warnings) != 0 {
|
||||
t.Errorf("expected no warnings for missing root, got %v", res.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkSubdir_SumsRegularFilesRecursively(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "a.txt"), []byte("hello")) // 5
|
||||
writeFile(t, filepath.Join(root, "nested", "b.txt"), []byte("world!")) // 6
|
||||
writeFile(t, filepath.Join(root, "nested", "deep", "c.bin"), []byte("12345")) // 5
|
||||
|
||||
res := walkSubdir(root)
|
||||
const want = int64(16)
|
||||
if res.bytes != want {
|
||||
t.Errorf("bytes = %d, want %d", res.bytes, want)
|
||||
}
|
||||
if len(res.warnings) != 0 {
|
||||
t.Errorf("expected no warnings, got %v", res.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkSubdir_RecordsPermissionWarningAndContinues(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod-based permission test is POSIX only")
|
||||
}
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("running as root bypasses permission checks")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
// Readable sibling contributes a known size.
|
||||
writeFile(t, filepath.Join(root, "readable", "ok.txt"), []byte("abcdef")) // 6
|
||||
|
||||
// Unreadable sibling: file inside must exist before chmod so WalkDir
|
||||
// reports the permission error when descending.
|
||||
blocked := filepath.Join(root, "blocked")
|
||||
writeFile(t, filepath.Join(blocked, "secret.txt"), []byte("xx"))
|
||||
if err := os.Chmod(blocked, 0o000); err != nil {
|
||||
t.Fatalf("chmod blocked: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
// Restore permissions so the temp dir can be cleaned up.
|
||||
_ = os.Chmod(blocked, 0o755)
|
||||
})
|
||||
|
||||
res := walkSubdir(root)
|
||||
|
||||
if res.bytes != 6 {
|
||||
t.Errorf("bytes = %d, want 6 (readable sibling only)", res.bytes)
|
||||
}
|
||||
if len(res.warnings) == 0 {
|
||||
t.Fatalf("expected at least one warning for the chmod-000 subdir")
|
||||
}
|
||||
joined := strings.Join(res.warnings, "\n")
|
||||
if !strings.Contains(joined, "blocked") {
|
||||
t.Errorf("expected warning to mention the blocked subdir, got %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubdirsFor_CoversAllExpectedBuckets(t *testing.T) {
|
||||
got := subdirsFor("/tmp/example")
|
||||
wantNames := []string{"data_dir", "worktrees", "repos", "sessions", "tasks", "quick_chat", "backups"}
|
||||
if len(got) != len(wantNames) {
|
||||
t.Fatalf("got %d subdirs, want %d", len(got), len(wantNames))
|
||||
}
|
||||
for i, sd := range got {
|
||||
if sd.name != wantNames[i] {
|
||||
t.Errorf("subdir[%d].name = %s, want %s", i, sd.name, wantNames[i])
|
||||
}
|
||||
}
|
||||
// Backups must live under data/, not at the home root.
|
||||
for _, sd := range got {
|
||||
if sd.name == "backups" && !strings.HasSuffix(sd.path, filepath.Join("data", "backups")) {
|
||||
t.Errorf("backups path = %s, want suffix data/backups", sd.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package info serves GET /api/v1/system/info — a small read-only payload
|
||||
// describing the running binary (version, build commit, build time) and the
|
||||
// Go runtime (version, OS, arch). Powers the System -> About page.
|
||||
package info
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response is the JSON payload returned by GET /api/v1/system/info.
|
||||
type Response struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildTime string `json:"build_time"`
|
||||
GoVersion string `json:"go_version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
}
|
||||
|
||||
// Service composes the static build info; instantiated once at boot from
|
||||
// cmd/kandev (which owns the ldflag-injected variables) and reused per
|
||||
// request.
|
||||
type Service struct {
|
||||
Version string
|
||||
Commit string
|
||||
BuildTime string
|
||||
}
|
||||
|
||||
// NewService constructs a Service from the ldflag-injected build values.
|
||||
func NewService(version, commit, buildTime string) *Service {
|
||||
return &Service{Version: version, Commit: commit, BuildTime: buildTime}
|
||||
}
|
||||
|
||||
// Info renders the response payload.
|
||||
func (s *Service) Info() Response {
|
||||
return Response{
|
||||
Version: s.Version,
|
||||
Commit: s.Commit,
|
||||
BuildTime: s.BuildTime,
|
||||
GoVersion: runtime.Version(),
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the gin handler for GET /api/v1/system/info.
|
||||
func Handler(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.Info())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package info
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestInfo_ReturnsConfiguredAndRuntimeFields(t *testing.T) {
|
||||
s := NewService("v1.2.3", "abc123", "2026-05-18T00:00:00Z")
|
||||
got := s.Info()
|
||||
if got.Version != "v1.2.3" {
|
||||
t.Errorf("Version = %q", got.Version)
|
||||
}
|
||||
if got.Commit != "abc123" {
|
||||
t.Errorf("Commit = %q", got.Commit)
|
||||
}
|
||||
if got.BuildTime != "2026-05-18T00:00:00Z" {
|
||||
t.Errorf("BuildTime = %q", got.BuildTime)
|
||||
}
|
||||
if got.GoVersion != runtime.Version() {
|
||||
t.Errorf("GoVersion = %q, want %q", got.GoVersion, runtime.Version())
|
||||
}
|
||||
if got.OS != runtime.GOOS {
|
||||
t.Errorf("OS = %q", got.OS)
|
||||
}
|
||||
if got.Arch != runtime.GOARCH {
|
||||
t.Errorf("Arch = %q", got.Arch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_RendersJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
s := NewService("v1.2.3", "abc", "now")
|
||||
r := gin.New()
|
||||
r.GET("/info", Handler(s))
|
||||
|
||||
req := httptest.NewRequest("GET", "/info", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp Response
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if resp.Version != "v1.2.3" {
|
||||
t.Errorf("Version = %q", resp.Version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HandleGet renders GET /api/v1/system/jobs/:id. Returns 404 when the job is
|
||||
// unknown. Used by the frontend as a polling fallback in case the
|
||||
// `system.job.update` WS broadcast was missed (which happens when the WS
|
||||
// connection isn't open at the moment the job transitions). The same Job
|
||||
// shape is published over the bus, so the polling path and the WS path feed
|
||||
// into the same store slice.
|
||||
func HandleGet(t *Tracker) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
job := t.Get(id)
|
||||
if job == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func newJobsRouter(tracker *Tracker) *gin.Engine {
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1/system")
|
||||
g.GET("/jobs/:id", HandleGet(tracker))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHandleGet_UnknownJobReturns404(t *testing.T) {
|
||||
tracker := NewTracker(nil, newTestLogger())
|
||||
r := newJobsRouter(tracker)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/jobs/missing-id", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGet_KnownJobReturns200(t *testing.T) {
|
||||
tracker := NewTracker(nil, newTestLogger())
|
||||
id := tracker.Start(context.TODO(), "vacuum", func(_ context.Context) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{"ok": true}, nil
|
||||
})
|
||||
waitForState(t, tracker, id, StateSucceeded)
|
||||
|
||||
r := newJobsRouter(tracker)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/jobs/"+id, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var job Job
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &job); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if job.ID != id || job.State != StateSucceeded {
|
||||
t.Errorf("job = %+v", job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package jobs tracks long-running maintenance operations exposed by the
|
||||
// System pages (VACUUM, factory reset, snapshot create/restore, disk walk,
|
||||
// etc.) and publishes lifecycle events on the event bus so the WebSocket
|
||||
// gateway can stream progress to connected clients.
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
)
|
||||
|
||||
// State is the lifecycle state of a Job.
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateQueued State = "queued"
|
||||
StateRunning State = "running"
|
||||
StateSucceeded State = "succeeded"
|
||||
StateFailed State = "failed"
|
||||
)
|
||||
|
||||
// Job is a tracked unit of long-running work.
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
State State `json:"state"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Result map[string]interface{} `json:"result,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
EndedAt time.Time `json:"ended_at,omitempty"`
|
||||
}
|
||||
|
||||
// Tracker owns the in-memory job table and publishes lifecycle events.
|
||||
//
|
||||
// Tracker is safe for concurrent use.
|
||||
type Tracker struct {
|
||||
mu sync.RWMutex
|
||||
jobs map[string]*Job
|
||||
bus bus.EventBus
|
||||
log *logger.Logger
|
||||
source string
|
||||
}
|
||||
|
||||
// NewTracker constructs a Tracker. busSource is the value placed in the
|
||||
// event Source field (e.g. "system-jobs").
|
||||
func NewTracker(eventBus bus.EventBus, log *logger.Logger) *Tracker {
|
||||
return &Tracker{
|
||||
jobs: make(map[string]*Job),
|
||||
bus: eventBus,
|
||||
log: log,
|
||||
source: "system-jobs",
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs fn in a goroutine, publishing queued/running/succeeded|failed
|
||||
// transitions on the event bus. Returns the job ID immediately. fn returns
|
||||
// a result map (rendered as part of the succeeded event) or an error
|
||||
// (which becomes the failed message).
|
||||
func (t *Tracker) Start(ctx context.Context, kind string, fn func(ctx context.Context) (map[string]interface{}, error)) string {
|
||||
job := &Job{
|
||||
ID: uuid.New().String(),
|
||||
Kind: kind,
|
||||
State: StateQueued,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.jobs[job.ID] = job
|
||||
snapshot := *job
|
||||
t.mu.Unlock()
|
||||
|
||||
t.publish(ctx, &snapshot)
|
||||
|
||||
go t.run(ctx, job, fn)
|
||||
|
||||
return job.ID
|
||||
}
|
||||
|
||||
func (t *Tracker) run(ctx context.Context, job *Job, fn func(ctx context.Context) (map[string]interface{}, error)) {
|
||||
t.transition(ctx, job.ID, func(j *Job) {
|
||||
j.State = StateRunning
|
||||
})
|
||||
|
||||
result, err := fn(ctx)
|
||||
|
||||
t.transition(ctx, job.ID, func(j *Job) {
|
||||
j.EndedAt = time.Now().UTC()
|
||||
if err != nil {
|
||||
j.State = StateFailed
|
||||
j.Message = err.Error()
|
||||
return
|
||||
}
|
||||
j.State = StateSucceeded
|
||||
j.Result = result
|
||||
})
|
||||
}
|
||||
|
||||
func (t *Tracker) transition(ctx context.Context, id string, mutate func(*Job)) {
|
||||
t.mu.Lock()
|
||||
job, ok := t.jobs[id]
|
||||
if !ok {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
mutate(job)
|
||||
snapshot := *job
|
||||
t.mu.Unlock()
|
||||
t.publish(ctx, &snapshot)
|
||||
}
|
||||
|
||||
func (t *Tracker) publish(ctx context.Context, job *Job) {
|
||||
if t.bus == nil {
|
||||
return
|
||||
}
|
||||
if err := t.bus.Publish(ctx, events.SystemJobUpdate, bus.NewEvent(events.SystemJobUpdate, t.source, job)); err != nil && t.log != nil {
|
||||
t.log.Warn("failed to publish system.job.update", zap.String("job_id", job.ID), zap.String("kind", job.Kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a snapshot of the job by id, or nil if unknown.
|
||||
func (t *Tracker) Get(id string) *Job {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
job, ok := t.jobs[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
snapshot := *job
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
// List returns snapshots of all tracked jobs.
|
||||
func (t *Tracker) List() []Job {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := make([]Job, 0, len(t.jobs))
|
||||
for _, j := range t.jobs {
|
||||
out = append(out, *j)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
)
|
||||
|
||||
func newTestLogger() *logger.Logger {
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stderr"})
|
||||
return log
|
||||
}
|
||||
|
||||
// stubBus is a minimal in-memory EventBus that records published events on
|
||||
// the SystemJobUpdate subject. It is *not* a full implementation of all
|
||||
// EventBus methods - only Publish is used by Tracker.
|
||||
type stubBus struct {
|
||||
mu sync.Mutex
|
||||
events []*bus.Event
|
||||
}
|
||||
|
||||
func (s *stubBus) Publish(_ context.Context, _ string, event *bus.Event) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.events = append(s.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubBus) Close() {}
|
||||
func (s *stubBus) IsConnected() bool { return true }
|
||||
|
||||
func (s *stubBus) snapshot() []*bus.Event {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cp := make([]*bus.Event, len(s.events))
|
||||
copy(cp, s.events)
|
||||
return cp
|
||||
}
|
||||
|
||||
// waitForState polls Tracker.Get(id) until the job reaches a terminal state
|
||||
// (succeeded or failed) or the timeout fires. The Tracker runs work in a
|
||||
// goroutine so tests must wait for completion.
|
||||
func waitForState(t *testing.T, tracker *Tracker, id string, target State) *Job {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
j := tracker.Get(id)
|
||||
if j != nil && j.State == target {
|
||||
return j
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job %s did not reach state %s within 2s; last = %+v", id, target, tracker.Get(id))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStart_PublishesQueuedRunningSucceeded(t *testing.T) {
|
||||
stub := &stubBus{}
|
||||
tracker := NewTracker(stub, newTestLogger())
|
||||
|
||||
id := tracker.Start(context.Background(), "vacuum", func(context.Context) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{"reclaimed_bytes": 12345}, nil
|
||||
})
|
||||
|
||||
job := waitForState(t, tracker, id, StateSucceeded)
|
||||
if job.Result["reclaimed_bytes"] != 12345 {
|
||||
t.Errorf("expected reclaimed_bytes in result, got %+v", job.Result)
|
||||
}
|
||||
|
||||
events := stub.snapshot()
|
||||
if len(events) < 3 {
|
||||
t.Fatalf("expected at least 3 published events (queued/running/succeeded), got %d", len(events))
|
||||
}
|
||||
|
||||
// Confirm the lifecycle in order. The implementation may publish more
|
||||
// than three (e.g. snapshots after each transition), so we only check
|
||||
// the prefix.
|
||||
wantStates := []State{StateQueued, StateRunning, StateSucceeded}
|
||||
for i, want := range wantStates {
|
||||
got := events[i].Data.(*Job)
|
||||
if got.State != want {
|
||||
t.Errorf("event[%d].state = %s, want %s", i, got.State, want)
|
||||
}
|
||||
if got.Kind != "vacuum" {
|
||||
t.Errorf("event[%d].kind = %s, want vacuum", i, got.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStart_PublishesFailed(t *testing.T) {
|
||||
stub := &stubBus{}
|
||||
tracker := NewTracker(stub, newTestLogger())
|
||||
|
||||
id := tracker.Start(context.Background(), "vacuum", func(context.Context) (map[string]interface{}, error) {
|
||||
return nil, errors.New("disk full")
|
||||
})
|
||||
|
||||
job := waitForState(t, tracker, id, StateFailed)
|
||||
if job.Message != "disk full" {
|
||||
t.Errorf("expected message %q, got %q", "disk full", job.Message)
|
||||
}
|
||||
if job.EndedAt.IsZero() {
|
||||
t.Error("expected EndedAt to be set on failed job")
|
||||
}
|
||||
|
||||
events := stub.snapshot()
|
||||
if len(events) < 3 {
|
||||
t.Fatalf("expected at least 3 events, got %d", len(events))
|
||||
}
|
||||
last := events[len(events)-1].Data.(*Job)
|
||||
if last.State != StateFailed {
|
||||
t.Errorf("last event state = %s, want failed", last.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_UnknownReturnsNil(t *testing.T) {
|
||||
tracker := NewTracker(&stubBus{}, newTestLogger())
|
||||
if got := tracker.Get("nonexistent"); got != nil {
|
||||
t.Errorf("expected nil for unknown id, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_ReturnsAllTrackedJobs(t *testing.T) {
|
||||
tracker := NewTracker(&stubBus{}, newTestLogger())
|
||||
ids := make([]string, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
ids = append(ids, tracker.Start(context.Background(), "noop", func(context.Context) (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}))
|
||||
}
|
||||
for _, id := range ids {
|
||||
waitForState(t, tracker, id, StateSucceeded)
|
||||
}
|
||||
list := tracker.List()
|
||||
if len(list) != 3 {
|
||||
t.Errorf("expected 3 jobs, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilBus_DoesNotPanic(t *testing.T) {
|
||||
tracker := NewTracker(nil, newTestLogger())
|
||||
id := tracker.Start(context.Background(), "noop", func(context.Context) (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
})
|
||||
waitForState(t, tracker, id, StateSucceeded)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Package logs serves the System -> Logs page: lists log files on disk
|
||||
// (current + lumberjack-rotated backups), tails the most recent N lines of
|
||||
// the active log file, and streams individual files for download.
|
||||
package logs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/common/logger/buffer"
|
||||
)
|
||||
|
||||
// FileInfo describes one log file on disk.
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Mtime time.Time `json:"mtime"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// Service enumerates and serves files from the configured log directory.
|
||||
// The active (un-rotated) file is identified by exact name match against
|
||||
// currentName (set to the lumberjack Filename's basename, e.g. "kandev.log").
|
||||
// When logDir is empty (the process is logging to stdout/stderr only), Tail
|
||||
// falls back to the in-memory ring buffer so the UI still shows recent logs.
|
||||
type Service struct {
|
||||
logDir string
|
||||
currentName string
|
||||
log *logger.Logger
|
||||
memBuffer *buffer.RingBuffer
|
||||
}
|
||||
|
||||
// NewService constructs a Service. logDir may be empty (logger is writing to
|
||||
// stdout/stderr); List then returns an empty slice and Tail falls back to the
|
||||
// shared in-memory ring buffer. currentName is the basename of the active log
|
||||
// file (e.g. "kandev.log").
|
||||
func NewService(logDir, currentName string, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
logDir: logDir,
|
||||
currentName: currentName,
|
||||
log: log,
|
||||
memBuffer: buffer.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// List returns the log files in the configured directory, sorted by mtime
|
||||
// descending (newest first). The active file is flagged with Current=true.
|
||||
//
|
||||
// Subdirectories are skipped. A missing log directory yields an empty slice
|
||||
// without error (the logger may be configured for stdout-only output).
|
||||
func (s *Service) List() ([]FileInfo, error) {
|
||||
out := []FileInfo{}
|
||||
if s.logDir == "" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(s.logDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return out, nil
|
||||
}
|
||||
s.warn("read log directory failed", zap.String("dir", s.logDir), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
fi, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
s.warn("stat log entry failed", zap.String("name", entry.Name()), zap.Error(statErr))
|
||||
continue
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
// Allow-list: only the active log and lumberjack rotations of it.
|
||||
// A shared logging.outputPath directory may hold unrelated files —
|
||||
// we must never enumerate them through this endpoint.
|
||||
if !s.isAllowed(name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, FileInfo{
|
||||
Name: name,
|
||||
Size: fi.Size(),
|
||||
Mtime: fi.ModTime(),
|
||||
Current: s.isCurrent(name),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].Mtime.After(out[j].Mtime)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isCurrent reports whether name is the active (un-rotated) log file.
|
||||
// Lumberjack rotates "kandev.log" to "kandev-<timestamp>.log[.gz]", so an
|
||||
// exact basename match is enough to distinguish current from rotated.
|
||||
func (s *Service) isCurrent(name string) bool {
|
||||
return s.currentName != "" && name == s.currentName
|
||||
}
|
||||
|
||||
// lumberjackTimestamp matches the timestamp lumberjack injects between the
|
||||
// base name and extension when it rotates a log file. Lumberjack formats the
|
||||
// stamp as time.Format("2006-01-02T15-04-05.000"), so the pattern is exactly
|
||||
// four digits, a dash, two digits, a dash, two digits, a T, two digits, a
|
||||
// dash, two digits, a dash, two digits, a dot, three digits — nothing else.
|
||||
// This is tighter than the previous "non-empty string" check and prevents
|
||||
// unrelated files like "kandev-cli.log" from matching in shared directories.
|
||||
var lumberjackTimestamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}$`)
|
||||
|
||||
// isAllowed reports whether name belongs to the configured log set: either
|
||||
// the active log file itself, or a lumberjack-rotated sibling. Lumberjack
|
||||
// rotates "<base>.<ext>" into "<base>-<timestamp>.<ext>" (optionally gzipped
|
||||
// to ".<ext>.gz"). Only names whose embedded timestamp matches lumberjack's
|
||||
// exact YYYY-MM-DDTHH-MM-SS.sss format are accepted — this prevents unrelated
|
||||
// files with a matching base prefix from being exposed in shared log dirs.
|
||||
func (s *Service) isAllowed(name string) bool {
|
||||
if s.currentName == "" {
|
||||
return false
|
||||
}
|
||||
if name == s.currentName {
|
||||
return true
|
||||
}
|
||||
ext := filepath.Ext(s.currentName)
|
||||
base := strings.TrimSuffix(s.currentName, ext)
|
||||
if base == "" || ext == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(name, base+"-") {
|
||||
return false
|
||||
}
|
||||
rest := strings.TrimPrefix(name, base+"-")
|
||||
// rest must be "<timestamp><ext>" or "<timestamp><ext>.gz"; strip the
|
||||
// extension suffix to isolate the timestamp segment.
|
||||
switch {
|
||||
case strings.HasSuffix(rest, ext+".gz"):
|
||||
rest = strings.TrimSuffix(rest, ext+".gz")
|
||||
case strings.HasSuffix(rest, ext):
|
||||
rest = strings.TrimSuffix(rest, ext)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// Require an exact lumberjack timestamp so files like "kandev-cli.log"
|
||||
// in a shared directory cannot slip through the allow-list.
|
||||
return lumberjackTimestamp.MatchString(rest)
|
||||
}
|
||||
|
||||
// Open returns an *os.File for download, plus its size. name must be a bare
|
||||
// filename inside the log directory — any path separator, traversal segment,
|
||||
// or empty value is rejected. Caller closes the returned file.
|
||||
func (s *Service) Open(name string) (*os.File, int64, error) {
|
||||
if s.logDir == "" {
|
||||
return nil, 0, errors.New("logs: log directory not configured")
|
||||
}
|
||||
clean, err := s.safeName(name)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// Allow-list check: only the active log file and its lumberjack
|
||||
// rotations are downloadable. This prevents a shared logging.outputPath
|
||||
// directory from exposing unrelated files via the download endpoint.
|
||||
if !s.isAllowed(clean) {
|
||||
return nil, 0, errors.New("logs: not a log file")
|
||||
}
|
||||
// filepath.Base is redundant given safeName already rejects separators,
|
||||
// but it acts as a syntactic sanitiser for CodeQL's go/path-injection
|
||||
// taint tracker which does not see safeName as cutting the chain.
|
||||
full, err := s.containedPath(filepath.Base(clean))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// Reject symlinks and other non-regular files before opening so an
|
||||
// adversarial link planted in the log directory cannot escape it.
|
||||
lstat, err := os.Lstat(full)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if !lstat.Mode().IsRegular() {
|
||||
return nil, 0, errors.New("logs: not a regular file")
|
||||
}
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, fi.Size(), nil
|
||||
}
|
||||
|
||||
// containedPath joins the (already-safeName-validated) clean filename to the
|
||||
// configured log directory and verifies that the resolved absolute path is
|
||||
// still a child of the log directory. This is belt-and-braces alongside
|
||||
// safeName and gives CodeQL's taint-tracking a syntactic sanitizer it
|
||||
// recognises ("go/path-injection").
|
||||
func (s *Service) containedPath(clean string) (string, error) {
|
||||
root, err := filepath.Abs(s.logDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("logs: resolve log dir: %w", err)
|
||||
}
|
||||
joined, err := filepath.Abs(filepath.Join(root, clean))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("logs: resolve filename: %w", err)
|
||||
}
|
||||
rel, err := filepath.Rel(root, joined)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", errors.New("logs: resolved path escapes the log directory")
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
// safeName rejects any name that resolves outside the log directory.
|
||||
func (s *Service) safeName(name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", errors.New("logs: empty filename")
|
||||
}
|
||||
if strings.ContainsAny(name, `/\`) {
|
||||
return "", errors.New("logs: path separators not allowed")
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return "", errors.New("logs: invalid filename")
|
||||
}
|
||||
// filepath.Clean of a single segment without separators is the identity
|
||||
// for legitimate filenames; anything that changes under Clean is unsafe.
|
||||
if filepath.Clean(name) != name {
|
||||
return "", errors.New("logs: invalid filename")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func (s *Service) warn(msg string, fields ...zap.Field) {
|
||||
if s.log == nil {
|
||||
return
|
||||
}
|
||||
s.log.Warn(msg, fields...)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger/buffer"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T, logDir string) *Service {
|
||||
t.Helper()
|
||||
svc := NewService(logDir, "kandev.log", nil)
|
||||
// Isolate from the process-wide default ring buffer so parallel tests
|
||||
// cannot pollute Tail() fallbacks when the log file is missing.
|
||||
svc.memBuffer = buffer.New(100)
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestList_EmptyDirReturnsEmptySlice(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
|
||||
files, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if files == nil {
|
||||
t.Fatal("List() returned nil, want non-nil empty slice")
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("len(files) = %d, want 0", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_MissingDirReturnsEmpty(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "does-not-exist")
|
||||
svc := newTestService(t, dir)
|
||||
|
||||
files, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("len(files) = %d, want 0", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_EmptyLogDirConfigReturnsEmpty(t *testing.T) {
|
||||
svc := NewService("", "kandev.log", nil)
|
||||
files, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("len(files) = %d, want 0 for empty logDir", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_SortsNewestFirstAndMarksCurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
older := filepath.Join(dir, "kandev-2026-01-01T00-00-00.000.log")
|
||||
newer := filepath.Join(dir, "kandev-2026-05-01T00-00-00.000.log.gz")
|
||||
current := filepath.Join(dir, "kandev.log")
|
||||
|
||||
for _, p := range []string{older, newer, current} {
|
||||
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Force distinct mtimes: older < newer < current.
|
||||
base := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(older, base, base); err != nil {
|
||||
t.Fatalf("chtimes older: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(newer, base.Add(time.Hour), base.Add(time.Hour)); err != nil {
|
||||
t.Fatalf("chtimes newer: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(current, base.Add(90*time.Minute), base.Add(90*time.Minute)); err != nil {
|
||||
t.Fatalf("chtimes current: %v", err)
|
||||
}
|
||||
|
||||
svc := newTestService(t, dir)
|
||||
files, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(files) != 3 {
|
||||
t.Fatalf("len(files) = %d, want 3", len(files))
|
||||
}
|
||||
|
||||
// Newest first ordering.
|
||||
if files[0].Name != "kandev.log" {
|
||||
t.Errorf("files[0] = %q, want kandev.log", files[0].Name)
|
||||
}
|
||||
if files[1].Name != "kandev-2026-05-01T00-00-00.000.log.gz" {
|
||||
t.Errorf("files[1] = %q", files[1].Name)
|
||||
}
|
||||
if files[2].Name != "kandev-2026-01-01T00-00-00.000.log" {
|
||||
t.Errorf("files[2] = %q", files[2].Name)
|
||||
}
|
||||
|
||||
// Exactly one Current.
|
||||
currentCount := 0
|
||||
for _, f := range files {
|
||||
if f.Current {
|
||||
currentCount++
|
||||
if f.Name != "kandev.log" {
|
||||
t.Errorf("Current=true on wrong file: %q", f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentCount != 1 {
|
||||
t.Errorf("currentCount = %d, want 1", currentCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_IgnoresNeighboringNonLogFiles(t *testing.T) {
|
||||
// Simulate logging.outputPath pointing at a shared directory: List must
|
||||
// only surface the active log + lumberjack rotations, never unrelated
|
||||
// neighbours like ".env" or "config.yaml".
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{
|
||||
"kandev.log",
|
||||
"kandev-2026-01-01T00-00-00.000.log",
|
||||
"kandev-2026-05-01T00-00-00.000.log.gz",
|
||||
".env",
|
||||
"config.yaml",
|
||||
"secrets",
|
||||
"kandev", // no extension
|
||||
"other.log", // different base
|
||||
"kandev-suffixed.txt", // wrong extension
|
||||
"kandevish-2026-01-01.000.log", // base mismatch (no leading "kandev-")
|
||||
"kandev-cli.log", // matching prefix but not a timestamp
|
||||
"kandev-2026-01-01.log", // timestamp missing time part
|
||||
"kandev-2026-01-01T00-00-00.log", // timestamp missing milliseconds
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
svc := newTestService(t, dir)
|
||||
files, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, f := range files {
|
||||
got[f.Name] = true
|
||||
}
|
||||
want := []string{
|
||||
"kandev.log",
|
||||
"kandev-2026-01-01T00-00-00.000.log",
|
||||
"kandev-2026-05-01T00-00-00.000.log.gz",
|
||||
}
|
||||
for _, w := range want {
|
||||
if !got[w] {
|
||||
t.Errorf("List() missing %q", w)
|
||||
}
|
||||
}
|
||||
for _, ban := range []string{
|
||||
".env", "config.yaml", "secrets", "kandev", "other.log",
|
||||
"kandev-suffixed.txt", "kandevish-2026-01-01.000.log",
|
||||
"kandev-cli.log", "kandev-2026-01-01.log", "kandev-2026-01-01T00-00-00.log",
|
||||
} {
|
||||
if got[ban] {
|
||||
t.Errorf("List() leaked %q (not a log file)", ban)
|
||||
}
|
||||
}
|
||||
if len(files) != len(want) {
|
||||
t.Errorf("len(files) = %d, want %d", len(files), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_RejectsNeighboringNonLogFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("SECRET=hunter2"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
if _, _, err := svc.Open(".env"); err == nil {
|
||||
t.Fatal("Open(\".env\") returned nil, want allow-list rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_IgnoresSubdirectories(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "kandev.log"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
svc := newTestService(t, dir)
|
||||
files, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("len(files) = %d, want 1 (subdir filtered)", len(files))
|
||||
}
|
||||
if files[0].Name != "kandev.log" {
|
||||
t.Errorf("files[0] = %q", files[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_ReadsRegularFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "kandev.log"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
|
||||
f, size, err := svc.Open("kandev.log")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = f.Close() })
|
||||
if size != 5 {
|
||||
t.Errorf("size = %d, want 5", size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_RejectsSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real.log")
|
||||
if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.log")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlinks unsupported on this platform: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
|
||||
if _, _, err := svc.Open("link.log"); err == nil {
|
||||
t.Fatal("Open(symlink) returned nil, want not-a-regular-file error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_RejectsTraversalAndSeparators(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
|
||||
for _, name := range []string{
|
||||
"../etc/passwd",
|
||||
"sub/file.log",
|
||||
"\\windows\\style.log",
|
||||
".",
|
||||
"..",
|
||||
"",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := svc.Open(name); err == nil {
|
||||
t.Fatalf("Open(%q) returned nil, want error", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainedPath_ReturnedPathIsInsideLogDir(t *testing.T) {
|
||||
// containedPath's escape-rejection branch is architecturally unreachable
|
||||
// for single-segment clean names (filepath.Join of a non-empty rootAbs
|
||||
// and a bare filename without separators always stays inside the root).
|
||||
// This test exercises the happy path and verifies the postcondition:
|
||||
// the returned absolute path must be a child of the log directory.
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
|
||||
// A clean filename that would resolve outside the directory after
|
||||
// filepath.Join cannot exist by construction (a single segment without
|
||||
// separators always stays inside the directory). Document this by
|
||||
// exercising the happy path and verifying the returned path is under dir.
|
||||
got, err := svc.containedPath("kandev.log")
|
||||
if err != nil {
|
||||
t.Fatalf("containedPath: %v", err)
|
||||
}
|
||||
rootAbs, _ := filepath.Abs(dir)
|
||||
rel, err := filepath.Rel(rootAbs, got)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
t.Errorf("containedPath = %q escapes %q", got, rootAbs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Default and maximum line counts for the tail endpoint.
|
||||
const (
|
||||
defaultTailLines = 1000
|
||||
maxTailLines = 10_000
|
||||
)
|
||||
|
||||
// HandleList renders GET /api/v1/system/logs: the directory listing.
|
||||
func HandleList(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
files, err := s.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"files": files})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTail renders GET /api/v1/system/logs/tail?n=<lines>. n defaults to
|
||||
// 1000 and is capped at 10000. Invalid values silently fall back to the
|
||||
// default — the endpoint is read-only and lenient.
|
||||
func HandleTail(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n := resolveTailN(c.Query("n"))
|
||||
lines, err := s.Tail(n)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"lines": lines})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDownload streams a single log file. The :name path parameter is
|
||||
// validated to be a bare filename within the configured log directory;
|
||||
// anything else (path traversal, separators, missing file) returns 4xx.
|
||||
func HandleDownload(s *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
f, size, err := s.Open(name)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Length", strconv.FormatInt(size, 10))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, f)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTailN parses ?n=… , falling back to the default for empty or
|
||||
// unparseable values, and clamping to [0, maxTailLines].
|
||||
func resolveTailN(raw string) int {
|
||||
if raw == "" {
|
||||
return defaultTailLines
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return defaultTailLines
|
||||
}
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > maxTailLines {
|
||||
return maxTailLines
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newRouter(svc *Service) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/logs", HandleList(svc))
|
||||
r.GET("/logs/tail", HandleTail(svc))
|
||||
r.GET("/logs/:name/download", HandleDownload(svc))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHandleList_ReturnsJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "kandev.log"), []byte("hi"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/logs", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Files []FileInfo `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Files) != 1 {
|
||||
t.Fatalf("len(files) = %d, want 1", len(resp.Files))
|
||||
}
|
||||
if !resp.Files[0].Current {
|
||||
t.Error("expected kandev.log to be Current")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTail_DefaultN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
var sb strings.Builder
|
||||
for i := 1; i <= 50; i++ {
|
||||
fmt.Fprintf(&sb, "line-%d\n", i)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/logs/tail", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Lines []string `json:"lines"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Lines) != 50 {
|
||||
t.Errorf("len(lines) = %d, want 50", len(resp.Lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTail_ExplicitN(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
var sb strings.Builder
|
||||
for i := 1; i <= 100; i++ {
|
||||
fmt.Fprintf(&sb, "line-%d\n", i)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/logs/tail?n=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Lines []string `json:"lines"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Lines) != 10 {
|
||||
t.Errorf("len(lines) = %d, want 10", len(resp.Lines))
|
||||
}
|
||||
if resp.Lines[9] != "line-100" {
|
||||
t.Errorf("last = %q", resp.Lines[9])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTail_CapsAt10000(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
if err := os.WriteFile(path, []byte("a\nb\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/logs/tail?n=999999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
// Just ensure it returns 200 — the cap is internal; the small fixture
|
||||
// only has 2 lines, so the cap behavior is verified indirectly: a value
|
||||
// like 999999 must not cause the handler to reject the request.
|
||||
var resp struct {
|
||||
Lines []string `json:"lines"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Lines) != 2 {
|
||||
t.Errorf("len(lines) = %d, want 2", len(resp.Lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTail_InvalidNFallsBackToDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
if err := os.WriteFile(path, []byte("a\nb\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/logs/tail?n=notanumber", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDownload_StreamsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "kandev.log"), []byte("payload"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/logs/kandev.log/download", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
if w.Body.String() != "payload" {
|
||||
t.Errorf("body = %q, want payload", w.Body.String())
|
||||
}
|
||||
if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "kandev.log") {
|
||||
t.Errorf("Content-Disposition = %q", cd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDownload_PathTraversalRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/logs/..%2Fetc%2Fpasswd/download", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code == 200 {
|
||||
t.Fatalf("status = %d, want non-200 for traversal", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDownload_Missing404(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
r := newRouter(svc)
|
||||
|
||||
// Use a name that matches the lumberjack rotation pattern so the
|
||||
// allow-list lets it through; with the file missing on disk the
|
||||
// handler must surface os.ErrNotExist as a 404.
|
||||
req := httptest.NewRequest("GET", "/logs/kandev-2099-01-01T00-00-00.000.log/download", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != 404 {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger/buffer"
|
||||
)
|
||||
|
||||
// tailChunkSize is the read window stepped backward from the end of file.
|
||||
const tailChunkSize = 4 * 1024
|
||||
|
||||
// Tail returns the last n lines of the most recent log activity in order
|
||||
// (oldest first, newest last). When a log file is configured and non-empty
|
||||
// it is read backward in fixed-size chunks so memory usage stays bounded.
|
||||
// When no file is available (stdout-only logger, file missing, or file
|
||||
// empty) Tail falls back to the in-memory ring buffer so the UI never shows
|
||||
// "no logs" for a healthy running process.
|
||||
func (s *Service) Tail(n int) ([]string, error) {
|
||||
if n <= 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
lines, err := s.tailFromFile(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
return lines, nil
|
||||
}
|
||||
return s.tailFromBuffer(n), nil
|
||||
}
|
||||
|
||||
// tailFromFile reads the active log file from disk and returns its tail.
|
||||
// Returns an empty slice (no error) when there is no file to read.
|
||||
func (s *Service) tailFromFile(n int) ([]string, error) {
|
||||
if s.logDir == "" || s.currentName == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
path := filepath.Join(s.logDir, s.currentName)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := fi.Size()
|
||||
if size == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
return tailReader(f, size, n)
|
||||
}
|
||||
|
||||
// tailFromBuffer returns up to n formatted lines from the shared in-memory
|
||||
// ring buffer. Each entry becomes a single text line in zap's "console"
|
||||
// format: "<timestamp> <LEVEL> <caller> <message> [field=value ...]".
|
||||
func (s *Service) tailFromBuffer(n int) []string {
|
||||
if s.memBuffer == nil {
|
||||
return []string{}
|
||||
}
|
||||
entries := s.memBuffer.Snapshot()
|
||||
if len(entries) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
start := 0
|
||||
if len(entries) > n {
|
||||
start = len(entries) - n
|
||||
}
|
||||
out := make([]string, 0, len(entries)-start)
|
||||
for _, e := range entries[start:] {
|
||||
out = append(out, formatBufferEntry(e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatBufferEntry renders one ring-buffer entry as a single human-readable
|
||||
// line. Fields are appended as "key=value" pairs, JSON-encoded for non-string
|
||||
// values so the line stays single-row in the viewer.
|
||||
func formatBufferEntry(e buffer.Entry) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(e.Timestamp.Format("2006-01-02T15:04:05.000Z"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(strings.ToUpper(e.Level))
|
||||
if e.Caller != "" {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(e.Caller)
|
||||
}
|
||||
b.WriteString(" ")
|
||||
b.WriteString(e.Message)
|
||||
if len(e.Fields) > 0 {
|
||||
keys := make([]string, 0, len(e.Fields))
|
||||
for k := range e.Fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, " %s=%s", k, formatFieldValue(e.Fields[k]))
|
||||
}
|
||||
}
|
||||
if e.Stack != "" {
|
||||
b.WriteString(" stack=...")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatFieldValue(v any) string {
|
||||
switch typed := v.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case error:
|
||||
return typed.Error()
|
||||
default:
|
||||
if data, err := json.Marshal(typed); err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
// tailReader reads f backward in tailChunkSize windows until n line breaks
|
||||
// are accumulated or the start of file is reached.
|
||||
func tailReader(f io.ReaderAt, size int64, n int) ([]string, error) {
|
||||
var (
|
||||
buf []byte
|
||||
offset = size
|
||||
)
|
||||
for offset > 0 {
|
||||
readSize := int64(tailChunkSize)
|
||||
if offset < readSize {
|
||||
readSize = offset
|
||||
}
|
||||
offset -= readSize
|
||||
chunk := make([]byte, readSize)
|
||||
if _, err := f.ReadAt(chunk, offset); err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
buf = append(chunk, buf...)
|
||||
if countNewlines(buf) > n {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lastNLines(buf, n), nil
|
||||
}
|
||||
|
||||
// countNewlines returns the number of '\n' bytes in b.
|
||||
func countNewlines(b []byte) int {
|
||||
count := 0
|
||||
for _, c := range b {
|
||||
if c == '\n' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// lastNLines splits b on '\n' and returns at most the last n lines, preserving
|
||||
// order. A file that does not end with '\n' still yields its final partial
|
||||
// line as the last entry.
|
||||
func lastNLines(b []byte, n int) []string {
|
||||
if len(b) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
// Strip a single trailing newline so we don't emit a phantom empty line.
|
||||
trimmed := b
|
||||
if trimmed[len(trimmed)-1] == '\n' {
|
||||
trimmed = trimmed[:len(trimmed)-1]
|
||||
}
|
||||
// Walk from the end collecting up to n newline-delimited lines.
|
||||
out := make([]string, 0, n)
|
||||
end := len(trimmed)
|
||||
for i := len(trimmed) - 1; i >= 0 && len(out) < n; i-- {
|
||||
if trimmed[i] == '\n' {
|
||||
out = append(out, string(trimmed[i+1:end]))
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if len(out) < n && end > 0 {
|
||||
out = append(out, string(trimmed[:end]))
|
||||
}
|
||||
// out is in reverse order; flip in place.
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger/buffer"
|
||||
)
|
||||
|
||||
func TestTailFromBuffer_NilBufferReturnsEmpty(t *testing.T) {
|
||||
svc := &Service{}
|
||||
if got := svc.tailFromBuffer(5); len(got) != 0 {
|
||||
t.Fatalf("len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailFromBuffer_ReturnsLastNLines(t *testing.T) {
|
||||
rb := buffer.New(10)
|
||||
for i := 1; i <= 5; i++ {
|
||||
rb.Push(buffer.Entry{
|
||||
Timestamp: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC),
|
||||
Level: "info",
|
||||
Message: "line",
|
||||
})
|
||||
}
|
||||
svc := &Service{memBuffer: rb}
|
||||
lines := svc.tailFromBuffer(2)
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("len(lines) = %d, want 2", len(lines))
|
||||
}
|
||||
if lines[0] != "2026-01-01T00:00:04.000Z INFO line" {
|
||||
t.Errorf("lines[0] = %q", lines[0])
|
||||
}
|
||||
if lines[1] != "2026-01-01T00:00:05.000Z INFO line" {
|
||||
t.Errorf("lines[1] = %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBufferEntry_IncludesCallerFieldsAndStack(t *testing.T) {
|
||||
ts := time.Date(2026, 5, 1, 12, 30, 0, 0, time.UTC)
|
||||
line := formatBufferEntry(buffer.Entry{
|
||||
Timestamp: ts,
|
||||
Level: "warn",
|
||||
Caller: "main.go:42",
|
||||
Message: "boom",
|
||||
Fields: map[string]any{"count": 3, "name": "alpha"},
|
||||
Stack: "trace...",
|
||||
})
|
||||
if line != "2026-05-01T12:30:00.000Z WARN main.go:42 boom count=3 name=alpha stack=..." {
|
||||
t.Errorf("formatBufferEntry = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFieldValue_StringErrorAndJSON(t *testing.T) {
|
||||
if got := formatFieldValue("plain"); got != "plain" {
|
||||
t.Errorf("string = %q", got)
|
||||
}
|
||||
errVal := errors.New("boom")
|
||||
if got := formatFieldValue(errVal); got != "boom" {
|
||||
t.Errorf("error = %q", got)
|
||||
}
|
||||
if got := formatFieldValue(map[string]int{"a": 1}); got != `{"a":1}` {
|
||||
t.Errorf("json = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeLines(t *testing.T, path string, n int) {
|
||||
t.Helper()
|
||||
var sb strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
fmt.Fprintf(&sb, "line-%d\n", i)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_LargerThanRequested(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
writeLines(t, path, 2000)
|
||||
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(1000)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail: %v", err)
|
||||
}
|
||||
if len(lines) != 1000 {
|
||||
t.Fatalf("len(lines) = %d, want 1000", len(lines))
|
||||
}
|
||||
if lines[0] != "line-1001" {
|
||||
t.Errorf("first line = %q, want line-1001", lines[0])
|
||||
}
|
||||
if lines[999] != "line-2000" {
|
||||
t.Errorf("last line = %q, want line-2000", lines[999])
|
||||
}
|
||||
// Order preserved.
|
||||
for i, want := 1001, 0; want < 1000; i, want = i+1, want+1 {
|
||||
if lines[want] != fmt.Sprintf("line-%d", i) {
|
||||
t.Fatalf("lines[%d] = %q, want line-%d", want, lines[want], i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_SmallerThanRequested(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
writeLines(t, path, 500)
|
||||
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(1000)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail: %v", err)
|
||||
}
|
||||
if len(lines) != 500 {
|
||||
t.Fatalf("len(lines) = %d, want 500", len(lines))
|
||||
}
|
||||
if lines[0] != "line-1" {
|
||||
t.Errorf("first = %q, want line-1", lines[0])
|
||||
}
|
||||
if lines[499] != "line-500" {
|
||||
t.Errorf("last = %q, want line-500", lines[499])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_ZeroReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
writeLines(t, path, 100)
|
||||
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(0)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail: %v", err)
|
||||
}
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("len(lines) = %d, want 0", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_NegativeReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
writeLines(t, path, 10)
|
||||
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(-5)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail: %v", err)
|
||||
}
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("len(lines) = %d, want 0 for negative n", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_MissingFileReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(100)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail on missing file should not error, got %v", err)
|
||||
}
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("len(lines) = %d, want 0", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_EmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
if err := os.WriteFile(path, []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("write empty: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(100)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail: %v", err)
|
||||
}
|
||||
if len(lines) != 0 {
|
||||
t.Errorf("len(lines) = %d, want 0", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_FileWithoutTrailingNewline(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
// 3 lines but final has no trailing newline.
|
||||
content := "a\nb\nc"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(10)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail: %v", err)
|
||||
}
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("len(lines) = %d, want 3, got %v", len(lines), lines)
|
||||
}
|
||||
if lines[0] != "a" || lines[1] != "b" || lines[2] != "c" {
|
||||
t.Errorf("lines = %v, want [a b c]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail_LongLineExceedsChunkSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
long := strings.Repeat("x", 10_000) // larger than 4KB chunk
|
||||
content := "first\n" + long + "\nlast\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
lines, err := svc.Tail(3)
|
||||
if err != nil {
|
||||
t.Fatalf("Tail: %v", err)
|
||||
}
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("len(lines) = %d, want 3", len(lines))
|
||||
}
|
||||
if lines[0] != "first" {
|
||||
t.Errorf("lines[0] = %q, want first", lines[0])
|
||||
}
|
||||
if lines[1] != long {
|
||||
t.Errorf("lines[1] truncated: len=%d, want %d", len(lines[1]), len(long))
|
||||
}
|
||||
if lines[2] != "last" {
|
||||
t.Errorf("lines[2] = %q, want last", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_PathTraversalRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
for _, name := range []string{"../etc/passwd", "../../foo.log", "subdir/x.log", "/etc/passwd", ""} {
|
||||
_, _, err := svc.Open(name)
|
||||
if err == nil {
|
||||
t.Errorf("Open(%q) returned no error, want rejection", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_NonexistentFileError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := newTestService(t, dir)
|
||||
_, _, err := svc.Open("does-not-exist.log")
|
||||
if err == nil {
|
||||
t.Error("Open() returned no error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_OpensExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "kandev.log")
|
||||
if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
svc := newTestService(t, dir)
|
||||
f, size, err := svc.Open("kandev.log")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if size != 5 {
|
||||
t.Errorf("size = %d, want 5", size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Package system composes the System-pages backend: a top-level
|
||||
// Provide that constructs the info, disk, database, backups, logs,
|
||||
// updates, and jobs sub-services and registers the corresponding
|
||||
// HTTP route group under /api/v1/system.
|
||||
//
|
||||
// The existing internal/health package continues to own
|
||||
// GET /api/v1/system/health independently — this composer does not
|
||||
// replace it. The wiring layer (cmd/kandev) registers health alongside
|
||||
// this package.
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/system/backups"
|
||||
"github.com/kandev/kandev/internal/system/database"
|
||||
"github.com/kandev/kandev/internal/system/disk"
|
||||
"github.com/kandev/kandev/internal/system/info"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
"github.com/kandev/kandev/internal/system/logs"
|
||||
"github.com/kandev/kandev/internal/system/updates"
|
||||
)
|
||||
|
||||
// BuildInfo holds the ldflag-injected build metadata that cmd/kandev
|
||||
// passes to Provide.
|
||||
type BuildInfo struct {
|
||||
Version string
|
||||
Commit string
|
||||
BuildTime string
|
||||
}
|
||||
|
||||
// Wiring holds the runtime hooks the destructive endpoints need —
|
||||
// currently only OrchestratorShutdown, which stops in-flight agent
|
||||
// executions before factory reset wipes their backing data. The frontend
|
||||
// dialog prompts the user to relaunch after a successful reset/restore;
|
||||
// no in-process re-exec is performed.
|
||||
type Wiring struct {
|
||||
OrchestratorShutdown func()
|
||||
}
|
||||
|
||||
// Service exposes the composed system sub-services. Each field is
|
||||
// addressable so the cmd/kandev wiring can attach callbacks (Restart)
|
||||
// after construction.
|
||||
type Service struct {
|
||||
Info *info.Service
|
||||
Jobs *jobs.Tracker
|
||||
Disk *disk.Service
|
||||
Database *database.Service
|
||||
Backups *backups.Service
|
||||
Logs *logs.Service
|
||||
Updates *updates.Service
|
||||
}
|
||||
|
||||
// Provide constructs the composed Service. The HTTP routes are
|
||||
// registered separately via RegisterRoutes and the updates poller is
|
||||
// started via StartBackground, so callers can opt out (in tests, in
|
||||
// CLI subcommands).
|
||||
func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus.EventBus, build BuildInfo, wiring Wiring) *Service {
|
||||
tracker := jobs.NewTracker(eventBus, log)
|
||||
dataDir := cfg.ResolvedDataDir()
|
||||
homeDir := cfg.ResolvedHomeDir()
|
||||
|
||||
resetDirs := database.ResetDirs{
|
||||
Worktrees: filepath.Join(homeDir, "worktrees"),
|
||||
Repos: filepath.Join(homeDir, "repos"),
|
||||
Sessions: filepath.Join(homeDir, "sessions"),
|
||||
Tasks: filepath.Join(homeDir, "tasks"),
|
||||
QuickChat: filepath.Join(homeDir, "quick-chat"),
|
||||
}
|
||||
dbSvc := database.NewService(pool, dataDir, resetDirs, tracker, log)
|
||||
dbSvc.OrchestratorShutdown = wiring.OrchestratorShutdown
|
||||
|
||||
backupsSvc := backups.NewService(dataDir, pool, tracker, log)
|
||||
|
||||
logDir := log.LogDirectory()
|
||||
logFile := log.LogFilename()
|
||||
|
||||
return &Service{
|
||||
Info: info.NewService(build.Version, build.Commit, build.BuildTime),
|
||||
Jobs: tracker,
|
||||
Disk: disk.NewService(homeDir, tracker, log),
|
||||
Database: dbSvc,
|
||||
Backups: backupsSvc,
|
||||
Logs: logs.NewService(logDir, logFile, log),
|
||||
Updates: updates.NewService(pool, build.Version, nil, log),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts every system endpoint under /api/v1/system.
|
||||
// The health endpoint is mounted by internal/health.RegisterRoutes,
|
||||
// not here, to keep the existing package's surface unchanged.
|
||||
func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) {
|
||||
g := router.Group("/api/v1/system")
|
||||
|
||||
g.GET("/info", info.Handler(s.Info))
|
||||
|
||||
g.GET("/disk-usage", disk.HandleGet(s.Disk))
|
||||
g.POST("/disk-usage/refresh", disk.HandleRefresh(s.Disk))
|
||||
g.POST("/disk-usage/open", disk.HandleOpenFolder(s.Disk))
|
||||
|
||||
g.GET("/database", database.HandleStats(s.Database))
|
||||
g.POST("/database/vacuum", database.HandleVacuum(s.Database))
|
||||
g.POST("/database/optimize", database.HandleOptimize(s.Database))
|
||||
g.POST("/database/reset", database.HandleReset(s.Database))
|
||||
|
||||
backups.RegisterRoutes(g, s.Backups)
|
||||
|
||||
g.GET("/logs", logs.HandleList(s.Logs))
|
||||
g.GET("/logs/tail", logs.HandleTail(s.Logs))
|
||||
g.GET("/logs/:name/download", logs.HandleDownload(s.Logs))
|
||||
|
||||
g.GET("/updates", updates.HandleGet(s.Updates))
|
||||
g.POST("/updates/check", updates.HandleCheck(s.Updates))
|
||||
|
||||
g.GET("/jobs/:id", jobs.HandleGet(s.Jobs))
|
||||
|
||||
log.Debug("Registered System routes (HTTP)")
|
||||
}
|
||||
|
||||
// StartBackground kicks off the updates poller goroutine. The poller
|
||||
// stops when ctx is cancelled.
|
||||
func (s *Service) StartBackground(ctx context.Context) {
|
||||
if s.Updates != nil {
|
||||
s.Updates.StartPoller(ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultClientTimeout caps the GitHub poll when the caller passes a nil
|
||||
// http.Client. The 6h interval already provides plenty of slack; an
|
||||
// unbounded timeout could hang a goroutine for hours on a stalled socket.
|
||||
const defaultClientTimeout = 30 * time.Second
|
||||
|
||||
// DefaultReleaseURL is the GitHub Releases API endpoint polled by the updates
|
||||
// service. Exposed as a package-level variable so tests can point a stub
|
||||
// server at the github client without surgery on Service.
|
||||
var DefaultReleaseURL = "https://api.github.com/repos/kdlbs/kandev/releases/latest"
|
||||
|
||||
// ErrGitHubRateLimited is returned by FetchLatestRelease when GitHub responds
|
||||
// 403 with X-RateLimit-Remaining: 0. Callers should log at info (not warn)
|
||||
// and back off without burning further requests.
|
||||
var ErrGitHubRateLimited = errors.New("github api rate limited")
|
||||
|
||||
type releasePayload struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
// FetchLatestRelease performs an unauthenticated GET against the GitHub
|
||||
// Releases API and returns the tag name + release URL. On non-2xx it returns
|
||||
// an error including the HTTP status; on rate-limit responses it returns
|
||||
// ErrGitHubRateLimited so callers can branch on it.
|
||||
func FetchLatestRelease(ctx context.Context, client *http.Client) (string, string, error) {
|
||||
return FetchLatestReleaseFrom(ctx, client, DefaultReleaseURL)
|
||||
}
|
||||
|
||||
// FetchLatestReleaseFrom is like FetchLatestRelease but accepts an explicit
|
||||
// URL. Used internally and by tests pointing at a stub server.
|
||||
func FetchLatestReleaseFrom(ctx context.Context, client *http.Client, url string) (string, string, error) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultClientTimeout}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("build github request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "kandev-updates-poller")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("github request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// GitHub returns 403 with X-RateLimit-Remaining: 0 for the primary unauth
|
||||
// rate limit, and 429 for secondary/abuse-detection limits — both should
|
||||
// surface as ErrGitHubRateLimited so the poller backs off identically.
|
||||
if resp.StatusCode == http.StatusTooManyRequests ||
|
||||
(resp.StatusCode == http.StatusForbidden && resp.Header.Get("X-RateLimit-Remaining") == "0") {
|
||||
return "", "", ErrGitHubRateLimited
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", "", fmt.Errorf("github status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var payload releasePayload
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", "", fmt.Errorf("decode github response: %w", err)
|
||||
}
|
||||
if payload.TagName == "" {
|
||||
return "", "", errors.New("github response missing tag_name")
|
||||
}
|
||||
return payload.TagName, payload.HTMLURL, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchLatestRelease_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Accept"); got != "application/vnd.github+json" {
|
||||
t.Errorf("missing Accept header, got %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v1.2.4","html_url":"https://github.com/kdlbs/kandev/releases/tag/v1.2.4"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tag, url, err := FetchLatestReleaseFrom(context.Background(), srv.Client(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tag != "v1.2.4" {
|
||||
t.Errorf("tag=%q want v1.2.4", tag)
|
||||
}
|
||||
if url != "https://github.com/kdlbs/kandev/releases/tag/v1.2.4" {
|
||||
t.Errorf("url=%q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLatestRelease_404(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, _, err := FetchLatestReleaseFrom(context.Background(), srv.Client(), srv.URL)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on 404")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("expected error message to mention 404, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLatestRelease_RateLimited(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, _, err := FetchLatestReleaseFrom(context.Background(), srv.Client(), srv.URL)
|
||||
if !errors.Is(err, ErrGitHubRateLimited) {
|
||||
t.Fatalf("expected ErrGitHubRateLimited, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLatestRelease_MissingTag(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"html_url":"x"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, _, err := FetchLatestReleaseFrom(context.Background(), srv.Client(), srv.URL)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing tag_name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HandleGet returns the cached kandev_meta view of latest version. It never
|
||||
// hits GitHub. Errors from the meta read are surfaced as 500.
|
||||
func HandleGet(svc *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp, err := svc.Get()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCheck triggers a synchronous GitHub poll. When the per-process
|
||||
// limiter denies the request a 429 is returned with retry_after_seconds.
|
||||
// Other errors are surfaced as 502 since the upstream is GitHub.
|
||||
func HandleCheck(svc *Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
resp, err := svc.Check(c.Request.Context())
|
||||
if errors.Is(err, ErrRateLimited) {
|
||||
retry := svc.RetryAfter()
|
||||
seconds := int64(math.Ceil(retry.Seconds()))
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": ErrRateLimited.Error(),
|
||||
"retry_after_seconds": seconds,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func newRouter(svc *Service) *gin.Engine {
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1/system")
|
||||
api.GET("/updates", HandleGet(svc))
|
||||
api.POST("/updates/check", HandleCheck(svc))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestHandleGet_ReturnsZeroValues(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp UpdatesResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Current != "v1.0.0" {
|
||||
t.Errorf("current=%q", resp.Current)
|
||||
}
|
||||
if resp.UpdateAvailable {
|
||||
t.Errorf("expected UpdateAvailable=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheck_FirstCall200(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
srv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
||||
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
||||
svc.SetReleaseURL(srv.URL)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp UpdatesResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Latest != "v1.0.1" {
|
||||
t.Errorf("latest=%q", resp.Latest)
|
||||
}
|
||||
if !resp.UpdateAvailable {
|
||||
t.Errorf("expected UpdateAvailable=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheck_SecondCallReturns429(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
srv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
||||
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
||||
svc.SetReleaseURL(srv.URL)
|
||||
r := newRouter(svc)
|
||||
|
||||
// First call seeds the limiter.
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w1, req1)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("first call status=%d", w1.Code)
|
||||
}
|
||||
|
||||
// Second call within window is rate-limited.
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429, got %d body=%s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
RetryAfterSeconds int64 `json:"retry_after_seconds"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.RetryAfterSeconds < 1 || body.RetryAfterSeconds > 30 {
|
||||
t.Errorf("retry_after_seconds out of range: %d", body.RetryAfterSeconds)
|
||||
}
|
||||
if body.Error == "" {
|
||||
t.Errorf("expected non-empty error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCheck_GitHubFailureReturns502(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "down", http.StatusInternalServerError)
|
||||
}))
|
||||
defer failSrv.Close()
|
||||
svc := NewService(pool, "v1.0.0", failSrv.Client(), logger.Default())
|
||||
svc.SetReleaseURL(failSrv.URL)
|
||||
r := newRouter(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/updates/check", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// StartPoller launches the background polling goroutine. Calling StartPoller
|
||||
// more than once without the context being cancelled is a no-op. The goroutine
|
||||
// exits when ctx is cancelled; callers should retain the cancel func or rely
|
||||
// on application shutdown to release it.
|
||||
//
|
||||
// The poller runs an immediate probe on Start so the UI can render a value
|
||||
// shortly after backend boot, then ticks every PollInterval (6h by default).
|
||||
// All errors are logged at warn/info and never surfaced — the next tick will
|
||||
// retry. The handler never blocks on this goroutine; it serves cached values
|
||||
// from kandev_meta.
|
||||
func (s *Service) StartPoller(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.pollerStarted {
|
||||
return
|
||||
}
|
||||
loopCtx, cancel := context.WithCancel(ctx)
|
||||
s.pollerStarted = true
|
||||
s.pollerCancel = cancel
|
||||
s.pollerWg.Add(1)
|
||||
interval := s.pollerInterval
|
||||
go s.pollerLoop(loopCtx, interval)
|
||||
s.log.Debug("updates poller started", zap.Duration("interval", interval))
|
||||
}
|
||||
|
||||
// StopPoller cancels the background goroutine and waits for it to return.
|
||||
// Safe to call more than once.
|
||||
func (s *Service) StopPoller() {
|
||||
s.mu.Lock()
|
||||
if !s.pollerStarted {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
cancel := s.pollerCancel
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
s.pollerWg.Wait()
|
||||
s.mu.Lock()
|
||||
s.pollerStarted = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) pollerLoop(ctx context.Context, interval time.Duration) {
|
||||
defer s.pollerWg.Done()
|
||||
// Initial tick: run once on Start so the UI gets a value without waiting
|
||||
// the full interval after backend boot.
|
||||
s.tickOnce(ctx)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.tickOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tickOnce performs a single poll iteration. Intentionally package-internal
|
||||
// so deterministic tests can drive the loop without spinning up synctest.
|
||||
// GitHub rate-limit responses are logged at info; everything else at warn.
|
||||
func (s *Service) tickOnce(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if _, err := s.fetchAndPersist(ctx); err != nil {
|
||||
if errors.Is(err, ErrGitHubRateLimited) {
|
||||
s.log.Info("updates poller: github rate limited; will retry next tick")
|
||||
return
|
||||
}
|
||||
s.log.Warn("updates poller: fetch failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter is a single-slot rate limiter: at most one call is allowed per
|
||||
// configured window. Used by Service.Check to bound how often the manual
|
||||
// "check now" handler can hit GitHub.
|
||||
//
|
||||
// The limiter is safe for concurrent use.
|
||||
type Limiter struct {
|
||||
window time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// LimiterOption customises a Limiter on construction. Used by tests to inject
|
||||
// a deterministic clock instead of relying on wall time.
|
||||
type LimiterOption func(*Limiter)
|
||||
|
||||
// WithClock overrides the time source used by the limiter. The provided
|
||||
// function must return non-zero times.
|
||||
func WithClock(now func() time.Time) LimiterOption {
|
||||
return func(l *Limiter) {
|
||||
if now != nil {
|
||||
l.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewLimiter returns a Limiter that allows one call per window.
|
||||
func NewLimiter(window time.Duration, opts ...LimiterOption) *Limiter {
|
||||
l := &Limiter{window: window, now: time.Now}
|
||||
for _, opt := range opts {
|
||||
opt(l)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Allow records and authorises a call when the window has elapsed since the
|
||||
// previous call. On allow it returns (true, 0). When denied it returns
|
||||
// (false, retryAfter) where retryAfter is the positive duration until the next
|
||||
// allowed call.
|
||||
func (l *Limiter) Allow() (bool, time.Duration) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
if l.last.IsZero() || now.Sub(l.last) >= l.window {
|
||||
l.last = now
|
||||
return true, 0
|
||||
}
|
||||
retry := l.window - now.Sub(l.last)
|
||||
if retry <= 0 {
|
||||
retry = time.Nanosecond
|
||||
}
|
||||
return false, retry
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiter_FirstCallAllowed(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
l := NewLimiter(30*time.Second, WithClock(func() time.Time { return now }))
|
||||
ok, retry := l.Allow()
|
||||
if !ok {
|
||||
t.Fatalf("expected first call to be allowed")
|
||||
}
|
||||
if retry != 0 {
|
||||
t.Fatalf("expected retry=0 when allowed, got %v", retry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter_SecondCallWithinWindowDenied(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
l := NewLimiter(30*time.Second, WithClock(func() time.Time { return now }))
|
||||
if ok, _ := l.Allow(); !ok {
|
||||
t.Fatalf("seed call should be allowed")
|
||||
}
|
||||
// advance 5s
|
||||
now = now.Add(5 * time.Second)
|
||||
ok, retry := l.Allow()
|
||||
if ok {
|
||||
t.Fatalf("expected second call within window to be denied")
|
||||
}
|
||||
if retry <= 0 || retry > 30*time.Second {
|
||||
t.Fatalf("expected positive retry <=30s, got %v", retry)
|
||||
}
|
||||
wantRetry := 25 * time.Second
|
||||
if retry != wantRetry {
|
||||
t.Fatalf("expected retry %v, got %v", wantRetry, retry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter_AfterWindowAllowedAgain(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
l := NewLimiter(30*time.Second, WithClock(func() time.Time { return now }))
|
||||
if ok, _ := l.Allow(); !ok {
|
||||
t.Fatalf("seed call should be allowed")
|
||||
}
|
||||
now = now.Add(31 * time.Second)
|
||||
ok, retry := l.Allow()
|
||||
if !ok {
|
||||
t.Fatalf("expected call after window to be allowed")
|
||||
}
|
||||
if retry != 0 {
|
||||
t.Fatalf("expected retry=0, got %v", retry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// compareSemver returns -1, 0, or 1 when a is less than, equal to, or greater
|
||||
// than b. The leading "v" is optional on either side; pre-release suffixes are
|
||||
// compared lexicographically after the numeric segments to keep the comparator
|
||||
// simple. Invalid versions sort below valid ones; if both are invalid the
|
||||
// function falls back to plain string comparison.
|
||||
//
|
||||
// The intent is to support v1.X.Y kandev tags. We deliberately do not pull
|
||||
// golang.org/x/mod/semver — see the package README for the dependency
|
||||
// constraint. Output matches semver.Compare on well-formed v1 inputs.
|
||||
func compareSemver(a, b string) int {
|
||||
an, ap, aok := parseSemver(a)
|
||||
bn, bp, bok := parseSemver(b)
|
||||
switch {
|
||||
case !aok && !bok:
|
||||
return strings.Compare(a, b)
|
||||
case !aok:
|
||||
return -1
|
||||
case !bok:
|
||||
return 1
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if an[i] < bn[i] {
|
||||
return -1
|
||||
}
|
||||
if an[i] > bn[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
// Numeric segments equal; treat absence of pre-release as higher than any.
|
||||
switch {
|
||||
case ap == "" && bp == "":
|
||||
return 0
|
||||
case ap == "":
|
||||
return 1
|
||||
case bp == "":
|
||||
return -1
|
||||
}
|
||||
return strings.Compare(ap, bp)
|
||||
}
|
||||
|
||||
// parseSemver returns the three numeric segments, the pre-release suffix (or
|
||||
// ""), and ok=false when the input is not a recognisable major.minor.patch
|
||||
// triple. The leading "v" is optional.
|
||||
func parseSemver(s string) ([3]int, string, bool) {
|
||||
var out [3]int
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
if s == "" {
|
||||
return out, "", false
|
||||
}
|
||||
// Strip build metadata (semver: build metadata does not affect
|
||||
// precedence). The remaining string may still carry a `-pre` suffix.
|
||||
if i := strings.Index(s, "+"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
// Split off the pre-release suffix.
|
||||
pre := ""
|
||||
if i := strings.Index(s, "-"); i >= 0 {
|
||||
pre = s[i+1:]
|
||||
s = s[:i]
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return out, "", false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return out, "", false
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out, pre, true
|
||||
}
|
||||
|
||||
// isValidSemver returns true when s parses as a major.minor.patch triple.
|
||||
func isValidSemver(s string) bool {
|
||||
_, _, ok := parseSemver(s)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Package updates implements the kandev background updates poller and the
|
||||
// HTTP surface for the System -> Updates page. It polls GitHub Releases every
|
||||
// 6 hours, persists the latest tag in the kandev_meta key/value table, and
|
||||
// exposes a 30s rate-limited "check now" handler.
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
)
|
||||
|
||||
// PollInterval is the cadence at which the background goroutine polls GitHub.
|
||||
const PollInterval = 6 * time.Hour
|
||||
|
||||
// ManualCheckWindow is the minimum gap between two manual /check calls.
|
||||
const ManualCheckWindow = 30 * time.Second
|
||||
|
||||
// ErrRateLimited indicates that a manual check was denied because the
|
||||
// per-process rate limiter window has not yet elapsed.
|
||||
var ErrRateLimited = errors.New("updates check rate limited")
|
||||
|
||||
// devVersion is the sentinel used by the ldflags-injected current version
|
||||
// when no release tag was baked in (i.e. local dev builds).
|
||||
const devVersion = "dev"
|
||||
|
||||
// UpdatesResponse is the JSON shape returned by both Get() and Check().
|
||||
type UpdatesResponse struct {
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
LatestURL string `json:"latest_url"`
|
||||
LatestCheckedAt time.Time `json:"latest_checked_at"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
}
|
||||
|
||||
// releaseURL is the GitHub endpoint the service polls. Tests override it via
|
||||
// SetReleaseURL.
|
||||
//
|
||||
// Service holds the wiring needed to drive the poller and serve the two
|
||||
// HTTP endpoints.
|
||||
type Service struct {
|
||||
pool *db.Pool
|
||||
current string
|
||||
httpClient *http.Client
|
||||
log *logger.Logger
|
||||
limiter *Limiter
|
||||
|
||||
// releaseURL is the GitHub endpoint hit by Check + the poller; defaults
|
||||
// to DefaultReleaseURL and can be overridden by SetReleaseURL for tests.
|
||||
releaseURL string
|
||||
|
||||
// fetcher is the function used to retrieve the latest release. Defaults
|
||||
// to FetchLatestReleaseFrom(httpClient). Tests inject a deterministic
|
||||
// stub via SetFetcher so the synctest poller test does not block on
|
||||
// real network I/O (which sits outside the fake-time bubble and prevents
|
||||
// synctest.Wait from settling).
|
||||
fetcher Fetcher
|
||||
|
||||
// mu protects pollerStarted/cancel/wg under concurrent Start calls.
|
||||
mu sync.Mutex
|
||||
pollerStarted bool
|
||||
pollerCancel context.CancelFunc
|
||||
pollerWg sync.WaitGroup
|
||||
pollerInterval time.Duration
|
||||
}
|
||||
|
||||
// Fetcher abstracts the GitHub release call so tests can drive
|
||||
// the poller and Check() without spinning up an httptest server.
|
||||
type Fetcher func(ctx context.Context) (tag, url string, err error)
|
||||
|
||||
// NewService constructs the updates service. When httpClient is nil, a fresh
|
||||
// client carrying defaultClientTimeout is allocated — http.DefaultClient has
|
||||
// no timeout, so handing it the poller would let a stalled socket hang a
|
||||
// goroutine for hours. current is the running binary version (typically
|
||||
// injected via ldflags); the sentinel "dev" disables UpdateAvailable.
|
||||
func NewService(pool *db.Pool, current string, httpClient *http.Client, log *logger.Logger) *Service {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: defaultClientTimeout}
|
||||
}
|
||||
if log == nil {
|
||||
log = logger.Default()
|
||||
}
|
||||
s := &Service{
|
||||
pool: pool,
|
||||
current: current,
|
||||
httpClient: httpClient,
|
||||
log: log,
|
||||
limiter: NewLimiter(ManualCheckWindow),
|
||||
releaseURL: DefaultReleaseURL,
|
||||
pollerInterval: PollInterval,
|
||||
}
|
||||
s.fetcher = s.defaultFetcher
|
||||
return s
|
||||
}
|
||||
|
||||
// defaultFetcher delegates to FetchLatestReleaseFrom using the current
|
||||
// releaseURL + httpClient. Re-evaluating both on each call honours
|
||||
// SetReleaseURL after construction.
|
||||
func (s *Service) defaultFetcher(ctx context.Context) (string, string, error) {
|
||||
s.mu.Lock()
|
||||
url := s.releaseURL
|
||||
s.mu.Unlock()
|
||||
return FetchLatestReleaseFrom(ctx, s.httpClient, url)
|
||||
}
|
||||
|
||||
// SetFetcher overrides the GitHub fetch implementation. Intended for tests
|
||||
// that need deterministic behaviour inside testing/synctest.
|
||||
func (s *Service) SetFetcher(f Fetcher) {
|
||||
s.mu.Lock()
|
||||
if f == nil {
|
||||
s.fetcher = s.defaultFetcher
|
||||
} else {
|
||||
s.fetcher = f
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetReleaseURL overrides the GitHub endpoint used by the poller and Check().
|
||||
// Intended for tests that point at a httptest stub server.
|
||||
func (s *Service) SetReleaseURL(url string) {
|
||||
s.mu.Lock()
|
||||
s.releaseURL = url
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetPollInterval overrides the default 6h cadence. Must be called before
|
||||
// StartPoller. Intended for tests.
|
||||
func (s *Service) SetPollInterval(d time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.pollerInterval = d
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get returns the last-known state from kandev_meta without contacting
|
||||
// GitHub. Safe to call on every page load.
|
||||
func (s *Service) Get() (UpdatesResponse, error) {
|
||||
version, url, checkedAt, err := persistence.ReadLatestVersion(s.pool.Reader())
|
||||
if err != nil {
|
||||
return UpdatesResponse{}, err
|
||||
}
|
||||
return s.buildResponse(version, url, checkedAt), nil
|
||||
}
|
||||
|
||||
// Check forces a synchronous poll against GitHub. Rate-limited per process by
|
||||
// the 30s Limiter. On success the result is persisted and returned. On
|
||||
// failure the previously persisted values are returned unchanged.
|
||||
func (s *Service) Check(ctx context.Context) (UpdatesResponse, error) {
|
||||
if ok, _ := s.limiter.Allow(); !ok {
|
||||
return UpdatesResponse{}, ErrRateLimited
|
||||
}
|
||||
return s.fetchAndPersist(ctx)
|
||||
}
|
||||
|
||||
// RetryAfter exposes the limiter's remaining window so the HTTP handler can
|
||||
// surface a Retry-After value to clients.
|
||||
func (s *Service) RetryAfter() time.Duration {
|
||||
_, retry := s.peekLimiter()
|
||||
return retry
|
||||
}
|
||||
|
||||
func (s *Service) peekLimiter() (bool, time.Duration) {
|
||||
// peek without consuming: use a separate limiter with the same window/clock
|
||||
// is overkill; instead we synthesise a dry-run by inspecting state.
|
||||
s.limiter.mu.Lock()
|
||||
defer s.limiter.mu.Unlock()
|
||||
now := s.limiter.now()
|
||||
if s.limiter.last.IsZero() || now.Sub(s.limiter.last) >= s.limiter.window {
|
||||
return true, 0
|
||||
}
|
||||
retry := s.limiter.window - now.Sub(s.limiter.last)
|
||||
if retry <= 0 {
|
||||
retry = time.Nanosecond
|
||||
}
|
||||
return false, retry
|
||||
}
|
||||
|
||||
// fetchAndPersist hits GitHub, persists the result on success, and always
|
||||
// returns the now-current view of kandev_meta. A fetch failure preserves the
|
||||
// previously persisted state and returns the underlying error.
|
||||
func (s *Service) fetchAndPersist(ctx context.Context) (UpdatesResponse, error) {
|
||||
s.mu.Lock()
|
||||
fetch := s.fetcher
|
||||
s.mu.Unlock()
|
||||
|
||||
tag, releaseURL, err := fetch(ctx)
|
||||
if err != nil {
|
||||
// Preserve persisted state; surface the error to caller.
|
||||
current, _ := s.Get()
|
||||
return current, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if werr := persistence.WriteLatestVersion(s.pool.Writer(), tag, releaseURL, now); werr != nil {
|
||||
s.log.Warn("updates: persist latest version failed", zap.Error(werr))
|
||||
return UpdatesResponse{}, werr
|
||||
}
|
||||
return s.buildResponse(tag, releaseURL, now), nil
|
||||
}
|
||||
|
||||
func (s *Service) buildResponse(latest, url string, checkedAt time.Time) UpdatesResponse {
|
||||
return UpdatesResponse{
|
||||
Current: s.current,
|
||||
Latest: latest,
|
||||
LatestURL: url,
|
||||
LatestCheckedAt: checkedAt,
|
||||
UpdateAvailable: s.updateAvailable(latest),
|
||||
}
|
||||
}
|
||||
|
||||
// updateAvailable returns true iff latest is a valid semver strictly greater
|
||||
// than current. Current = "dev" or empty disables the flag.
|
||||
func (s *Service) updateAvailable(latest string) bool {
|
||||
if latest == "" || !isValidSemver(latest) {
|
||||
return false
|
||||
}
|
||||
if s.current == "" || s.current == devVersion {
|
||||
return false
|
||||
}
|
||||
if !isValidSemver(s.current) {
|
||||
return false
|
||||
}
|
||||
return compareSemver(latest, s.current) > 0
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
)
|
||||
|
||||
// newTestPool builds an in-memory SQLite pool with kandev_meta migrated.
|
||||
// Both Writer/Reader share the same connection because we never need WAL
|
||||
// snapshots in these tests.
|
||||
func newTestPool(t *testing.T) *db.Pool {
|
||||
t.Helper()
|
||||
conn, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
// kandev_meta needs to exist before Write/Read; reuse persistence's
|
||||
// public roundtrip on a fresh DB by calling WriteLatestVersion with
|
||||
// zero values to lazily create the table is not possible — meta.go
|
||||
// keeps ensureMetaTable internal. Create the table directly with the
|
||||
// same DDL.
|
||||
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)`); err != nil {
|
||||
t.Fatalf("create kandev_meta: %v", err)
|
||||
}
|
||||
return db.NewPool(conn, conn)
|
||||
}
|
||||
|
||||
// newStubGitHub stands up an httptest.Server that returns the given tag/url
|
||||
// payload on every request and records the call count via the returned
|
||||
// atomic counter.
|
||||
func newStubGitHub(t *testing.T, tag, url string) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var count int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&count, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"tag_name":"` + tag + `","html_url":"` + url + `"}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &count
|
||||
}
|
||||
|
||||
func TestService_Get_FreshDB_ReturnsZeroValues(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
||||
|
||||
resp, err := svc.Get()
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if resp.Current != "v1.0.0" {
|
||||
t.Errorf("current=%q", resp.Current)
|
||||
}
|
||||
if resp.Latest != "" || resp.LatestURL != "" {
|
||||
t.Errorf("expected zero latest, got %+v", resp)
|
||||
}
|
||||
if !resp.LatestCheckedAt.IsZero() {
|
||||
t.Errorf("expected zero checkedAt, got %v", resp.LatestCheckedAt)
|
||||
}
|
||||
if resp.UpdateAvailable {
|
||||
t.Errorf("expected UpdateAvailable=false on fresh DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Check_PersistsToMeta(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
srv, count := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
||||
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
||||
svc.SetReleaseURL(srv.URL)
|
||||
|
||||
resp, err := svc.Check(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if resp.Latest != "v1.0.1" || resp.LatestURL != "https://example/v1.0.1" {
|
||||
t.Errorf("unexpected resp %+v", resp)
|
||||
}
|
||||
if !resp.UpdateAvailable {
|
||||
t.Errorf("expected UpdateAvailable=true (v1.0.1 > v1.0.0)")
|
||||
}
|
||||
if atomic.LoadInt32(count) != 1 {
|
||||
t.Errorf("expected 1 github call, got %d", *count)
|
||||
}
|
||||
|
||||
// Persistence side-effect: a fresh Get should return the same values.
|
||||
got, err := svc.Get()
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Latest != "v1.0.1" {
|
||||
t.Errorf("Get latest=%q", got.Latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Check_RateLimited(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
srv, _ := newStubGitHub(t, "v1.0.1", "https://example")
|
||||
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
||||
svc.SetReleaseURL(srv.URL)
|
||||
|
||||
if _, err := svc.Check(context.Background()); err != nil {
|
||||
t.Fatalf("first Check: %v", err)
|
||||
}
|
||||
_, err := svc.Check(context.Background())
|
||||
if !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("expected ErrRateLimited, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Check_FailurePreservesPersisted(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
// First a successful stub to seed kandev_meta.
|
||||
okSrv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
||||
svc := NewService(pool, "v1.0.0", okSrv.Client(), logger.Default())
|
||||
svc.SetReleaseURL(okSrv.URL)
|
||||
if _, err := svc.Check(context.Background()); err != nil {
|
||||
t.Fatalf("seed Check: %v", err)
|
||||
}
|
||||
// Drain limiter so the next Check is allowed.
|
||||
svc.limiter = NewLimiter(ManualCheckWindow)
|
||||
|
||||
// Now swap in a failing stub.
|
||||
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer failSrv.Close()
|
||||
svc.SetReleaseURL(failSrv.URL)
|
||||
|
||||
resp, err := svc.Check(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on failing fetch")
|
||||
}
|
||||
if resp.Latest != "v1.0.1" {
|
||||
t.Errorf("expected preserved latest=v1.0.1, got %q", resp.Latest)
|
||||
}
|
||||
|
||||
// kandev_meta itself must remain unchanged.
|
||||
v, u, _, rerr := persistence.ReadLatestVersion(pool.Reader())
|
||||
if rerr != nil {
|
||||
t.Fatalf("read meta: %v", rerr)
|
||||
}
|
||||
if v != "v1.0.1" || u != "https://example/v1.0.1" {
|
||||
t.Errorf("kandev_meta mutated: v=%q u=%q", v, u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UpdateAvailable_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
current string
|
||||
latest string
|
||||
want bool
|
||||
}{
|
||||
{"newer", "v1.0.0", "v1.0.1", true},
|
||||
{"equal", "v1.0.1", "v1.0.1", false},
|
||||
{"older", "v1.0.1", "v1.0.0", false},
|
||||
{"dev_current", "dev", "v1.0.1", false},
|
||||
{"empty_current", "", "v1.0.1", false},
|
||||
{"invalid_latest", "v1.0.0", "not-a-version", false},
|
||||
{"no_prefix", "1.0.0", "1.0.1", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := NewService(nil, tc.current, nil, logger.Default())
|
||||
if got := svc.updateAvailable(tc.latest); got != tc.want {
|
||||
t.Errorf("updateAvailable(%q,%q)=%v want %v", tc.current, tc.latest, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_StartPoller_TicksEvery6h(t *testing.T) {
|
||||
// We use an injected Fetcher rather than an httptest.Server because
|
||||
// real HTTP I/O sits outside the synctest fake-time bubble and would
|
||||
// prevent synctest.Wait from settling on parked goroutines.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
var count int32
|
||||
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
||||
svc.SetFetcher(func(_ context.Context) (string, string, error) {
|
||||
atomic.AddInt32(&count, 1)
|
||||
return "v1.0.1", "https://example/v1.0.1", nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
svc.StartPoller(ctx)
|
||||
defer svc.StopPoller()
|
||||
|
||||
// Initial probe runs immediately after Start.
|
||||
synctest.Wait()
|
||||
if got := atomic.LoadInt32(&count); got != 1 {
|
||||
t.Fatalf("expected 1 initial call, got %d", got)
|
||||
}
|
||||
|
||||
// Advance fake time by one full interval; one extra tick should fire.
|
||||
time.Sleep(PollInterval)
|
||||
synctest.Wait()
|
||||
if got := atomic.LoadInt32(&count); got != 2 {
|
||||
t.Fatalf("expected 2 calls after 6h, got %d", got)
|
||||
}
|
||||
|
||||
// And one more interval = one more call.
|
||||
time.Sleep(PollInterval)
|
||||
synctest.Wait()
|
||||
if got := atomic.LoadInt32(&count); got != 3 {
|
||||
t.Fatalf("expected 3 calls after 12h, got %d", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_tickOnce_PersistsAndSurvivesFailures(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
srv, count := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
||||
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
||||
svc.SetReleaseURL(srv.URL)
|
||||
|
||||
svc.tickOnce(context.Background())
|
||||
if atomic.LoadInt32(count) != 1 {
|
||||
t.Fatalf("expected 1 github call, got %d", *count)
|
||||
}
|
||||
v, _, _, err := persistence.ReadLatestVersion(pool.Reader())
|
||||
if err != nil {
|
||||
t.Fatalf("ReadLatestVersion: %v", err)
|
||||
}
|
||||
if v != "v1.0.1" {
|
||||
t.Errorf("expected persisted v1.0.1, got %q", v)
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,11 @@ const (
|
||||
ActionUserSettingsUpdate = "user.settings.update"
|
||||
ActionUserSettingsUpdated = "user.settings.updated"
|
||||
|
||||
// System maintenance jobs (VACUUM, factory reset, snapshot create/restore,
|
||||
// disk walk). Broadcast to all connected clients so the System pages can
|
||||
// render progress.
|
||||
ActionSystemJobUpdate = "system.job.update"
|
||||
|
||||
// VS Code server actions
|
||||
ActionVscodeStart = "vscode.start" // Start code-server for a session
|
||||
ActionVscodeStop = "vscode.stop" // Stop code-server for a session
|
||||
|
||||
@@ -1,21 +1,5 @@
|
||||
import { ChangelogSettings } from "@/components/settings/changelog-settings";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { fetchUserSettings } from "@/lib/api";
|
||||
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ChangelogPage() {
|
||||
let initialState = {};
|
||||
try {
|
||||
const response = await fetchUserSettings({ cache: "no-store" });
|
||||
const mapped = mapUserSettingsResponse(response);
|
||||
initialState = { userSettings: mapped.loaded ? mapped : undefined };
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<ChangelogSettings />
|
||||
</StateProvider>
|
||||
);
|
||||
export default function ChangelogPage() {
|
||||
redirect("/settings/system/updates");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { AboutCard } from "@/components/settings/system/about-card";
|
||||
import { fetchSystemInfo } from "@/lib/api/domains/system-api";
|
||||
|
||||
export default async function SystemAboutPage() {
|
||||
let initialState: Record<string, unknown> = {};
|
||||
try {
|
||||
const info = await fetchSystemInfo({ cache: "no-store" }).catch(() => null);
|
||||
if (info) {
|
||||
initialState = { system: { info } };
|
||||
}
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<SystemPageShell title="About" description="Version, build metadata, and links.">
|
||||
<AboutCard />
|
||||
</SystemPageShell>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { BackupsTable } from "@/components/settings/system/backups-table";
|
||||
import { fetchBackups } from "@/lib/api/domains/system-api";
|
||||
|
||||
export default async function SystemBackupsPage() {
|
||||
let initialState: Record<string, unknown> = {};
|
||||
try {
|
||||
const backups = await fetchBackups({ cache: "no-store" }).catch(() => null);
|
||||
if (backups) {
|
||||
initialState = {
|
||||
system: {
|
||||
backups: { items: backups, loaded: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<SystemPageShell
|
||||
title="Backups"
|
||||
description="VACUUM INTO snapshots stored under <data-dir>/backups/."
|
||||
>
|
||||
<BackupsTable />
|
||||
</SystemPageShell>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { DatabaseStatsCard } from "@/components/settings/system/database-stats-card";
|
||||
import { fetchDatabaseStats } from "@/lib/api/domains/system-api";
|
||||
|
||||
export default async function SystemDatabasePage() {
|
||||
let initialState: Record<string, unknown> = {};
|
||||
try {
|
||||
const database = await fetchDatabaseStats({ cache: "no-store" }).catch(() => null);
|
||||
initialState = { system: database ? { database } : undefined };
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<SystemPageShell
|
||||
title="Database"
|
||||
description="SQLite path and size, plus VACUUM, optimize, and factory reset."
|
||||
>
|
||||
<DatabaseStatsCard />
|
||||
</SystemPageShell>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { LicensesList } from "@/components/settings/system/licenses-list";
|
||||
import licenses from "@/generated/licenses.json";
|
||||
import type { LicenseEntry } from "@/lib/types/system";
|
||||
|
||||
export default function SystemLicensesPage() {
|
||||
const entries = licenses as LicenseEntry[];
|
||||
|
||||
return (
|
||||
<StateProvider initialState={{}}>
|
||||
<SystemPageShell
|
||||
title="Licenses"
|
||||
description="Open-source licenses for every npm and Go dependency shipped with kandev."
|
||||
>
|
||||
<LicensesList entries={entries} />
|
||||
</SystemPageShell>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { LogViewer } from "@/components/settings/system/log-viewer";
|
||||
import { fetchLogFiles } from "@/lib/api/domains/system-api";
|
||||
|
||||
export default async function SystemLogsPage() {
|
||||
let initialState: Record<string, unknown> = {};
|
||||
try {
|
||||
const files = await fetchLogFiles({ cache: "no-store" }).catch(() => null);
|
||||
if (files) {
|
||||
initialState = {
|
||||
system: {
|
||||
logs: { files, tail: [], tailLoaded: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<SystemPageShell
|
||||
title="Logs"
|
||||
description="Tail of the active backend log file plus rotated downloads."
|
||||
>
|
||||
<LogViewer />
|
||||
</SystemPageShell>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { HealthIssuesCard } from "@/components/settings/system/health-issues-card";
|
||||
import { DiskUsageCard } from "@/components/settings/system/disk-usage-card";
|
||||
import { UIStateCard } from "@/components/settings/system/ui-state-card";
|
||||
import { VersionSummaryCard } from "@/components/settings/system/version-summary-card";
|
||||
import { fetchSystemHealth } from "@/lib/api/domains/health-api";
|
||||
import { fetchUpdates } from "@/lib/api/domains/system-api";
|
||||
|
||||
export default async function SystemStatusPage() {
|
||||
let initialState: Record<string, unknown> = {};
|
||||
try {
|
||||
const [health, updates] = await Promise.all([
|
||||
fetchSystemHealth({ cache: "no-store" }).catch(() => null),
|
||||
fetchUpdates({ cache: "no-store" }).catch(() => null),
|
||||
]);
|
||||
initialState = {
|
||||
systemHealth: health
|
||||
? {
|
||||
healthy: health.healthy,
|
||||
issues: health.issues,
|
||||
checks: health.checks ?? [],
|
||||
loaded: true,
|
||||
loading: false,
|
||||
}
|
||||
: undefined,
|
||||
system: updates ? { updates } : undefined,
|
||||
};
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<SystemPageShell title="Status" description="Health checks, disk usage, and version summary.">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<HealthIssuesCard />
|
||||
<VersionSummaryCard />
|
||||
</div>
|
||||
<DiskUsageCard />
|
||||
<UIStateCard />
|
||||
</SystemPageShell>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Suspense } from "react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { SystemPageShell } from "@/components/settings/system/system-page-shell";
|
||||
import { UpdatesCard } from "@/components/settings/system/updates-card";
|
||||
import { ChangelogList } from "@/components/settings/changelog-list";
|
||||
import { fetchUpdates } from "@/lib/api/domains/system-api";
|
||||
|
||||
export default async function SystemUpdatesPage() {
|
||||
let initialState: Record<string, unknown> = {};
|
||||
try {
|
||||
const updates = await fetchUpdates({ cache: "no-store" }).catch(() => null);
|
||||
initialState = { system: updates ? { updates } : undefined };
|
||||
} catch {
|
||||
initialState = {};
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<SystemPageShell
|
||||
title="Updates"
|
||||
description="Current vs latest release plus the full kandev changelog."
|
||||
>
|
||||
<UpdatesCard />
|
||||
{/*
|
||||
* ChangelogList reads ?page=N via useSearchParams, which forces the
|
||||
* client subtree to deopt out of static prerender. The Suspense
|
||||
* boundary lets the rest of the page prerender while the list
|
||||
* hydrates on the client.
|
||||
*/}
|
||||
<Suspense fallback={null}>
|
||||
<ChangelogList />
|
||||
</Suspense>
|
||||
</SystemPageShell>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { cn } from "@kandev/ui/lib/utils";
|
||||
|
||||
type PageTopbarProps = {
|
||||
/** Page title shown in the breadcrumb */
|
||||
/** Page title shown as the rightmost (current) breadcrumb */
|
||||
title: string;
|
||||
/** Optional subtitle shown to the right of the title */
|
||||
subtitle?: string;
|
||||
@@ -23,6 +23,12 @@ type PageTopbarProps = {
|
||||
backHref?: string;
|
||||
/** Label for the parent breadcrumb (default: "Kandev") */
|
||||
backLabel?: string;
|
||||
/**
|
||||
* Optional middle crumb inserted between the back link and the title — use
|
||||
* for nested sections (e.g. Home > Settings > Prompts). When omitted the
|
||||
* breadcrumb is two segments wide.
|
||||
*/
|
||||
parent?: { label: string; href: string };
|
||||
/** Optional content rendered before the breadcrumb */
|
||||
leading?: ReactNode;
|
||||
/** Optional content rendered at the visual center of the topbar */
|
||||
@@ -37,6 +43,84 @@ type PageTopbarProps = {
|
||||
actionsClassName?: string;
|
||||
};
|
||||
|
||||
function BackLink({ href, label }: { href: string; label: string }) {
|
||||
if (href === "/") {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<IconHome className="h-4 w-4" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<IconArrowLeft className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function TopbarBreadcrumb({
|
||||
backHref,
|
||||
backLabel,
|
||||
parent,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
}: {
|
||||
backHref: string;
|
||||
backLabel: string;
|
||||
parent: { label: string; href: string } | undefined;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Breadcrumb className="relative z-10 min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap text-sm">
|
||||
<BreadcrumbItem className="shrink-0">
|
||||
<BreadcrumbLink asChild>
|
||||
<BackLink href={backHref} label={backLabel} />
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="shrink-0" />
|
||||
{parent && (
|
||||
<>
|
||||
<BreadcrumbItem className="shrink-0">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={parent.href}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{parent.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="flex min-w-0 items-center gap-2">
|
||||
{icon}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{subtitle && (
|
||||
<>
|
||||
<span className="hidden text-muted-foreground/50 sm:inline">·</span>
|
||||
<span className="hidden truncate text-xs text-muted-foreground sm:inline">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
||||
export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function PageTopbar(
|
||||
{
|
||||
title,
|
||||
@@ -44,6 +128,7 @@ export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function Page
|
||||
icon,
|
||||
backHref = "/",
|
||||
backLabel = "Kandev",
|
||||
parent,
|
||||
leading,
|
||||
center,
|
||||
leftActions,
|
||||
@@ -66,43 +151,14 @@ export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function Page
|
||||
<span className="truncate text-[15px] font-semibold leading-none">{backLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Breadcrumb className="relative z-10 min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap text-sm">
|
||||
<BreadcrumbItem className="shrink-0">
|
||||
<BreadcrumbLink asChild>
|
||||
{backHref === "/" ? (
|
||||
<Link
|
||||
href={backHref}
|
||||
aria-label={backLabel}
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<IconHome className="h-4 w-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={backHref} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<IconArrowLeft className="h-3.5 w-3.5" />
|
||||
{backLabel}
|
||||
</Link>
|
||||
)}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="shrink-0" />
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="flex min-w-0 items-center gap-2">
|
||||
{icon}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{subtitle && (
|
||||
<>
|
||||
<span className="hidden text-muted-foreground/50 sm:inline">·</span>
|
||||
<span className="hidden truncate text-xs text-muted-foreground sm:inline">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<TopbarBreadcrumb
|
||||
backHref={backHref}
|
||||
backLabel={backLabel}
|
||||
parent={parent}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
icon={icon}
|
||||
/>
|
||||
)}
|
||||
{leftActions && (
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-1 [&:empty]:hidden">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
@@ -19,6 +20,15 @@ import { getChangelog, type ChangelogEntry } from "@/lib/changelog";
|
||||
import { getReleaseUrl } from "@/lib/release-notes";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const PAGE_PARAM = "page";
|
||||
|
||||
function parsePageParam(raw: string | null, totalPages: number): number {
|
||||
if (!raw) return 1;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 1) return 1;
|
||||
if (parsed > totalPages) return totalPages;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
const releaseUrl = getReleaseUrl(entry.version);
|
||||
@@ -73,9 +83,28 @@ function buildPageNumbers(currentPage: number, totalPages: number): (number | "e
|
||||
|
||||
export function ChangelogList() {
|
||||
const changelog = getChangelog();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(changelog.length / PAGE_SIZE));
|
||||
const currentPage = parsePageParam(searchParams.get(PAGE_PARAM), totalPages);
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: number) => {
|
||||
const clamped = Math.min(Math.max(1, next), totalPages);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (clamped === 1) {
|
||||
params.delete(PAGE_PARAM);
|
||||
} else {
|
||||
params.set(PAGE_PARAM, String(clamped));
|
||||
}
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
},
|
||||
[pathname, router, searchParams, totalPages],
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(changelog.length / PAGE_SIZE);
|
||||
const pageEntries = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return changelog.slice(start, start + PAGE_SIZE);
|
||||
@@ -93,11 +122,11 @@ export function ChangelogList() {
|
||||
<ChangelogEntryCard key={entry.version} entry={entry} />
|
||||
))}
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<Pagination data-testid="changelog-pagination">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
/>
|
||||
</PaginationItem>
|
||||
@@ -110,8 +139,9 @@ export function ChangelogList() {
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
onClick={() => setPage(page)}
|
||||
className="cursor-pointer"
|
||||
data-testid={`changelog-page-${page}`}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
@@ -120,7 +150,7 @@ export function ChangelogList() {
|
||||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
className={
|
||||
currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
|
||||
@@ -16,13 +16,20 @@ import {
|
||||
IconBrandGitlab,
|
||||
IconBrandSlack,
|
||||
IconHexagon,
|
||||
IconSparkles,
|
||||
IconWand,
|
||||
IconGitBranch,
|
||||
IconArrowsShuffle,
|
||||
IconTicket,
|
||||
IconPlugConnected,
|
||||
IconBolt,
|
||||
IconActivity,
|
||||
IconDatabase,
|
||||
IconArchive,
|
||||
IconFileText,
|
||||
IconRefresh,
|
||||
IconScale,
|
||||
IconInfoCircle,
|
||||
IconServerCog,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -31,6 +38,7 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuSub,
|
||||
@@ -43,6 +51,7 @@ import { ScrollArea } from "@kandev/ui/scroll-area";
|
||||
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
|
||||
import { useSystemBadgeCount } from "@/hooks/domains/system/use-system-badge-count";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { getExecutorIcon } from "@/lib/executor-icons";
|
||||
import { getCapabilityWarning } from "@/lib/capability-warning";
|
||||
@@ -153,6 +162,53 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
|
||||
);
|
||||
}
|
||||
|
||||
function SystemSidebarSection({ pathname }: { pathname: string }) {
|
||||
const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
|
||||
{ href: "/settings/system/status", label: "Status", Icon: IconActivity },
|
||||
{ href: "/settings/system/database", label: "Database", Icon: IconDatabase },
|
||||
{ href: "/settings/system/backups", label: "Backups", Icon: IconArchive },
|
||||
{ href: "/settings/system/logs", label: "Logs", Icon: IconFileText },
|
||||
{ href: "/settings/system/updates", label: "Updates", Icon: IconRefresh },
|
||||
{ href: "/settings/system/about", label: "About", Icon: IconInfoCircle },
|
||||
{ href: "/settings/system/licenses", label: "Licenses", Icon: IconScale },
|
||||
];
|
||||
const isSystem = pathname.startsWith("/settings/system");
|
||||
// Sum of non-info health issues + 1 when an update is available. Surfaced
|
||||
// on the group row and the Status child entry so the user gets a hint
|
||||
// without having to open System.
|
||||
const badgeCount = useSystemBadgeCount();
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="System" isActive={isSystem}>
|
||||
<Link href="/settings/system/status">
|
||||
<IconServerCog className="h-4 w-4" />
|
||||
<span>System</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{badgeCount > 0 && (
|
||||
<SidebarMenuBadge data-testid="system-sidebar-badge">{badgeCount}</SidebarMenuBadge>
|
||||
)}
|
||||
<SidebarMenuSub className="ml-3 mt-1">
|
||||
{items.map(({ href, label, Icon }) => (
|
||||
<SidebarMenuSubItem key={href}>
|
||||
<SidebarMenuSubButton asChild size="sm" isActive={pathname === href}>
|
||||
<Link href={href}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
{href === "/settings/system/status" && badgeCount > 0 && (
|
||||
<SidebarMenuBadge data-testid="system-sidebar-status-badge">
|
||||
{badgeCount}
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationsSidebarSection({ pathname }: { pathname: string }) {
|
||||
const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
|
||||
{ href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub },
|
||||
@@ -378,15 +434,8 @@ export function SettingsAppSidebar() {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Changelog */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === "/settings/changelog"}>
|
||||
<Link href="/settings/changelog">
|
||||
<IconSparkles className="h-4 w-4" />
|
||||
<span>Changelog</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{/* System */}
|
||||
<SystemSidebarSection pathname={pathname} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -6,13 +6,83 @@ import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { SettingsAppSidebar } from "@/components/settings/settings-app-sidebar";
|
||||
|
||||
// Brand/initialism overrides so the derived label matches how the rest of the
|
||||
// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not
|
||||
// listed here falls back to dash-aware title-casing of the path segment.
|
||||
const SEGMENT_LABEL_OVERRIDES: Record<string, string> = {
|
||||
github: "GitHub",
|
||||
jira: "Jira",
|
||||
linear: "Linear",
|
||||
slack: "Slack",
|
||||
mcp: "MCP",
|
||||
ui: "UI",
|
||||
vscode: "VS Code",
|
||||
};
|
||||
|
||||
function titleCase(segment: string): string {
|
||||
if (SEGMENT_LABEL_OVERRIDES[segment]) return SEGMENT_LABEL_OVERRIDES[segment];
|
||||
return segment
|
||||
.split("-")
|
||||
.map((p) => (p.length === 0 ? p : p[0].toUpperCase() + p.slice(1)))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// Derive the human-readable label for the current /settings sub-page from the
|
||||
// deepest non-id path segment. /settings → null (the topbar still shows
|
||||
// "Settings" as the page itself). UUID-looking segments are skipped so e.g.
|
||||
// /settings/workspace/<uuid> resolves to "Workspace" not the raw id.
|
||||
function deriveCurrentPageLabel(pathname: string): string | null {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length <= 1) return null; // just /settings
|
||||
for (let i = segments.length - 1; i >= 1; i--) {
|
||||
const seg = segments[i];
|
||||
if (/^[0-9a-f-]{8,}$/i.test(seg)) continue; // skip ids
|
||||
return titleCase(seg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SettingsLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents";
|
||||
const backHref = isAgentDetail ? "/settings/agents" : "/";
|
||||
const backLabel = isAgentDetail ? "Agents" : "Kandev";
|
||||
const title = isAgentDetail ? "Agent" : "Settings";
|
||||
|
||||
if (isAgentDetail) {
|
||||
return (
|
||||
<SettingsShell
|
||||
title="Agent"
|
||||
backHref="/settings/agents"
|
||||
backLabel="Agents"
|
||||
parent={undefined}
|
||||
>
|
||||
{children}
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
const pageLabel = deriveCurrentPageLabel(pathname);
|
||||
const title = pageLabel ?? "Settings";
|
||||
const parent = pageLabel ? { label: "Settings", href: "/settings" } : undefined;
|
||||
|
||||
return (
|
||||
<SettingsShell title={title} backHref="/" backLabel="Kandev" parent={parent}>
|
||||
{children}
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsShell({
|
||||
title,
|
||||
backHref,
|
||||
backLabel,
|
||||
parent,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
backHref: string;
|
||||
backLabel: string;
|
||||
parent: { label: string; href: string } | undefined;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
@@ -22,6 +92,7 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
|
||||
title={title}
|
||||
backHref={backHref}
|
||||
backLabel={backLabel}
|
||||
parent={parent}
|
||||
className="h-16 border-b-0"
|
||||
leading={<SidebarTrigger size="lg" className="md:hidden h-10 w-10 cursor-pointer" />}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconInfoCircle, IconExternalLink } from "@tabler/icons-react";
|
||||
import { useSystemInfo } from "@/hooks/domains/system/use-system-info";
|
||||
|
||||
function Row({ label, value, testid }: { label: string; value: string; testid: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1.5 border-b last:border-b-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-mono break-all text-right" data-testid={testid}>
|
||||
{value || "-"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutCard() {
|
||||
const { info, isLoading } = useSystemInfo();
|
||||
|
||||
if (!info && isLoading) {
|
||||
return (
|
||||
<Card data-testid="system-about-card">
|
||||
<CardContent className="py-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-testid="system-about-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconInfoCircle className="h-4 w-4" /> About kandev
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border px-3 py-2">
|
||||
<Row label="Version" value={info?.version ?? "-"} testid="system-about-version" />
|
||||
<Row label="Commit" value={info?.commit ?? "-"} testid="system-about-commit" />
|
||||
<Row
|
||||
label="Build time"
|
||||
value={info?.build_time ?? "-"}
|
||||
testid="system-about-build-time"
|
||||
/>
|
||||
<Row
|
||||
label="Go version"
|
||||
value={info?.go_version ?? "-"}
|
||||
testid="system-about-go-version"
|
||||
/>
|
||||
<Row label="OS" value={info?.os ?? "-"} testid="system-about-os" />
|
||||
<Row label="Arch" value={info?.arch ?? "-"} testid="system-about-arch" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="cursor-pointer">
|
||||
<a
|
||||
href="https://github.com/kdlbs/kandev"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="system-about-github-link"
|
||||
>
|
||||
GitHub <IconExternalLink className="h-3.5 w-3.5 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="cursor-pointer">
|
||||
<a
|
||||
href="https://github.com/kdlbs/kandev#readme"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="system-about-docs-link"
|
||||
>
|
||||
Documentation <IconExternalLink className="h-3.5 w-3.5 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm" className="cursor-pointer">
|
||||
<a
|
||||
href="https://github.com/kdlbs/kandev/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-testid="system-about-issue-link"
|
||||
>
|
||||
Report an issue <IconExternalLink className="h-3.5 w-3.5 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconAlertTriangle, IconCheck } from "@tabler/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ActionFeedbackState } from "@/hooks/use-action-feedback";
|
||||
|
||||
type Props = {
|
||||
state: ActionFeedbackState;
|
||||
/** Icon to show in the idle state (button's resting icon). */
|
||||
idleIcon: ReactNode;
|
||||
/** Label to show in the idle state (e.g. "Run vacuum"). */
|
||||
idleLabel: string;
|
||||
/** Label to show while pending. Defaults to "Running...". */
|
||||
pendingLabel?: string;
|
||||
/** Label to show on success. Defaults to "Done". */
|
||||
successLabel?: string;
|
||||
/** Label to show on error. Defaults to "Failed". */
|
||||
errorLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the icon + label inside an action button, switching by feedback
|
||||
* state. Pair with useActionFeedback to make fast operations visible to the
|
||||
* user (the spinner is held for a minimum perceptible window, then success
|
||||
* tick stays for ~1.8s before reverting to idle).
|
||||
*/
|
||||
export function ActionButtonContent({
|
||||
state,
|
||||
idleIcon,
|
||||
idleLabel,
|
||||
pendingLabel = "Running...",
|
||||
successLabel = "Done",
|
||||
errorLabel = "Failed",
|
||||
}: Props) {
|
||||
if (state === "pending") {
|
||||
return (
|
||||
<>
|
||||
<Spinner className="size-3.5 mr-1" />
|
||||
{pendingLabel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (state === "success") {
|
||||
return (
|
||||
<>
|
||||
<IconCheck className="h-3.5 w-3.5 mr-1 text-emerald-500" />
|
||||
{successLabel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (state === "error") {
|
||||
return (
|
||||
<>
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 mr-1 text-red-500" />
|
||||
{errorLabel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{idleIcon}
|
||||
{idleLabel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
|
||||
import { IconDownload, IconTrash, IconArchive, IconRotateClockwise } from "@tabler/icons-react";
|
||||
import { useBackups } from "@/hooks/domains/system/use-backups";
|
||||
import { buildBackupDownloadUrl, createBackup, deleteBackup } from "@/lib/api/domains/system-api";
|
||||
import { formatBytes } from "@/lib/utils/format-bytes";
|
||||
import { JobProgressIndicator } from "./job-progress-indicator";
|
||||
import { RestoreDialog } from "./restore-dialog";
|
||||
import type { SnapshotInfo } from "@/lib/types/system";
|
||||
|
||||
const BACKUP_CREATE_TIMEOUT_MS = 15_000;
|
||||
const BACKUP_CREATE_POLL_MS = 250;
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function BackupRow({
|
||||
row,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: {
|
||||
row: SnapshotInfo;
|
||||
onRestore: (name: string) => void;
|
||||
onDelete: (name: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<TableRow data-testid="system-backups-row" data-name={row.name}>
|
||||
<TableCell className="font-mono text-xs break-all" data-testid="system-backups-name">
|
||||
{row.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={row.kind === "manual" ? "default" : "secondary"} className="text-[10px]">
|
||||
{row.kind}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatBytes(row.size_bytes)}</TableCell>
|
||||
<TableCell className="text-xs">{formatTimestamp(row.mtime)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="cursor-pointer"
|
||||
data-testid="system-backups-download"
|
||||
>
|
||||
<a href={buildBackupDownloadUrl(row.name)} download>
|
||||
<IconDownload className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onRestore(row.name)}
|
||||
data-testid="system-backups-restore"
|
||||
>
|
||||
<IconRotateClockwise className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="cursor-pointer text-destructive"
|
||||
onClick={() => onDelete(row.name)}
|
||||
data-testid="system-backups-delete"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function BackupsList({
|
||||
items,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}: {
|
||||
items: SnapshotInfo[];
|
||||
onRestore: (name: string) => void;
|
||||
onDelete: (name: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Table data-testid="system-backups-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead className="text-right">Size</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((row) => (
|
||||
<BackupRow key={row.name} row={row} onRestore={onRestore} onDelete={onDelete} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForCreatedBackup(
|
||||
reload: () => Promise<SnapshotInfo[]>,
|
||||
previousNames: Set<string>,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + BACKUP_CREATE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const items = await reload();
|
||||
if (items.some((item) => item.kind === "manual" && !previousNames.has(item.name))) {
|
||||
return;
|
||||
}
|
||||
await sleep(BACKUP_CREATE_POLL_MS);
|
||||
}
|
||||
throw new Error("Create snapshot did not finish within 15s");
|
||||
}
|
||||
|
||||
export function BackupsTable() {
|
||||
const { backups, loaded, isLoading, reload } = useBackups();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [restoreName, setRestoreName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onCreate = async () => {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
const previousNames = new Set(backups.map((backup) => backup.name));
|
||||
try {
|
||||
await createBackup();
|
||||
await waitForCreatedBackup(reload, previousNames);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Create snapshot failed");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await deleteBackup(name);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const items = backups;
|
||||
|
||||
return (
|
||||
<Card data-testid="system-backups-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconArchive className="h-4 w-4" /> Backups
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={creating}
|
||||
onClick={() => void onCreate()}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-backups-create"
|
||||
>
|
||||
{creating ? "Creating..." : "Create snapshot"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground" data-testid="system-backups-help">
|
||||
A backup is a full copy of Kandev's database (tasks, sessions, settings). Kandev
|
||||
automatically creates one before every version upgrade so you can roll back if something
|
||||
goes wrong. You can also create one manually before risky operations like factory reset.
|
||||
Only the latest 2 backups are kept on disk.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" data-testid="system-backups-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!loaded && isLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading backups...
|
||||
</div>
|
||||
)}
|
||||
{loaded && items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground" data-testid="system-backups-empty">
|
||||
No backups yet. Snapshots created automatically on version upgrade or manually via the
|
||||
button above will appear here.
|
||||
</p>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<BackupsList
|
||||
items={items}
|
||||
onRestore={(n) => setRestoreName(n)}
|
||||
onDelete={(n) => void onDelete(n)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<JobProgressIndicator kind="backup-create" />
|
||||
<JobProgressIndicator kind="restore" />
|
||||
</div>
|
||||
|
||||
<RestoreDialog
|
||||
open={restoreName !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRestoreName(null);
|
||||
}}
|
||||
name={restoreName ?? ""}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import {
|
||||
IconDatabase,
|
||||
IconBolt,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useDatabaseStats } from "@/hooks/domains/system/use-database-stats";
|
||||
import { optimizeDatabase, vacuumDatabase } from "@/lib/api/domains/system-api";
|
||||
import { formatBytes } from "@/lib/utils/format-bytes";
|
||||
import { useActionFeedback, type ActionFeedbackState } from "@/hooks/use-action-feedback";
|
||||
import { ActionButtonContent } from "./action-button-content";
|
||||
import { JobProgressIndicator } from "./job-progress-indicator";
|
||||
import { FactoryResetDialog } from "./factory-reset-dialog";
|
||||
|
||||
function formatTimestamp(iso: string | null | undefined): string {
|
||||
if (!iso) return "Never";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
const WAL_HELP =
|
||||
"Write-Ahead Log. SQLite writes new changes to this companion file first and merges them into the main database over time. A non-zero WAL size is normal - it temporarily grows under load and shrinks back during checkpoints. Running vacuum will reset it.";
|
||||
|
||||
type Row = { label: string; value: string; testid: string; info?: string };
|
||||
|
||||
function StatRow({ label, value, testid, info }: Row) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1.5 border-b last:border-b-0">
|
||||
<span className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
{label}
|
||||
{info && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`What is ${label}?`}
|
||||
className="cursor-pointer text-muted-foreground/70 hover:text-foreground transition-colors"
|
||||
data-testid={`${testid}-info`}
|
||||
>
|
||||
<IconInfoCircle className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{info}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono break-all text-right" data-testid={testid}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DBStats = {
|
||||
path: string;
|
||||
size_bytes: number;
|
||||
wal_size_bytes: number;
|
||||
schema_version: string;
|
||||
last_backup_at: string | null;
|
||||
};
|
||||
|
||||
function StatsTable({ database }: { database: DBStats }) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2">
|
||||
<StatRow label="Path" value={database.path} testid="system-db-path" />
|
||||
<StatRow label="Size" value={formatBytes(database.size_bytes)} testid="system-db-size" />
|
||||
<StatRow
|
||||
label="WAL"
|
||||
value={formatBytes(database.wal_size_bytes)}
|
||||
testid="system-db-wal"
|
||||
info={WAL_HELP}
|
||||
/>
|
||||
<StatRow
|
||||
label="Schema version"
|
||||
value={database.schema_version || "-"}
|
||||
testid="system-db-schema-version"
|
||||
/>
|
||||
<StatRow
|
||||
label="Last backup"
|
||||
value={formatTimestamp(database.last_backup_at)}
|
||||
testid="system-db-last-backup"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Plain-language descriptions of each maintenance operation. Surfaced as a
|
||||
// short paragraph next to the button + an on-hover tooltip so users without
|
||||
// database background know what each action does and when to use it.
|
||||
const VACUUM_HELP =
|
||||
"Reclaims unused space inside the database file. The file can grow over time as you delete tasks or sessions; vacuum compacts it back down. Safe to run anytime - it does not change any data. Use it once in a while if the database size looks large.";
|
||||
const OPTIMIZE_HELP =
|
||||
"Asks the database to refresh its internal indexes so common queries stay fast. Run it after deleting a lot of data or if the app feels sluggish. Quick and safe - no data is changed.";
|
||||
const FACTORY_RESET_HELP =
|
||||
"Wipes ALL Kandev data: tasks, sessions, settings, repositories, and worktrees. There is no undo. A backup is taken first, but restoring it requires using the Backups page after the reset. Only use this if you want to start over from scratch.";
|
||||
|
||||
function OperationRow({
|
||||
testid,
|
||||
label,
|
||||
description,
|
||||
button,
|
||||
}: {
|
||||
testid: string;
|
||||
label: string;
|
||||
description: string;
|
||||
button: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-md border p-3"
|
||||
data-testid={testid}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{button}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaintenanceButtons({
|
||||
vacuumState,
|
||||
optimizeState,
|
||||
onVacuum,
|
||||
onOptimize,
|
||||
onResetOpen,
|
||||
}: {
|
||||
vacuumState: ActionFeedbackState;
|
||||
optimizeState: ActionFeedbackState;
|
||||
onVacuum: () => void;
|
||||
onOptimize: () => void;
|
||||
onResetOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<OperationRow
|
||||
testid="system-vacuum-row"
|
||||
label="Vacuum"
|
||||
description={VACUUM_HELP}
|
||||
button={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={vacuumState === "pending"}
|
||||
onClick={onVacuum}
|
||||
className="cursor-pointer min-w-[7.5rem] justify-center"
|
||||
data-testid="system-vacuum-button"
|
||||
data-state={vacuumState}
|
||||
>
|
||||
<ActionButtonContent
|
||||
state={vacuumState}
|
||||
idleIcon={<IconBolt className="h-3.5 w-3.5 mr-1" />}
|
||||
idleLabel="Run vacuum"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{VACUUM_HELP}</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<OperationRow
|
||||
testid="system-optimize-row"
|
||||
label="Optimize"
|
||||
description={OPTIMIZE_HELP}
|
||||
button={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={optimizeState === "pending"}
|
||||
onClick={onOptimize}
|
||||
className="cursor-pointer min-w-[7.5rem] justify-center"
|
||||
data-testid="system-optimize-button"
|
||||
data-state={optimizeState}
|
||||
>
|
||||
<ActionButtonContent
|
||||
state={optimizeState}
|
||||
idleIcon={<IconRefresh className="h-3.5 w-3.5 mr-1" />}
|
||||
idleLabel="Run optimize"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{OPTIMIZE_HELP}</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<OperationRow
|
||||
testid="system-factory-reset-row"
|
||||
label="Factory reset"
|
||||
description={FACTORY_RESET_HELP}
|
||||
button={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onResetOpen}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-factory-reset-button"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5 mr-1" /> Factory reset
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{FACTORY_RESET_HELP}</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseStatsCard() {
|
||||
const { database, isLoading, error, reload } = useDatabaseStats();
|
||||
const vacuum = useActionFeedback();
|
||||
const optimize = useActionFeedback();
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
const onVacuum = () =>
|
||||
void vacuum.run(async () => {
|
||||
await vacuumDatabase();
|
||||
await reload();
|
||||
});
|
||||
|
||||
const onOptimize = () =>
|
||||
void optimize.run(async () => {
|
||||
await optimizeDatabase();
|
||||
});
|
||||
|
||||
return (
|
||||
<Card data-testid="system-database-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconDatabase className="h-4 w-4" /> Database
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<p className="text-xs text-red-500" data-testid="system-database-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!database && isLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading database stats...
|
||||
</div>
|
||||
)}
|
||||
{database && <StatsTable database={database} />}
|
||||
<MaintenanceButtons
|
||||
vacuumState={vacuum.state}
|
||||
optimizeState={optimize.state}
|
||||
onVacuum={onVacuum}
|
||||
onOptimize={onOptimize}
|
||||
onResetOpen={() => setResetOpen(true)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<JobProgressIndicator kind="vacuum" />
|
||||
<JobProgressIndicator kind="optimize" />
|
||||
<JobProgressIndicator kind="factory-reset" />
|
||||
</div>
|
||||
<FactoryResetDialog open={resetOpen} onOpenChange={setResetOpen} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
|
||||
import { IconDatabase, IconRefresh, IconAlertTriangle, IconFolderOpen } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useDiskUsage } from "@/hooks/domains/system/use-disk-usage";
|
||||
import { openDataFolder } from "@/lib/api/domains/system-api";
|
||||
import { useActionFeedback } from "@/hooks/use-action-feedback";
|
||||
import type { DiskBreakdown } from "@/lib/types/system";
|
||||
import { formatBytes } from "@/lib/utils/format-bytes";
|
||||
import { ActionButtonContent } from "./action-button-content";
|
||||
import { JobProgressIndicator } from "./job-progress-indicator";
|
||||
|
||||
const REFRESH_HELP =
|
||||
"Walks every directory inside the data folder and adds up the file sizes. Results are cached for a couple of hours; click to compute fresh numbers right now. Can take a few seconds on large workspaces.";
|
||||
|
||||
type Row = {
|
||||
key: keyof Omit<DiskBreakdown, "warnings" | "computed_at" | "total">;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ key: "data_dir", label: "Data directory" },
|
||||
{ key: "worktrees", label: "Worktrees" },
|
||||
{ key: "repos", label: "Repositories" },
|
||||
{ key: "sessions", label: "Sessions" },
|
||||
{ key: "tasks", label: "Tasks" },
|
||||
{ key: "quick_chat", label: "Quick chat" },
|
||||
{ key: "backups", label: "Backups" },
|
||||
];
|
||||
|
||||
function formatComputedAt(iso: string): string {
|
||||
const parsed = new Date(iso);
|
||||
if (Number.isNaN(parsed.getTime())) return iso;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function HomeDirRow({ homeDir }: { homeDir: string }) {
|
||||
return (
|
||||
<div
|
||||
className="mb-3 flex items-center justify-between gap-2 rounded-md border bg-muted/30 px-3 py-2"
|
||||
data-testid="system-disk-usage-home-dir"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Data directory</p>
|
||||
<p className="text-xs font-mono break-all">{homeDir}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void openDataFolder().catch(() => {});
|
||||
}}
|
||||
className="cursor-pointer shrink-0"
|
||||
data-testid="system-disk-usage-open"
|
||||
>
|
||||
<IconFolderOpen className="h-3.5 w-3.5 mr-1" />
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningsBlock({ warnings }: { warnings: string[] }) {
|
||||
if (warnings.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-xs text-amber-700 dark:text-amber-400 flex items-start gap-2"
|
||||
data-testid="system-disk-usage-warnings"
|
||||
>
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium">Some directories could not be measured:</div>
|
||||
<ul className="list-disc pl-4 mt-1">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownTable({ data }: { data: DiskBreakdown }) {
|
||||
return (
|
||||
<Table data-testid="system-disk-usage-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Path</TableHead>
|
||||
<TableHead className="text-right">Size</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ROWS.map((row) => (
|
||||
<TableRow key={row.key} data-testid={`system-disk-usage-row-${row.key}`}>
|
||||
<TableCell>{row.label}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatBytes(data[row.key])}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="font-semibold">
|
||||
<TableCell>Total</TableCell>
|
||||
<TableCell className="text-right tabular-nums" data-testid="system-disk-usage-total">
|
||||
{formatBytes(data.total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiskUsageCard() {
|
||||
const { diskUsage, isLoading, error, refresh } = useDiskUsage();
|
||||
const refreshFeedback = useActionFeedback();
|
||||
const data = diskUsage?.data ?? null;
|
||||
const computing = diskUsage?.computing ?? false;
|
||||
const homeDir = diskUsage?.home_dir ?? "";
|
||||
|
||||
const onRefresh = () =>
|
||||
void refreshFeedback.run(async () => {
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<Card data-testid="system-disk-usage-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconDatabase className="h-4 w-4" />
|
||||
Disk Usage
|
||||
{computing && data && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Refreshing...
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<JobProgressIndicator kind="disk-walk" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading || refreshFeedback.state === "pending"}
|
||||
onClick={onRefresh}
|
||||
className="cursor-pointer min-w-[6rem] justify-center"
|
||||
data-testid="system-disk-usage-refresh"
|
||||
data-state={refreshFeedback.state}
|
||||
>
|
||||
<ActionButtonContent
|
||||
state={refreshFeedback.state}
|
||||
idleIcon={<IconRefresh className="h-3.5 w-3.5 mr-1" />}
|
||||
idleLabel="Refresh"
|
||||
pendingLabel="Refreshing..."
|
||||
successLabel="Refreshed"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{REFRESH_HELP}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{homeDir && <HomeDirRow homeDir={homeDir} />}
|
||||
{error && (
|
||||
<p className="text-xs text-red-500" data-testid="system-disk-usage-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!data && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground py-4"
|
||||
data-testid="system-disk-usage-spinner"
|
||||
>
|
||||
<Spinner className="size-4" />
|
||||
Calculating...
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<div className="space-y-3">
|
||||
<BreakdownTable data={data} />
|
||||
<WarningsBlock warnings={data.warnings ?? []} />
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="system-disk-usage-computed-at"
|
||||
>
|
||||
Computed at {formatComputedAt(data.computed_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
||||
import { resetDatabase } from "@/lib/api/domains/system-api";
|
||||
import { useSystemJob } from "@/hooks/domains/system/use-system-jobs";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const CONFIRM_TOKEN = "RESET";
|
||||
|
||||
function ConfirmView({
|
||||
typed,
|
||||
onTyped,
|
||||
submitting,
|
||||
error,
|
||||
enabled,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
typed: string;
|
||||
onTyped: (v: string) => void;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
enabled: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconAlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Factory Reset
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<span>
|
||||
This wipes the entire kandev install: database, worktrees, repo clones, sessions, tasks,
|
||||
and quick-chat sessions.
|
||||
</span>
|
||||
<span className="block">
|
||||
A snapshot is created automatically under <code>{`<data-dir>/backups/`}</code> before
|
||||
the wipe runs, so you can restore from the Backups page if you change your mind.
|
||||
</span>
|
||||
<span className="block font-medium text-foreground">
|
||||
Type <code>RESET</code> to enable the confirm button. After the wipe completes
|
||||
you'll be asked to quit and relaunch Kandev - the backend does not auto-restart.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Type RESET to confirm"
|
||||
value={typed}
|
||||
onChange={(e) => onTyped(e.target.value)}
|
||||
disabled={submitting}
|
||||
data-testid="system-factory-reset-input"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" data-testid="system-factory-reset-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{submitting && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
data-testid="system-factory-reset-pending"
|
||||
>
|
||||
<Spinner className="size-4" /> Wiping data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-factory-reset-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={!enabled}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-factory-reset-confirm"
|
||||
>
|
||||
Factory Reset
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessView({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconCircleCheck className="h-5 w-5 text-emerald-500" />
|
||||
Factory reset complete
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span>
|
||||
Kandev's data has been wiped. Quit and relaunch the app to start fresh - the
|
||||
running backend still holds connections to the now-empty database and will not rebuild
|
||||
its caches until you restart. The pre-reset snapshot is available on the Backups page if
|
||||
you need to roll back.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-factory-reset-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FactoryResetDialog({ open, onOpenChange }: Props) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [requestPending, setRequestPending] = useState(false);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
|
||||
const job = useSystemJob(jobId);
|
||||
const succeeded = job?.state === "succeeded";
|
||||
const failed = job?.state === "failed";
|
||||
const submitting = requestPending || (jobId !== null && !succeeded && !failed);
|
||||
const error = requestError ?? (failed ? (job?.message ?? "Reset failed") : null);
|
||||
const enabled = typed === CONFIRM_TOKEN && !submitting && !succeeded;
|
||||
|
||||
const handleClose = (next: boolean) => {
|
||||
if (submitting) return;
|
||||
if (!next) {
|
||||
setTyped("");
|
||||
setRequestError(null);
|
||||
setJobId(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
setRequestPending(true);
|
||||
setRequestError(null);
|
||||
setJobId(null);
|
||||
try {
|
||||
const res = await resetDatabase(CONFIRM_TOKEN);
|
||||
setJobId(res.job_id);
|
||||
} catch (err) {
|
||||
setRequestError(err instanceof Error ? err.message : "Reset request failed");
|
||||
} finally {
|
||||
setRequestPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent data-testid="system-factory-reset-dialog">
|
||||
{succeeded ? (
|
||||
<SuccessView onClose={() => handleClose(false)} />
|
||||
) : (
|
||||
<ConfirmView
|
||||
typed={typed}
|
||||
onTyped={setTyped}
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
enabled={enabled}
|
||||
onCancel={() => handleClose(false)}
|
||||
onConfirm={() => void onConfirm()}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import {
|
||||
IconActivity,
|
||||
IconAlertTriangle,
|
||||
IconCircleCheck,
|
||||
IconExternalLink,
|
||||
IconInfoCircle,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useSystemHealth } from "@/hooks/domains/settings/use-system-health";
|
||||
import type { HealthCheckSummary, HealthIssue, HealthSeverity } from "@/lib/types/health";
|
||||
|
||||
function severityIcon(severity: HealthSeverity) {
|
||||
if (severity === "error") return <IconAlertTriangle className="h-4 w-4 text-red-500" />;
|
||||
if (severity === "warning") return <IconAlertTriangle className="h-4 w-4 text-amber-500" />;
|
||||
return <IconInfoCircle className="h-4 w-4 text-blue-500" />;
|
||||
}
|
||||
|
||||
function severityLabel(severity: HealthSeverity): string {
|
||||
return severity.charAt(0).toUpperCase() + severity.slice(1);
|
||||
}
|
||||
|
||||
function HealthIssueRow({ issue }: { issue: HealthIssue }) {
|
||||
const router = useRouter();
|
||||
const workspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const resolveUrl = (url: string) => url.replace("{workspaceId}", workspaceId ?? "");
|
||||
return (
|
||||
<div
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
data-testid={`system-health-issue-${issue.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{severityIcon(issue.severity)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{issue.title}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{severityLabel(issue.severity)}
|
||||
</Badge>
|
||||
</div>
|
||||
{issue.message && <p className="text-xs text-muted-foreground mt-1">{issue.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{issue.fix_url && issue.fix_label && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer h-7 text-xs"
|
||||
onClick={() => router.push(resolveUrl(issue.fix_url))}
|
||||
>
|
||||
{issue.fix_label}
|
||||
<IconExternalLink className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksPopover({ checks }: { checks: HealthCheckSummary[] }) {
|
||||
if (checks.length === 0) return null;
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="What's monitored"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid="system-health-checks-trigger"
|
||||
>
|
||||
<IconInfoCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72" data-testid="system-health-checks-popover">
|
||||
<p className="text-xs font-medium mb-2">System checks</p>
|
||||
<ul className="space-y-1.5">
|
||||
{checks.map((c) => (
|
||||
<li
|
||||
key={c.category}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
data-testid={`system-health-check-${c.category}`}
|
||||
>
|
||||
{c.passing ? (
|
||||
<IconCircleCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||
) : (
|
||||
<IconX className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
)}
|
||||
<span>{c.name}</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{c.passing ? "Passing" : "Issue"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthIssuesCard() {
|
||||
const { issues, checks, loaded } = useSystemHealth();
|
||||
const nonInfo = issues.filter((i) => i.severity !== "info");
|
||||
const hasIssues = nonInfo.length > 0;
|
||||
|
||||
return (
|
||||
<Card data-testid="system-health-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconActivity className="h-4 w-4" />
|
||||
Health
|
||||
{loaded && (
|
||||
<Badge variant={hasIssues ? "destructive" : "secondary"} className="text-[10px]">
|
||||
{hasIssues ? `${nonInfo.length} issue${nonInfo.length === 1 ? "" : "s"}` : "Healthy"}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{loaded && <ChecksPopover checks={checks} />}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!loaded && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="system-health-loading">
|
||||
Loading health checks...
|
||||
</p>
|
||||
)}
|
||||
{loaded && issues.length === 0 && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
data-testid="system-health-empty"
|
||||
>
|
||||
<IconCircleCheck className="h-4 w-4 text-emerald-500" />
|
||||
All system checks pass.
|
||||
</div>
|
||||
)}
|
||||
{loaded && issues.map((issue) => <HealthIssueRow key={issue.id} issue={issue} />)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconCheck, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useSystemJobs } from "@/hooks/domains/system/use-system-jobs";
|
||||
import type { SystemJob, SystemJobKind } from "@/lib/types/system";
|
||||
|
||||
type JobProgressIndicatorProps = {
|
||||
kind: SystemJobKind;
|
||||
/** When provided, only the matching job id is rendered (ignores all others). */
|
||||
jobId?: string;
|
||||
/** Testid suffix; default "system-job-<kind>". */
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
function pickJob(jobs: SystemJob[], jobId?: string): SystemJob | null {
|
||||
if (!jobs.length) return null;
|
||||
if (jobId) return jobs.find((j) => j.id === jobId) ?? null;
|
||||
// Latest by started_at (fall back to insertion order).
|
||||
const sorted = [...jobs].sort((a, b) => {
|
||||
const at = Date.parse(a.started_at) || 0;
|
||||
const bt = Date.parse(b.started_at) || 0;
|
||||
return bt - at;
|
||||
});
|
||||
return sorted[0] ?? null;
|
||||
}
|
||||
|
||||
function badgeVariant(state: SystemJob["state"]): "destructive" | "secondary" | "outline" {
|
||||
if (state === "failed") return "destructive";
|
||||
if (state === "succeeded") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function stateLabel(state: SystemJob["state"]): string {
|
||||
switch (state) {
|
||||
case "queued":
|
||||
return "Queued";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "succeeded":
|
||||
return "Done";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function JobProgressIndicator({ kind, jobId, testId }: JobProgressIndicatorProps) {
|
||||
const jobs = useSystemJobs(kind);
|
||||
const job = pickJob(jobs, jobId);
|
||||
if (!job) return null;
|
||||
|
||||
const tid = testId ?? `system-job-${kind}`;
|
||||
const isActive = job.state === "queued" || job.state === "running";
|
||||
const isSuccess = job.state === "succeeded";
|
||||
const isFailed = job.state === "failed";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 text-xs text-muted-foreground"
|
||||
data-testid={tid}
|
||||
data-state={job.state}
|
||||
>
|
||||
{isActive && <Spinner className="size-3.5" />}
|
||||
{isSuccess && <IconCheck className="size-3.5 text-emerald-500" />}
|
||||
{isFailed && <IconAlertTriangle className="size-3.5 text-red-500" />}
|
||||
<Badge variant={badgeVariant(job.state)} className="text-[10px]">
|
||||
{stateLabel(job.state)}
|
||||
</Badge>
|
||||
{job.message && <span className="truncate max-w-[24rem]">{job.message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
IconScale,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconExternalLink,
|
||||
} from "@tabler/icons-react";
|
||||
import type { LicenseEntry } from "@/lib/types/system";
|
||||
|
||||
type Props = {
|
||||
entries: LicenseEntry[];
|
||||
};
|
||||
|
||||
function LicenseRow({ entry }: { entry: LicenseEntry }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="border-b last:border-b-0 py-2"
|
||||
data-testid="system-license-row"
|
||||
data-name={entry.name}
|
||||
data-ecosystem={entry.ecosystem}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer px-1 h-auto py-1"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
data-testid="system-license-toggle"
|
||||
disabled={!entry.license_text}
|
||||
>
|
||||
{expanded ? (
|
||||
<IconChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<IconChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="font-mono text-sm break-all" data-testid="system-license-name">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground" data-testid="system-license-version">
|
||||
{entry.version}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px]"
|
||||
data-testid="system-license-ecosystem"
|
||||
>
|
||||
{entry.ecosystem}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]" data-testid="system-license-type">
|
||||
{entry.license}
|
||||
</Badge>
|
||||
{entry.repository && (
|
||||
<a
|
||||
href={entry.repository}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 cursor-pointer"
|
||||
data-testid="system-license-repo"
|
||||
>
|
||||
source <IconExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{expanded && entry.license_text && (
|
||||
<pre
|
||||
className="mt-2 max-h-72 overflow-auto rounded border bg-muted/40 p-2 text-[10px] leading-snug font-mono whitespace-pre-wrap"
|
||||
data-testid="system-license-text"
|
||||
>
|
||||
{entry.license_text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function filterEntries(entries: LicenseEntry[], query: string): LicenseEntry[] {
|
||||
if (!query.trim()) return entries;
|
||||
const lower = query.toLowerCase();
|
||||
return entries.filter((e) => {
|
||||
return (
|
||||
e.name.toLowerCase().includes(lower) ||
|
||||
(e.license ?? "").toLowerCase().includes(lower) ||
|
||||
(e.ecosystem ?? "").toLowerCase().includes(lower)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function LicensesList({ entries }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const visible = useMemo(() => filterEntries(entries, query), [entries, query]);
|
||||
|
||||
return (
|
||||
<Card data-testid="system-licenses-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconScale className="h-4 w-4" /> Third-party licenses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Input
|
||||
placeholder="Filter by name, license, or ecosystem..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
data-testid="system-licenses-filter"
|
||||
/>
|
||||
<p
|
||||
className="text-xs text-muted-foreground whitespace-nowrap"
|
||||
data-testid="system-licenses-count"
|
||||
>
|
||||
{visible.length} of {entries.length}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="max-h-[calc(100vh-22rem)] overflow-auto rounded-md border px-2"
|
||||
data-testid="system-licenses-list"
|
||||
>
|
||||
{visible.map((entry) => (
|
||||
<LicenseRow key={`${entry.ecosystem}-${entry.name}-${entry.version}`} entry={entry} />
|
||||
))}
|
||||
{visible.length === 0 && (
|
||||
<p
|
||||
className="py-6 text-center text-sm text-muted-foreground"
|
||||
data-testid="system-licenses-empty"
|
||||
>
|
||||
No entries match the filter.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { IconCopy, IconDownload, IconFileText, IconRefresh } from "@tabler/icons-react";
|
||||
import { useLogFiles } from "@/hooks/domains/system/use-log-files";
|
||||
import { useLogTail } from "@/hooks/domains/system/use-log-tail";
|
||||
import { buildLogDownloadUrl } from "@/lib/api/domains/system-api";
|
||||
import { formatBytes } from "@/lib/utils/format-bytes";
|
||||
import { useActionFeedback, type ActionFeedbackState } from "@/hooks/use-action-feedback";
|
||||
import { ActionButtonContent } from "./action-button-content";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
if (!iso) return "-";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function TailContent({
|
||||
tail,
|
||||
loading,
|
||||
inMemoryOnly,
|
||||
}: {
|
||||
tail: string[];
|
||||
loading: boolean;
|
||||
inMemoryOnly: boolean;
|
||||
}) {
|
||||
if (loading && tail.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading log...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (tail.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground" data-testid="system-log-tail-empty">
|
||||
No recent log activity captured yet. Logs will appear as Kandev does work.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{inMemoryOnly && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="system-log-tail-source">
|
||||
Showing the in-memory log buffer (last ~2000 entries). Kandev is currently logging to the
|
||||
terminal, not to a file - file rotation is disabled. Set <code>logging.outputPath</code>{" "}
|
||||
in <code>config.yaml</code> to a file path to enable downloadable log files.
|
||||
</p>
|
||||
)}
|
||||
<pre
|
||||
className="max-h-[28rem] overflow-auto rounded-md border bg-muted/30 p-3 text-[11px] leading-relaxed font-mono whitespace-pre"
|
||||
data-testid="system-log-tail-content"
|
||||
>
|
||||
{tail.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTailFilename(): string {
|
||||
// YYYY-MM-DDTHH-MM-SS to mirror lumberjack's rotation naming, minus the
|
||||
// colons (which are invalid on Windows). The user can rename freely.
|
||||
const stamp = new Date().toISOString().replace(/:/g, "-").replace(/\..+$/, "");
|
||||
return `kandev-tail-${stamp}.log`;
|
||||
}
|
||||
|
||||
function downloadTailAsFile(tail: string[]) {
|
||||
if (typeof window === "undefined" || tail.length === 0) return;
|
||||
const blob = new Blob([tail.join("\n") + "\n"], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = buildTailFilename();
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function TailHeader({
|
||||
tail,
|
||||
current,
|
||||
refreshState,
|
||||
copyState,
|
||||
onRefresh,
|
||||
onCopy,
|
||||
}: {
|
||||
tail: string[];
|
||||
current: ReturnType<typeof useLogFiles>["files"][number] | undefined;
|
||||
refreshState: ActionFeedbackState;
|
||||
copyState: ActionFeedbackState;
|
||||
onRefresh: () => void;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
const hasTail = tail.length > 0;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasTail || copyState === "pending"}
|
||||
onClick={onCopy}
|
||||
className="cursor-pointer min-w-[5.5rem] justify-center"
|
||||
data-testid="system-log-tail-copy"
|
||||
data-state={copyState}
|
||||
>
|
||||
<ActionButtonContent
|
||||
state={copyState}
|
||||
idleIcon={<IconCopy className="h-3.5 w-3.5 mr-1" />}
|
||||
idleLabel="Copy"
|
||||
pendingLabel="Copying..."
|
||||
successLabel="Copied"
|
||||
/>
|
||||
</Button>
|
||||
{current ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
data-testid="system-log-current-download"
|
||||
>
|
||||
<a href={buildLogDownloadUrl(current.name)} download>
|
||||
<IconDownload className="h-3.5 w-3.5 mr-1" />
|
||||
Download file
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasTail}
|
||||
onClick={() => downloadTailAsFile(tail)}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-log-tail-download"
|
||||
>
|
||||
<IconDownload className="h-3.5 w-3.5 mr-1" />
|
||||
Download tail
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshState === "pending"}
|
||||
onClick={onRefresh}
|
||||
className="cursor-pointer min-w-[6.5rem] justify-center"
|
||||
data-testid="system-log-tail-refresh"
|
||||
data-state={refreshState}
|
||||
>
|
||||
<ActionButtonContent
|
||||
state={refreshState}
|
||||
idleIcon={<IconRefresh className="h-3.5 w-3.5 mr-1" />}
|
||||
idleLabel="Refresh"
|
||||
pendingLabel="Refreshing..."
|
||||
successLabel="Refreshed"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogViewer() {
|
||||
const { files, isLoading: filesLoading } = useLogFiles();
|
||||
const { tail, isLoading: tailLoading, reload: reloadTail } = useLogTail(1000);
|
||||
const refreshFeedback = useActionFeedback();
|
||||
const copyFeedback = useActionFeedback();
|
||||
|
||||
const onRefresh = () =>
|
||||
void refreshFeedback.run(async () => {
|
||||
await reloadTail();
|
||||
});
|
||||
|
||||
const onCopy = () =>
|
||||
void copyFeedback.run(async () => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
||||
throw new Error("Clipboard API not available");
|
||||
}
|
||||
await navigator.clipboard.writeText(tail.join("\n") + "\n");
|
||||
});
|
||||
|
||||
const current = files.find((f) => f.current);
|
||||
const inMemoryOnly = !filesLoading && files.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card data-testid="system-log-tail-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconFileText className="h-4 w-4" /> Recent log output
|
||||
</CardTitle>
|
||||
<TailHeader
|
||||
tail={tail}
|
||||
current={current}
|
||||
refreshState={refreshFeedback.state}
|
||||
copyState={copyFeedback.state}
|
||||
onRefresh={onRefresh}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TailContent tail={tail} loading={tailLoading} inMemoryOnly={inMemoryOnly} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card data-testid="system-log-files-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Log files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!files.length && filesLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading files...
|
||||
</div>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead className="text-right">Size</TableHead>
|
||||
<TableHead>Modified</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((f) => (
|
||||
<TableRow key={f.name} data-testid="system-log-file-row" data-name={f.name}>
|
||||
<TableCell className="font-mono text-xs break-all">{f.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={f.current ? "default" : "secondary"} className="text-[10px]">
|
||||
{f.current ? "current" : "rotated"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-right">{formatBytes(f.size)}</TableCell>
|
||||
<TableCell className="text-xs">{formatTimestamp(f.mtime)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="cursor-pointer"
|
||||
data-testid="system-log-download"
|
||||
>
|
||||
<a href={buildLogDownloadUrl(f.name)} download>
|
||||
<IconDownload className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{!filesLoading && files.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground" data-testid="system-log-files-empty">
|
||||
No log files found. Kandev may be logging to stdout (no file rotation configured).
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
||||
import { restoreBackup } from "@/lib/api/domains/system-api";
|
||||
import { useSystemJob } from "@/hooks/domains/system/use-system-jobs";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const CONFIRM_TOKEN = "RESTORE";
|
||||
|
||||
function ConfirmView({
|
||||
name,
|
||||
typed,
|
||||
onTyped,
|
||||
submitting,
|
||||
error,
|
||||
enabled,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
name: string;
|
||||
typed: string;
|
||||
onTyped: (v: string) => void;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
enabled: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconAlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Restore snapshot
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<span>
|
||||
Restore <code className="font-mono">{name}</code> over the current database. After the
|
||||
staged copy is in place you will be asked to quit and relaunch Kandev so the new data is
|
||||
loaded fresh - the backend does not auto-restart.
|
||||
</span>
|
||||
<span className="block font-medium text-foreground">
|
||||
Type <code>RESTORE</code> to enable the confirm button.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Type RESTORE to confirm"
|
||||
value={typed}
|
||||
onChange={(e) => onTyped(e.target.value)}
|
||||
disabled={submitting}
|
||||
data-testid="system-restore-input"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" data-testid="system-restore-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{submitting && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
data-testid="system-restore-pending"
|
||||
>
|
||||
<Spinner className="size-4" /> Writing the snapshot over the live database...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-restore-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={!enabled}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-restore-confirm"
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessView({ name, onClose }: { name: string; onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<IconCircleCheck className="h-5 w-5 text-emerald-500" />
|
||||
Restore complete
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span>
|
||||
<code className="font-mono">{name}</code> has been written over the current database.
|
||||
Quit and relaunch Kandev to load the restored data - the running backend still holds
|
||||
connections to the previous version and will serve stale rows until you restart.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-restore-close"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RestoreDialog({ open, onOpenChange, name }: Props) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [requestPending, setRequestPending] = useState(false);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
|
||||
const job = useSystemJob(jobId);
|
||||
const succeeded = job?.state === "succeeded";
|
||||
const failed = job?.state === "failed";
|
||||
// submitting spans both the HTTP roundtrip and the in-flight backend job.
|
||||
const submitting = requestPending || (jobId !== null && !succeeded && !failed);
|
||||
const error = requestError ?? (failed ? (job?.message ?? "Restore failed") : null);
|
||||
const enabled = typed === CONFIRM_TOKEN && !submitting && !succeeded;
|
||||
|
||||
const handleClose = (next: boolean) => {
|
||||
if (submitting) return;
|
||||
if (!next) {
|
||||
setTyped("");
|
||||
setRequestError(null);
|
||||
setJobId(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
setRequestPending(true);
|
||||
setRequestError(null);
|
||||
setJobId(null);
|
||||
try {
|
||||
const res = await restoreBackup(name, CONFIRM_TOKEN);
|
||||
setJobId(res.job_id);
|
||||
} catch (err) {
|
||||
setRequestError(err instanceof Error ? err.message : "Restore request failed");
|
||||
} finally {
|
||||
setRequestPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent data-testid="system-restore-dialog">
|
||||
{succeeded ? (
|
||||
<SuccessView name={name} onClose={() => handleClose(false)} />
|
||||
) : (
|
||||
<ConfirmView
|
||||
name={name}
|
||||
typed={typed}
|
||||
onTyped={setTyped}
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
enabled={enabled}
|
||||
onCancel={() => handleClose(false)}
|
||||
onConfirm={() => void onConfirm()}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
|
||||
type SystemPageShellProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function SystemPageShell({ title, description, actions, children }: SystemPageShellProps) {
|
||||
return (
|
||||
<div className="space-y-6" data-testid="system-page-shell">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold" data-testid="system-page-title">
|
||||
{title}
|
||||
</h2>
|
||||
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { IconLayoutDashboard, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
const HELP =
|
||||
"Clears layout and UI preferences saved in your browser (panel sizes, pinned tasks, expanded groups, etc.). Use this if the app looks broken, panels are stuck, or the layout feels wrong. Your tasks, sessions, and server data are not affected - only the per-browser UI state. The page reloads after the reset.";
|
||||
|
||||
function resetBrowserStorage() {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export function UIStateCard() {
|
||||
return (
|
||||
<Card data-testid="system-ui-state-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconLayoutDashboard className="h-4 w-4" /> UI state
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Reset browser layout</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{HELP}</p>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetBrowserStorage}
|
||||
className="cursor-pointer shrink-0"
|
||||
data-testid="system-ui-state-reset"
|
||||
>
|
||||
<IconRefresh className="h-3.5 w-3.5 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{HELP}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconExternalLink, IconRefresh } from "@tabler/icons-react";
|
||||
import { useUpdates } from "@/hooks/domains/system/use-updates";
|
||||
|
||||
function formatChecked(iso: string | undefined): string {
|
||||
if (!iso) return "never";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export function UpdatesCard() {
|
||||
const { updates, check } = useUpdates();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryAfter, setRetryAfter] = useState<number | null>(null);
|
||||
|
||||
const onCheck = async () => {
|
||||
setChecking(true);
|
||||
setError(null);
|
||||
setRetryAfter(null);
|
||||
try {
|
||||
await check();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Update check failed";
|
||||
setError(message);
|
||||
const match = /retry.*?(\d+)/i.exec(message);
|
||||
if (match) {
|
||||
setRetryAfter(Number(match[1]));
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const current = updates?.current ?? "-";
|
||||
const latest = updates?.latest ?? "-";
|
||||
const available = updates?.update_available ?? false;
|
||||
const checkedAt = updates?.latest_checked_at;
|
||||
const url = updates?.latest_url;
|
||||
|
||||
return (
|
||||
<Card data-testid="system-updates-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
Updates
|
||||
{available && (
|
||||
<Badge variant="default" className="text-[10px]" data-testid="system-updates-badge">
|
||||
Update available
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Current version</div>
|
||||
<div className="font-mono text-sm" data-testid="system-updates-current">
|
||||
{current}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Latest release</div>
|
||||
<div className="font-mono text-sm" data-testid="system-updates-latest">
|
||||
{latest}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground" data-testid="system-updates-checked-at">
|
||||
Last checked {formatChecked(checkedAt)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={checking}
|
||||
onClick={() => void onCheck()}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-updates-check"
|
||||
>
|
||||
{checking ? (
|
||||
<Spinner className="size-3.5 mr-1" />
|
||||
) : (
|
||||
<IconRefresh className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Check now
|
||||
</Button>
|
||||
{url && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
data-testid="system-updates-release-link"
|
||||
>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
Release notes
|
||||
<IconExternalLink className="h-3.5 w-3.5 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" data-testid="system-updates-error">
|
||||
{retryAfter ? `Already checked. Try again in ${retryAfter}s.` : error}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { IconRefresh, IconArrowRight } from "@tabler/icons-react";
|
||||
import { useUpdates } from "@/hooks/domains/system/use-updates";
|
||||
|
||||
export function VersionSummaryCard() {
|
||||
const { updates } = useUpdates();
|
||||
const current = updates?.current ?? "-";
|
||||
const latest = updates?.latest ?? "-";
|
||||
const updateAvailable = updates?.update_available ?? false;
|
||||
|
||||
return (
|
||||
<Card data-testid="system-version-summary-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
Version
|
||||
{updateAvailable && (
|
||||
<Badge variant="default" className="text-[10px]">
|
||||
Update available
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Current</div>
|
||||
<div className="font-mono text-sm" data-testid="system-version-current">
|
||||
{current}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Latest</div>
|
||||
<div className="font-mono text-sm" data-testid="system-version-latest">
|
||||
{latest}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="cursor-pointer">
|
||||
<Link href="/settings/system/updates" data-testid="system-version-updates-link">
|
||||
View updates
|
||||
<IconArrowRight className="h-3.5 w-3.5 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("System About page", () => {
|
||||
test("renders version metadata rows and the GitHub link", async ({ testPage }) => {
|
||||
test.setTimeout(30_000);
|
||||
|
||||
await testPage.goto("/settings/system/about");
|
||||
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("About");
|
||||
await expect(testPage.getByTestId("system-about-card")).toBeVisible();
|
||||
|
||||
// Each row must render *some* value (could be "dev" / "unknown" — just non-empty).
|
||||
const ids = [
|
||||
"system-about-version",
|
||||
"system-about-commit",
|
||||
"system-about-build-time",
|
||||
"system-about-os",
|
||||
"system-about-arch",
|
||||
];
|
||||
for (const id of ids) {
|
||||
const value = (await testPage.getByTestId(id).innerText()).trim();
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const githubLink = testPage.getByTestId("system-about-github-link");
|
||||
await expect(githubLink).toBeVisible();
|
||||
await expect(githubLink).toHaveAttribute("href", "https://github.com/kdlbs/kandev");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
async function deleteAllManualBackups(apiClient: {
|
||||
rawRequest: (m: string, p: string) => Promise<Response>;
|
||||
}) {
|
||||
const res = await apiClient.rawRequest("GET", "/api/v1/system/backups");
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { snapshots?: Array<{ name: string; kind: string }> };
|
||||
for (const snap of body.snapshots ?? []) {
|
||||
if (snap.kind === "manual") {
|
||||
await apiClient
|
||||
.rawRequest("DELETE", `/api/v1/system/backups/${encodeURIComponent(snap.name)}`)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("System Backups page", () => {
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
await deleteAllManualBackups(apiClient);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
await deleteAllManualBackups(apiClient);
|
||||
});
|
||||
|
||||
test("create a manual backup, see it in the table, then delete it back to the empty state", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await testPage.goto("/settings/system/backups");
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Backups");
|
||||
await expect(testPage.getByTestId("system-backups-card")).toBeVisible();
|
||||
|
||||
// Empty state shows initially (no auto snapshots exist on this fresh boot path).
|
||||
await expect(testPage.getByTestId("system-backups-empty")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click create snapshot; the UI polls until the async VACUUM INTO job
|
||||
// has produced a new manual snapshot, then renders the table.
|
||||
await testPage.getByTestId("system-backups-create").click();
|
||||
await expect(testPage.getByTestId("system-backups-table")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const rows = testPage.locator('[data-testid="system-backups-row"]');
|
||||
await expect(rows.first()).toBeVisible();
|
||||
|
||||
// The newly created row has a manual- prefix and the kind badge says "manual".
|
||||
const firstName = await rows.first().getAttribute("data-name");
|
||||
expect(firstName ?? "").toMatch(/^manual-/);
|
||||
await expect(rows.first()).toContainText("manual");
|
||||
|
||||
// Delete the new row → empty state returns.
|
||||
await rows.first().getByTestId("system-backups-delete").click();
|
||||
await expect(testPage.getByTestId("system-backups-empty")).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("Changelog redirect", () => {
|
||||
test("/settings/changelog redirects to /settings/system/updates", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/changelog");
|
||||
await expect(testPage).toHaveURL(/\/settings\/system\/updates$/, { timeout: 10_000 });
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("System Database page", () => {
|
||||
test("renders database stats and exposes maintenance buttons", async ({ testPage }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await testPage.goto("/settings/system/database");
|
||||
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Database");
|
||||
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
|
||||
|
||||
// Each stat row must render a non-empty value.
|
||||
const rows = ["system-db-path", "system-db-size", "system-db-wal", "system-db-schema-version"];
|
||||
for (const id of rows) {
|
||||
const value = await testPage.getByTestId(id).innerText();
|
||||
expect(value.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking VACUUM briefly disables the button while the request runs", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await testPage.goto("/settings/system/database");
|
||||
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
|
||||
|
||||
const vacuumButton = testPage.getByTestId("system-vacuum-button");
|
||||
await expect(vacuumButton).toBeEnabled();
|
||||
await expect(vacuumButton).toHaveAttribute("data-state", "idle");
|
||||
|
||||
// Fire and confirm the request leaves the page.
|
||||
const requestPromise = testPage.waitForRequest(
|
||||
(req) => req.url().includes("/api/v1/system/database/vacuum") && req.method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await vacuumButton.click();
|
||||
await requestPromise;
|
||||
|
||||
// After completion the button switches to the success state (green check
|
||||
// + "Done"). useActionFeedback holds the pending state for at least
|
||||
// 350ms, so the success transition is observable.
|
||||
await expect(vacuumButton).toHaveAttribute("data-state", "success", { timeout: 5_000 });
|
||||
// ...and reverts back to idle after the auto-reset window (~1.8s).
|
||||
await expect(vacuumButton).toHaveAttribute("data-state", "idle", { timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("WAL row exposes an info tooltip trigger", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/system/database");
|
||||
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
|
||||
await expect(testPage.getByTestId("system-db-wal-info")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking Factory Reset opens the confirmation modal", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/system/database");
|
||||
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
|
||||
|
||||
await testPage.getByTestId("system-factory-reset-button").click();
|
||||
await expect(testPage.getByTestId("system-factory-reset-dialog")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
// Wait until the backend has a populated disk-usage cache (the initial walk
|
||||
// is async; the first call returns `{data: null, computing: true}` and kicks
|
||||
// it off, subsequent calls return the cached breakdown). We poll the API
|
||||
// before loading the page so the client's first fetch lands on populated
|
||||
// data without depending on a WS event that may race against page mount.
|
||||
async function waitForDiskUsageCached(apiClient: {
|
||||
rawRequest: (m: string, p: string) => Promise<Response>;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await apiClient.rawRequest("GET", "/api/v1/system/disk-usage");
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { data: unknown; computing: boolean };
|
||||
if (body.data) return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
throw new Error("disk-usage cache never populated within 20s");
|
||||
}
|
||||
|
||||
test.describe("System Status — disk usage", () => {
|
||||
test("disk usage breakdown renders once the cache is populated", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await waitForDiskUsageCached(apiClient);
|
||||
|
||||
await testPage.goto("/settings/system/status");
|
||||
|
||||
const card = testPage.getByTestId("system-disk-usage-card");
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(testPage.getByTestId("system-disk-usage-table")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(testPage.getByTestId("system-disk-usage-total")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh button hits the refresh endpoint and re-fetches usage", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await waitForDiskUsageCached(apiClient);
|
||||
|
||||
await testPage.goto("/settings/system/status");
|
||||
await expect(testPage.getByTestId("system-disk-usage-table")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const refreshCalled = testPage.waitForRequest(
|
||||
(req) => req.url().includes("/api/v1/system/disk-usage/refresh") && req.method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
await testPage.getByTestId("system-disk-usage-refresh").click();
|
||||
await refreshCalled;
|
||||
});
|
||||
|
||||
test("data directory path and Open button are exposed on the card", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await waitForDiskUsageCached(apiClient);
|
||||
|
||||
await testPage.goto("/settings/system/status");
|
||||
|
||||
const homeDirBlock = testPage.getByTestId("system-disk-usage-home-dir");
|
||||
await expect(homeDirBlock).toBeVisible({ timeout: 15_000 });
|
||||
// The path is rendered in font-mono inside the block; just assert it is
|
||||
// non-empty (the exact path varies by env / config).
|
||||
const text = await homeDirBlock.innerText();
|
||||
expect(text.trim().length).toBeGreaterThan("DATA DIRECTORY".length);
|
||||
|
||||
const openRequest = testPage.waitForRequest(
|
||||
(req) => req.url().includes("/api/v1/system/disk-usage/open") && req.method() === "POST",
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await testPage.getByTestId("system-disk-usage-open").click();
|
||||
await openRequest;
|
||||
});
|
||||
|
||||
test("page first-loads without manual refresh even when WS event is missed", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
// Regression: the disk-walk job's completion is published over WS; if the
|
||||
// socket isn't connected at the moment the walk finishes the broadcast is
|
||||
// dropped and the card used to sit on "Calculating..." forever. The hook
|
||||
// now polls every ~1.5s while the backend reports computing=true, so the
|
||||
// table must appear within a few seconds on a fresh page load — no
|
||||
// intermediate apiClient pre-warm here, on purpose.
|
||||
test.setTimeout(30_000);
|
||||
|
||||
await testPage.goto("/settings/system/status");
|
||||
await expect(testPage.getByTestId("system-disk-usage-card")).toBeVisible();
|
||||
await expect(testPage.getByTestId("system-disk-usage-table")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("Factory Reset dialog", () => {
|
||||
test("confirm button only enables on the literal 'RESET' token; cancel closes the modal", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
await testPage.goto("/settings/system/database");
|
||||
await expect(testPage.getByTestId("system-database-card")).toBeVisible();
|
||||
|
||||
await testPage.getByTestId("system-factory-reset-button").click();
|
||||
const dialog = testPage.getByTestId("system-factory-reset-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const confirm = testPage.getByTestId("system-factory-reset-confirm");
|
||||
const input = testPage.getByTestId("system-factory-reset-input");
|
||||
|
||||
// Empty → disabled.
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
// Wrong token → still disabled.
|
||||
await input.fill("WRONG");
|
||||
await expect(confirm).toBeDisabled();
|
||||
|
||||
// Correct token → enabled. DO NOT click — that would wipe state.
|
||||
await input.fill("RESET");
|
||||
await expect(confirm).toBeEnabled();
|
||||
|
||||
// Cancel closes the modal.
|
||||
await testPage.getByTestId("system-factory-reset-cancel").click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("System Licenses page", () => {
|
||||
test("renders the licenses card, filters rows, and shows empty state on no match", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await testPage.goto("/settings/system/licenses");
|
||||
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Licenses");
|
||||
const card = testPage.getByTestId("system-licenses-card");
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
const count = testPage.getByTestId("system-licenses-count");
|
||||
await expect(count).toBeVisible();
|
||||
const fullCountText = (await count.innerText()).trim();
|
||||
expect(fullCountText.length).toBeGreaterThan(0);
|
||||
|
||||
// Filter to "react" — expect at least one row and a different count value.
|
||||
const filter = testPage.getByTestId("system-licenses-filter");
|
||||
await filter.fill("react");
|
||||
await expect(testPage.locator('[data-testid="system-license-row"]').first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const filteredCountText = (await count.innerText()).trim();
|
||||
expect(filteredCountText).not.toBe(fullCountText);
|
||||
|
||||
// Filter to a nonsense token → empty state.
|
||||
await filter.fill("zzz-this-package-does-not-exist");
|
||||
await expect(testPage.getByTestId("system-licenses-empty")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("System Logs page", () => {
|
||||
test("renders the tail viewer card and refresh works without error", async ({ testPage }) => {
|
||||
test.setTimeout(30_000);
|
||||
|
||||
await testPage.goto("/settings/system/logs");
|
||||
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Logs");
|
||||
await expect(testPage.getByTestId("system-log-tail-card")).toBeVisible();
|
||||
|
||||
const refreshButton = testPage.getByTestId("system-log-tail-refresh");
|
||||
await expect(refreshButton).toBeVisible();
|
||||
|
||||
const tailRequest = testPage.waitForRequest(
|
||||
(req) => req.url().includes("/api/v1/system/logs/tail") && req.method() === "GET",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await refreshButton.click();
|
||||
await tailRequest;
|
||||
|
||||
// Either the tail content or the empty-tail message is rendered; both are valid.
|
||||
const content = testPage.getByTestId("system-log-tail-content");
|
||||
const empty = testPage.getByTestId("system-log-tail-empty");
|
||||
await expect(content.or(empty)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
async function listBackups(apiClient: { rawRequest: (m: string, p: string) => Promise<Response> }) {
|
||||
const res = await apiClient.rawRequest("GET", "/api/v1/system/backups");
|
||||
if (!res.ok) return [] as Array<{ name: string; kind: string }>;
|
||||
const body = (await res.json()) as { snapshots?: Array<{ name: string; kind: string }> };
|
||||
return body.snapshots ?? [];
|
||||
}
|
||||
|
||||
async function deleteAllManual(apiClient: {
|
||||
rawRequest: (m: string, p: string) => Promise<Response>;
|
||||
}) {
|
||||
const snapshots = await listBackups(apiClient);
|
||||
for (const snap of snapshots) {
|
||||
if (snap.kind === "manual") {
|
||||
await apiClient
|
||||
.rawRequest("DELETE", `/api/v1/system/backups/${encodeURIComponent(snap.name)}`)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Restore snapshot dialog", () => {
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
await deleteAllManual(apiClient);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
await deleteAllManual(apiClient);
|
||||
});
|
||||
|
||||
test("confirm enables only on 'RESTORE'; cancel closes the modal", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Seed a manual backup via the API so the row exists when the page loads.
|
||||
const create = await apiClient.rawRequest("POST", "/api/v1/system/backups");
|
||||
expect(create.ok).toBeTruthy();
|
||||
|
||||
// Poll the backups list until the new snapshot appears (creation is async).
|
||||
let found = false;
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
const snaps = await listBackups(apiClient);
|
||||
if (snaps.some((s) => s.kind === "manual")) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
expect(found).toBeTruthy();
|
||||
|
||||
await testPage.goto("/settings/system/backups");
|
||||
await expect(testPage.getByTestId("system-backups-table")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const firstRow = testPage.locator('[data-testid="system-backups-row"]').first();
|
||||
await firstRow.getByTestId("system-backups-restore").click();
|
||||
|
||||
const dialog = testPage.getByTestId("system-restore-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const confirm = testPage.getByTestId("system-restore-confirm");
|
||||
const input = testPage.getByTestId("system-restore-input");
|
||||
|
||||
await expect(confirm).toBeDisabled();
|
||||
await input.fill("WRONG");
|
||||
await expect(confirm).toBeDisabled();
|
||||
await input.fill("RESTORE");
|
||||
await expect(confirm).toBeEnabled();
|
||||
|
||||
// DO NOT click confirm — would actually restart the backend.
|
||||
await testPage.getByTestId("system-restore-cancel").click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
const SYSTEM_ENTRIES: Array<{ href: string; label: string; title: string }> = [
|
||||
{ href: "/settings/system/status", label: "Status", title: "Status" },
|
||||
{ href: "/settings/system/database", label: "Database", title: "Database" },
|
||||
{ href: "/settings/system/backups", label: "Backups", title: "Backups" },
|
||||
{ href: "/settings/system/logs", label: "Logs", title: "Logs" },
|
||||
{ href: "/settings/system/updates", label: "Updates", title: "Updates" },
|
||||
{ href: "/settings/system/about", label: "About", title: "About" },
|
||||
{ href: "/settings/system/licenses", label: "Licenses", title: "Licenses" },
|
||||
];
|
||||
|
||||
test.describe("System sidebar navigation", () => {
|
||||
test("System group has 7 sub-entries that navigate correctly and no standalone Changelog entry remains", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await testPage.goto("/settings/general/notifications");
|
||||
|
||||
// Each sub-entry is present in the settings sidebar.
|
||||
for (const entry of SYSTEM_ENTRIES) {
|
||||
const link = testPage.locator(`a[href="${entry.href}"]`).first();
|
||||
await expect(link).toBeVisible();
|
||||
}
|
||||
|
||||
// Standalone Changelog entry is NOT present.
|
||||
await expect(testPage.locator('a[href="/settings/changelog"]')).toHaveCount(0);
|
||||
|
||||
// Click each entry and confirm the URL + page title.
|
||||
for (const entry of SYSTEM_ENTRIES) {
|
||||
await testPage.locator(`a[href="${entry.href}"]`).first().click();
|
||||
await expect(testPage).toHaveURL((url) => new URL(url).pathname === entry.href, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText(entry.title, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("System Status page", () => {
|
||||
test("renders health card, version summary, and the page title", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/system/status");
|
||||
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Status");
|
||||
await expect(testPage.getByTestId("system-health-card")).toBeVisible();
|
||||
await expect(testPage.getByTestId("system-version-summary-card")).toBeVisible();
|
||||
await expect(testPage.getByTestId("system-disk-usage-card")).toBeVisible();
|
||||
await expect(testPage.getByTestId("system-ui-state-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("inner System > Status breadcrumb is no longer rendered", async ({ testPage }) => {
|
||||
// The outer Home > Settings breadcrumb from PageTopbar is enough; the
|
||||
// duplicated inner one inside SystemPageShell was removed.
|
||||
await testPage.goto("/settings/system/status");
|
||||
await expect(testPage.getByTestId("system-page-title")).toBeVisible();
|
||||
await expect(testPage.getByTestId("system-breadcrumb")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("health card info popover lists the system checks that ran", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/system/status");
|
||||
|
||||
const trigger = testPage.getByTestId("system-health-checks-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await trigger.click();
|
||||
const popover = testPage.getByTestId("system-health-checks-popover");
|
||||
await expect(popover).toBeVisible();
|
||||
// The default backend wiring registers GitHub + agents checks.
|
||||
await expect(testPage.getByTestId("system-health-check-github")).toBeVisible();
|
||||
await expect(testPage.getByTestId("system-health-check-agents")).toBeVisible();
|
||||
});
|
||||
|
||||
test("UI state Reset button is wired up and triggers a reload", async ({ testPage }) => {
|
||||
await testPage.goto("/settings/system/status");
|
||||
|
||||
const resetButton = testPage.getByTestId("system-ui-state-reset");
|
||||
await expect(resetButton).toBeVisible();
|
||||
|
||||
// Sentinel value: if Reset wipes localStorage as designed, this key will
|
||||
// not survive the click + reload that follows.
|
||||
await testPage.evaluate(() => window.localStorage.setItem("e2e-ui-state-sentinel", "1"));
|
||||
|
||||
const navigation = testPage.waitForLoadState("load");
|
||||
await resetButton.click();
|
||||
await navigation;
|
||||
|
||||
const stored = await testPage.evaluate(() =>
|
||||
window.localStorage.getItem("e2e-ui-state-sentinel"),
|
||||
);
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("System Updates — rate-limited check", () => {
|
||||
test("clicking Check now while rate-limited surfaces a 'Try again in <N>s' error", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(30_000);
|
||||
|
||||
// Provide a base payload for the initial fetch so the page renders cleanly.
|
||||
await testPage.route("**/api/v1/system/updates", (route) => {
|
||||
void route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
current: "v1.0.0",
|
||||
latest: "v1.0.0",
|
||||
latest_url: "",
|
||||
latest_checked_at: new Date().toISOString(),
|
||||
update_available: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// The /updates/check endpoint returns a rate-limited 429.
|
||||
await testPage.route("**/api/v1/system/updates/check", (route) => {
|
||||
void route.fulfill({
|
||||
status: 429,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: "Already checked recently. Retry after 25 seconds.",
|
||||
retry_after_seconds: 25,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await testPage.goto("/settings/system/updates");
|
||||
await expect(testPage.getByTestId("system-updates-card")).toBeVisible();
|
||||
|
||||
await testPage.getByTestId("system-updates-check").click();
|
||||
|
||||
const errorMsg = testPage.getByTestId("system-updates-error");
|
||||
await expect(errorMsg).toBeVisible({ timeout: 10_000 });
|
||||
await expect(errorMsg).toContainText(/try again in 25s/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("System Updates page", () => {
|
||||
test("clicking Check now with a stubbed 'update available' response shows the badge and versions", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(30_000);
|
||||
|
||||
// The /updates GET fires server-side during SSR (can't be intercepted by
|
||||
// Playwright's testPage.route), so we drive the state via the client-only
|
||||
// /updates/check POST instead — the hook writes its response into the
|
||||
// store and the card re-renders with the stubbed values.
|
||||
await testPage.route("**/api/v1/system/updates/check", (route) => {
|
||||
void route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
current: "v1.0.0",
|
||||
latest: "v1.0.1",
|
||||
latest_url: "https://example.com/r/v1.0.1",
|
||||
latest_checked_at: new Date().toISOString(),
|
||||
update_available: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await testPage.goto("/settings/system/updates");
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates");
|
||||
|
||||
await testPage.getByTestId("system-updates-check").click();
|
||||
|
||||
const badge = testPage.getByTestId("system-updates-badge");
|
||||
await expect(badge).toBeVisible({ timeout: 10_000 });
|
||||
await expect(badge).toHaveText(/update available/i);
|
||||
|
||||
await expect(testPage.getByTestId("system-updates-current")).toHaveText("v1.0.0");
|
||||
await expect(testPage.getByTestId("system-updates-latest")).toHaveText("v1.0.1");
|
||||
});
|
||||
|
||||
test("changelog pagination is URL-driven via ?page=N", async ({ testPage }) => {
|
||||
// The changelog renders 10 entries per page and the embedded list (built
|
||||
// from generated/changelog.json) routinely covers >10 versions in this
|
||||
// repo, so page 2 should exist. If a future trim drops it below 11 the
|
||||
// pagination element would not render and the test would skip; protect
|
||||
// against that by asserting on the page-2 link only when present.
|
||||
await testPage.goto("/settings/system/updates");
|
||||
await expect(testPage.getByTestId("system-page-title")).toHaveText("Updates");
|
||||
|
||||
// Scope to changelog pagination — settings sidebar workspace links can also
|
||||
// expose accessible names that match single-digit page numbers.
|
||||
const changelogPagination = testPage.getByTestId("changelog-pagination");
|
||||
const page2 = changelogPagination.getByTestId("changelog-page-2");
|
||||
const hasPagination = (await page2.count()) > 0;
|
||||
test.skip(!hasPagination, "Changelog has fewer than 2 pages on this build");
|
||||
|
||||
await expect(changelogPagination).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page2.click();
|
||||
// URL replace should land on ?page=2 without a full reload.
|
||||
await testPage.waitForURL(/[?&]page=2(\b|&)/, { timeout: 5_000 });
|
||||
|
||||
// Going back to page 1 strips the query param (clean URL convention).
|
||||
const page1 = changelogPagination.getByTestId("changelog-page-1");
|
||||
await page1.click();
|
||||
await testPage.waitForURL((url) => !url.search.includes("page="), { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!licenses.json
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
|
||||
export function useSystemHealth() {
|
||||
const issues = useAppStore((state) => state.systemHealth.issues);
|
||||
const checks = useAppStore((state) => state.systemHealth.checks);
|
||||
const healthy = useAppStore((state) => state.systemHealth.healthy);
|
||||
const loaded = useAppStore((state) => state.systemHealth.loaded);
|
||||
const loading = useAppStore((state) => state.systemHealth.loading);
|
||||
@@ -17,15 +18,15 @@ export function useSystemHealth() {
|
||||
setSystemHealthLoading(true);
|
||||
fetchSystemHealth({ cache: "no-store" })
|
||||
.then((response) => {
|
||||
setSystemHealth(response ?? { healthy: true, issues: [] });
|
||||
setSystemHealth(response ?? { healthy: true, issues: [], checks: [] });
|
||||
})
|
||||
.catch(() => {
|
||||
setSystemHealth({ healthy: true, issues: [] });
|
||||
setSystemHealth({ healthy: true, issues: [], checks: [] });
|
||||
})
|
||||
.finally(() => {
|
||||
setSystemHealthLoading(false);
|
||||
});
|
||||
}, [loaded, loading, setSystemHealth, setSystemHealthLoading]);
|
||||
|
||||
return { issues, healthy, loaded, loading };
|
||||
return { issues, checks, healthy, loaded, loading };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user