Introduce internal/ui package and rewrite user-facing output
All user-facing output now goes through a single ui.Writer with a
uniform style:
》 (white) for begin / info / notice
》 (green) for complete / success
Warning: for warnings (orange)
ERROR: for errors (red)
》 (indented) for progress heartbeats
Color is enabled when stdout is a TTY and NO_COLOR is unset.
Standards:
- Complete-sentence messages with fully qualified terms ("backup
destination store", "local index database", "snapshot source
files enumeration").
- Every Complete has a matching Begin.
- Natural verb tense conveys state ("Uploading" -> "Uploaded"). The
words "begin"/"complete" never appear in message bodies; the marker
color carries that information.
- ETA means clock time, not duration. Progress lines say "estimated
remaining time (<dur>), finish at <time>" with both labeled.
Adds globals.CommitDate (populated by Makefile/Dockerfile/goreleaser
via ldflags from `git show -s --format=%cI HEAD`) and a startup banner
printed once per invocation.
Strips fx call-chain noise from startup errors so users see the actual
underlying error (e.g. "creating base path: mkdir /Volumes/BACKUPS:
permission denied" instead of three layers of "could not build
arguments for function ...").
README documents the output style and the ui package conventions.
This commit is contained in:
@@ -22,6 +22,7 @@ builds:
|
||||
- -s -w
|
||||
- -X 'sneak.berlin/go/vaultik/internal/globals.Version={{ .Version }}'
|
||||
- -X 'sneak.berlin/go/vaultik/internal/globals.Commit={{ .Commit }}'
|
||||
- -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate={{ .CommitDate }}'
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
|
||||
@@ -41,8 +41,8 @@ COPY . .
|
||||
# Run tests
|
||||
RUN make test
|
||||
|
||||
# Build with CGO enabled (required for mattn/go-sqlite3)
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik
|
||||
# Build (pure Go, no CGO required since we use modernc.org/sqlite)
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X 'sneak.berlin/go/vaultik/internal/globals.Version=${VERSION}' -X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(git rev-parse HEAD 2>/dev/null || echo unknown)' -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(git show -s --format=%cI HEAD 2>/dev/null || echo unknown)'" -o /vaultik ./cmd/vaultik
|
||||
|
||||
# Runtime stage
|
||||
# alpine:3.21, 2026-02-25
|
||||
|
||||
4
Makefile
4
Makefile
@@ -5,10 +5,12 @@ VERSION := 1.0.0-rc.1
|
||||
|
||||
# Build variables
|
||||
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
GIT_COMMIT_DATE := $(shell git show -s --format=%cI HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
# Linker flags
|
||||
LDFLAGS := -X 'sneak.berlin/go/vaultik/internal/globals.Version=$(VERSION)' \
|
||||
-X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(GIT_REVISION)'
|
||||
-X 'sneak.berlin/go/vaultik/internal/globals.Commit=$(GIT_REVISION)' \
|
||||
-X 'sneak.berlin/go/vaultik/internal/globals.CommitDate=$(GIT_COMMIT_DATE)'
|
||||
|
||||
# Default target
|
||||
all: vaultik
|
||||
|
||||
59
README.md
59
README.md
@@ -389,6 +389,65 @@ Items for future releases:
|
||||
|
||||
---
|
||||
|
||||
## output style
|
||||
|
||||
All user-facing output goes through helpers in `internal/ui` and conforms
|
||||
to a uniform style. Color is enabled when stdout is a TTY and the
|
||||
`NO_COLOR` environment variable is unset (https://no-color.org/).
|
||||
|
||||
Message classes:
|
||||
|
||||
| Class | Marker | Alignment | Use for |
|
||||
|-------|--------|-----------|---------|
|
||||
| Banner | none | column 0 | The startup line printed once per invocation |
|
||||
| Begin | `》` (white) | column 0 | An operation is about to start (present-continuous verb) |
|
||||
| Complete | `》` (green) | column 0 | An operation just finished (past-tense verb) |
|
||||
| Info | `》` (white) | column 0 | Neutral status update |
|
||||
| Notice | `》` (cyan) | column 0 | Important note that is not a warning |
|
||||
| Warning | `Warning:` (orange/yellow) | column 0 | Recoverable problem |
|
||||
| Error | `ERROR:` (red) | column 0 | Operation aborted |
|
||||
| Progress | ` 》` (white) | column 2 | Heartbeat or per-item status during a long-running operation |
|
||||
|
||||
Conventions:
|
||||
|
||||
* Messages are complete English sentences ending with a period.
|
||||
* Fully qualify terms — say "backup destination store" instead of
|
||||
"storage", "snapshot source files enumeration" instead of "scan",
|
||||
"local index database" instead of "database".
|
||||
* Every operation that emits a Complete also emits a corresponding
|
||||
Begin. Operations that print only a Begin (because completion is
|
||||
obvious from a later Begin) should be rare and intentional.
|
||||
* Use natural verb tense to signal state: "Uploading" for Begin,
|
||||
"Uploaded" for Complete. Never write the words "begin" or "complete"
|
||||
in the body — the marker color already conveys that.
|
||||
* All elapsed and remaining-time fields are explicitly scoped to their
|
||||
subject: write "blob upload elapsed 30s, blob upload estimated remaining
|
||||
time (14s), finish at 2026-06-17T03:15:00Z", never just "elapsed 30s,
|
||||
ETA 14s".
|
||||
* "ETA" means an absolute clock time (when the operation will finish),
|
||||
not a remaining-duration. Use `ui.Time()` for the former and
|
||||
`ui.Duration()` for the latter, and label both.
|
||||
|
||||
Value colorizers in `internal/ui` colorize specific value types
|
||||
consistently. Compose messages from these helpers rather than embedding
|
||||
ANSI escapes inline:
|
||||
|
||||
| Helper | Color | Use for |
|
||||
|--------|-------|---------|
|
||||
| `Hex` | cyan | Blob hashes, chunk hashes (truncated to 12 chars + `...`) |
|
||||
| `Snapshot` | bold cyan | Snapshot IDs (untruncated) |
|
||||
| `Path` | blue | Filesystem paths |
|
||||
| `Size` | magenta | Byte counts (human-readable) |
|
||||
| `Speed` | magenta | Bytes-per-second rates |
|
||||
| `Duration` | yellow | Elapsed or remaining time |
|
||||
| `Time` | yellow | Absolute clock times |
|
||||
| `Count` | magenta | Integer counts with thousands separators |
|
||||
| `Percent` | magenta | Percentages |
|
||||
|
||||
When `NO_COLOR` is set or output is not a TTY, all helpers return plain
|
||||
text and the marker prefixes (`》`, `Warning:`, `ERROR:`) emit without
|
||||
ANSI escapes.
|
||||
|
||||
## requirements
|
||||
|
||||
* Go 1.26 or later
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
@@ -33,11 +35,21 @@ type AppOptions struct {
|
||||
Invokes []fx.Option
|
||||
}
|
||||
|
||||
// setupGlobals sets up the globals with application startup time
|
||||
func setupGlobals(lc fx.Lifecycle, g *globals.Globals) {
|
||||
// setupGlobals records the startup time and prints the startup banner.
|
||||
// In --cron mode the banner is suppressed (LogOptions.Cron == true).
|
||||
func setupGlobals(lc fx.Lifecycle, g *globals.Globals, v *vaultik.Vaultik, opts log.LogOptions) {
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
g.StartTime = time.Now().UTC()
|
||||
if opts.Cron || opts.Quiet {
|
||||
// Replace UI writer with a discarding one so all
|
||||
// user-facing output is suppressed.
|
||||
v.UI = ui.NewWithColor(io.Discard, false)
|
||||
} else {
|
||||
v.UI.Banner("%s %s (commit %s, %s) invoked at %s by %s",
|
||||
g.Appname, g.Version, g.ShortCommit(), g.CommitDate,
|
||||
g.StartTime.Format(time.RFC3339), globals.Author)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -73,9 +72,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`,
|
||||
OnStart: func(ctx context.Context) error {
|
||||
// Start the snapshot creation in a goroutine
|
||||
go func() {
|
||||
if opts.Cron {
|
||||
v.Stdout = io.Discard
|
||||
}
|
||||
// --cron suppression is wired through v.UI by setupGlobals.
|
||||
if err := v.CreateSnapshot(opts); err != nil {
|
||||
if err != context.Canceled {
|
||||
log.Error("Snapshot creation failed", "error", err)
|
||||
|
||||
@@ -13,11 +13,18 @@ var Version string = "dev"
|
||||
// Commit is the git commit hash, populated from main().
|
||||
var Commit string = "unknown"
|
||||
|
||||
// CommitDate is the ISO-8601 date of the commit, populated from main().
|
||||
var CommitDate string = "unknown"
|
||||
|
||||
// Author identifies the upstream author of vaultik.
|
||||
const Author = "Jeffrey Paul <sneak@sneak.berlin>"
|
||||
|
||||
// Globals contains application-wide configuration and metadata.
|
||||
type Globals struct {
|
||||
Appname string
|
||||
Version string
|
||||
Commit string
|
||||
CommitDate string
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
@@ -27,5 +34,15 @@ func New() (*Globals, error) {
|
||||
Appname: Appname,
|
||||
Version: Version,
|
||||
Commit: Commit,
|
||||
CommitDate: CommitDate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShortCommit returns the first 12 chars of the commit hash, or the
|
||||
// whole string if it's shorter (e.g. "unknown").
|
||||
func (g *Globals) ShortCommit() string {
|
||||
if len(g.Commit) > 12 {
|
||||
return g.Commit[:12]
|
||||
}
|
||||
return g.Commit
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// ScannerParams holds parameters for scanner creation
|
||||
type ScannerParams struct {
|
||||
EnableProgress bool
|
||||
Output io.Writer // Where one-off scanner messages go; nil disables them
|
||||
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
|
||||
Fs afero.Fs
|
||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||
@@ -49,7 +48,7 @@ func provideScannerFactory(cfg *config.Config, repos *database.Repositories, sto
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
EnableProgress: params.EnableProgress,
|
||||
Output: params.Output,
|
||||
UI: params.UI,
|
||||
Exclude: excludes,
|
||||
SkipErrors: params.SkipErrors,
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// FileToProcess holds information about a file that needs processing
|
||||
@@ -61,7 +62,7 @@ type Scanner struct {
|
||||
compiledExclude []compiledPattern // Compiled glob patterns
|
||||
progress *ProgressReporter
|
||||
skipErrors bool // Skip file read errors (log loudly but continue)
|
||||
output io.Writer // User-facing output (os.Stdout or io.Discard in cron mode)
|
||||
ui *ui.Writer // User-facing output; never nil (defaults to a discarding writer)
|
||||
|
||||
// In-memory cache of known chunk hashes for fast existence checks
|
||||
knownChunks map[string]struct{}
|
||||
@@ -94,7 +95,7 @@ type ScannerConfig struct {
|
||||
CompressionLevel int
|
||||
AgeRecipients []string // Optional, empty means no encryption
|
||||
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
|
||||
Output io.Writer // Where one-off scanner messages go; nil disables them
|
||||
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
|
||||
Exclude []string // Glob patterns for files/directories to exclude
|
||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||
}
|
||||
@@ -143,9 +144,9 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
||||
// Compile exclude patterns
|
||||
compiledExclude := compileExcludePatterns(cfg.Exclude)
|
||||
|
||||
output := cfg.Output
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
uiw := cfg.UI
|
||||
if uiw == nil {
|
||||
uiw = ui.NewWithColor(io.Discard, false)
|
||||
}
|
||||
|
||||
return &Scanner{
|
||||
@@ -161,7 +162,7 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
||||
compiledExclude: compiledExclude,
|
||||
progress: progress,
|
||||
skipErrors: cfg.SkipErrors,
|
||||
output: output,
|
||||
ui: uiw,
|
||||
pendingChunkHashes: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
@@ -212,7 +213,7 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc
|
||||
|
||||
// Phase 1c: Associate unchanged files with this snapshot (no new records needed)
|
||||
if len(scanResult.UnchangedFileIDs) > 0 {
|
||||
_, _ = fmt.Fprintf(s.output, "Associating %s unchanged files with snapshot...\n", formatNumber(len(scanResult.UnchangedFileIDs)))
|
||||
s.ui.Begin("Associating %s unchanged files with the snapshot.", s.ui.Count(len(scanResult.UnchangedFileIDs)))
|
||||
if err := s.batchAddFilesToSnapshot(ctx, scanResult.UnchangedFileIDs); err != nil {
|
||||
return nil, fmt.Errorf("associating unchanged files: %w", err)
|
||||
}
|
||||
@@ -223,13 +224,13 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc
|
||||
|
||||
// Phase 2: Process files and create chunks
|
||||
if len(filesToProcess) > 0 {
|
||||
_, _ = fmt.Fprintf(s.output, "Processing %s files...\n", formatNumber(len(filesToProcess)))
|
||||
s.ui.Begin("Processing %s snapshot source files (chunking, compressing, encrypting, uploading).", s.ui.Count(len(filesToProcess)))
|
||||
log.Info("Phase 2/3: Creating snapshot (chunking, compressing, encrypting, and uploading blobs)")
|
||||
if err := s.processPhase(ctx, filesToProcess, result); err != nil {
|
||||
return nil, fmt.Errorf("process phase failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(s.output, "No files need processing. Creating metadata-only snapshot.\n")
|
||||
s.ui.Info("Snapshot file processing skipped: no changed files (creating metadata-only snapshot).")
|
||||
log.Info("Phase 2/3: Skipping (no files need processing, metadata-only snapshot)")
|
||||
}
|
||||
|
||||
@@ -242,18 +243,18 @@ func (s *Scanner) Scan(ctx context.Context, path string, snapshotID string) (*Sc
|
||||
// loadDatabaseState loads known files and chunks from the database into memory for fast lookup
|
||||
// This avoids per-file and per-chunk database queries during the scan and process phases
|
||||
func (s *Scanner) loadDatabaseState(ctx context.Context, path string) (map[string]*database.File, error) {
|
||||
_, _ = fmt.Fprintln(s.output, "Loading known files from database...")
|
||||
s.ui.Begin("Loading known files from local index database.")
|
||||
knownFiles, err := s.loadKnownFiles(ctx, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading known files: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(s.output, "Loaded %s known files from database\n", formatNumber(len(knownFiles)))
|
||||
s.ui.Complete("Loaded %s known files from local index database.", s.ui.Count(len(knownFiles)))
|
||||
|
||||
_, _ = fmt.Fprintln(s.output, "Loading known chunks from database...")
|
||||
s.ui.Begin("Loading known chunks from local index database.")
|
||||
if err := s.loadKnownChunks(ctx); err != nil {
|
||||
return nil, fmt.Errorf("loading known chunks: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(s.output, "Loaded %s known chunks from database\n", formatNumber(len(s.knownChunks)))
|
||||
s.ui.Complete("Loaded %s known chunks from local index database.", s.ui.Count(len(s.knownChunks)))
|
||||
|
||||
return knownFiles, nil
|
||||
}
|
||||
@@ -277,17 +278,17 @@ func (s *Scanner) summarizeScanPhase(result *ScanResult, filesToProcess []*FileT
|
||||
"files_skipped", result.FilesSkipped,
|
||||
"bytes_skipped", humanize.Bytes(uint64(result.BytesSkipped)))
|
||||
|
||||
_, _ = fmt.Fprintf(s.output, "Scan complete: %s examined (%s), %s to process (%s)",
|
||||
formatNumber(result.FilesScanned),
|
||||
humanize.Bytes(uint64(totalSizeToProcess+result.BytesSkipped)),
|
||||
formatNumber(len(filesToProcess)),
|
||||
humanize.Bytes(uint64(totalSizeToProcess)))
|
||||
msg := fmt.Sprintf("Enumerated %s snapshot source files (%s total), %s to process (%s)",
|
||||
s.ui.Count(result.FilesScanned),
|
||||
s.ui.Size(totalSizeToProcess+result.BytesSkipped),
|
||||
s.ui.Count(len(filesToProcess)),
|
||||
s.ui.Size(totalSizeToProcess))
|
||||
if result.FilesDeleted > 0 {
|
||||
_, _ = fmt.Fprintf(s.output, ", %s deleted (%s)",
|
||||
formatNumber(result.FilesDeleted),
|
||||
humanize.Bytes(uint64(result.BytesDeleted)))
|
||||
msg += fmt.Sprintf(", %s deleted (%s)",
|
||||
s.ui.Count(result.FilesDeleted),
|
||||
s.ui.Size(result.BytesDeleted))
|
||||
}
|
||||
_, _ = fmt.Fprintln(s.output)
|
||||
s.ui.Complete("%s.", msg)
|
||||
}
|
||||
|
||||
// finalizeScanResult populates final blob statistics in the scan result
|
||||
@@ -628,8 +629,8 @@ func (s *Scanner) scanPhase(ctx context.Context, path string, result *ScanResult
|
||||
err := afero.Walk(s.fs, path, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
if s.skipErrors {
|
||||
log.Error("ERROR: Failed to access file (skipping due to --skip-errors)", "path", filePath, "error", err)
|
||||
_, _ = fmt.Fprintf(s.output, "ERROR: Failed to access %s: %v (skipping)\n", filePath, err)
|
||||
log.Error("Failed to access file (skipping due to --skip-errors)", "path", filePath, "error", err)
|
||||
s.ui.Error("Failed to access %s: %v. Skipping (--skip-errors).", s.ui.Path(filePath), err)
|
||||
return nil // Continue scanning
|
||||
}
|
||||
log.Debug("Error accessing filesystem entry", "path", filePath, "error", err)
|
||||
@@ -775,23 +776,29 @@ func (s *Scanner) printScanProgressLine(filesScanned int64, changedCount int, es
|
||||
if rate > 0 && remaining > 0 {
|
||||
eta = time.Duration(float64(remaining)/rate) * time.Second
|
||||
}
|
||||
_, _ = fmt.Fprintf(s.output, "Scan: %s files (~%.0f%%), %s changed/new, %.0f files/sec, %s elapsed",
|
||||
formatNumber(int(filesScanned)),
|
||||
pct,
|
||||
formatNumber(changedCount),
|
||||
rate,
|
||||
elapsed.Round(time.Second))
|
||||
if eta > 0 {
|
||||
_, _ = fmt.Fprintf(s.output, ", ETA %s", eta.Round(time.Second))
|
||||
}
|
||||
_, _ = fmt.Fprintln(s.output)
|
||||
} else {
|
||||
// First backup - no estimate available
|
||||
_, _ = fmt.Fprintf(s.output, "Scan: %s files, %s changed/new, %.0f files/sec, %s elapsed\n",
|
||||
formatNumber(int(filesScanned)),
|
||||
formatNumber(changedCount),
|
||||
s.ui.Progress("Snapshot source files enumeration: %s files (~%s), %s changed or new, %.0f files/sec, enumeration elapsed %s, enumeration estimated remaining time (%s), finish at %s.",
|
||||
s.ui.Count(int(filesScanned)),
|
||||
s.ui.Percent(pct),
|
||||
s.ui.Count(changedCount),
|
||||
rate,
|
||||
elapsed.Round(time.Second))
|
||||
s.ui.Duration(elapsed),
|
||||
s.ui.Duration(eta),
|
||||
s.ui.Time(time.Now().Add(eta)))
|
||||
} else {
|
||||
s.ui.Progress("Snapshot source files enumeration: %s files (~%s), %s changed or new, %.0f files/sec, enumeration elapsed %s.",
|
||||
s.ui.Count(int(filesScanned)),
|
||||
s.ui.Percent(pct),
|
||||
s.ui.Count(changedCount),
|
||||
rate,
|
||||
s.ui.Duration(elapsed))
|
||||
}
|
||||
} else {
|
||||
s.ui.Progress("Snapshot source files enumeration: %s files seen, %s changed or new, %.0f files/sec, enumeration elapsed %s.",
|
||||
s.ui.Count(int(filesScanned)),
|
||||
s.ui.Count(changedCount),
|
||||
rate,
|
||||
s.ui.Duration(elapsed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,16 +964,16 @@ func (s *Scanner) batchAddFilesToSnapshot(ctx context.Context, fileIDs []types.F
|
||||
elapsed := time.Since(startTime)
|
||||
rate := float64(end) / elapsed.Seconds()
|
||||
pct := float64(end) / float64(len(fileIDs)) * 100
|
||||
_, _ = fmt.Fprintf(s.output, "Associating files: %s/%s (%.1f%%), %.0f files/sec\n",
|
||||
formatNumber(end), formatNumber(len(fileIDs)), pct, rate)
|
||||
s.ui.Progress("Snapshot unchanged-file association: %s/%s (%s), %.0f files/sec.",
|
||||
s.ui.Count(end), s.ui.Count(len(fileIDs)), s.ui.Percent(pct), rate)
|
||||
lastStatusTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
rate := float64(len(fileIDs)) / elapsed.Seconds()
|
||||
_, _ = fmt.Fprintf(s.output, "Associated %s unchanged files in %s (%.0f files/sec)\n",
|
||||
formatNumber(len(fileIDs)), elapsed.Round(time.Second), rate)
|
||||
s.ui.Complete("Associated %s unchanged files with the snapshot in %s (%.0f files/sec).",
|
||||
s.ui.Count(len(fileIDs)), s.ui.Duration(elapsed), rate)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1034,8 +1041,8 @@ func (s *Scanner) processFileWithErrorHandling(ctx context.Context, fileToProces
|
||||
}
|
||||
// Skip file read errors if --skip-errors is enabled
|
||||
if s.skipErrors {
|
||||
log.Error("ERROR: Failed to process file (skipping due to --skip-errors)", "path", fileToProcess.Path, "error", err)
|
||||
_, _ = fmt.Fprintf(s.output, "ERROR: Failed to process %s: %v (skipping)\n", fileToProcess.Path, err)
|
||||
log.Error("Failed to process file (skipping due to --skip-errors)", "path", fileToProcess.Path, "error", err)
|
||||
s.ui.Error("Failed to process %s: %v. Skipping (--skip-errors).", s.ui.Path(fileToProcess.Path), err)
|
||||
result.FilesSkipped++
|
||||
return true, nil
|
||||
}
|
||||
@@ -1059,20 +1066,29 @@ func (s *Scanner) printProcessingProgress(filesProcessed, totalFiles int, bytesP
|
||||
eta = time.Duration(float64(remainingBytes)/byteRate) * time.Second
|
||||
}
|
||||
|
||||
// Format: Progress [5.7k/610k] 6.7 GB/44 GB (15.4%), 106MB/sec, 500 files/sec, running for 1m30s, ETA: 5m49s
|
||||
_, _ = fmt.Fprintf(s.output, "Progress [%s/%s] %s/%s (%.1f%%), %s/sec, %.0f files/sec, running for %s",
|
||||
formatCompact(filesProcessed),
|
||||
formatCompact(totalFiles),
|
||||
humanize.Bytes(uint64(bytesProcessed)),
|
||||
humanize.Bytes(uint64(totalBytes)),
|
||||
pct,
|
||||
humanize.Bytes(uint64(byteRate)),
|
||||
fileRate,
|
||||
elapsed.Round(time.Second))
|
||||
if eta > 0 {
|
||||
_, _ = fmt.Fprintf(s.output, ", ETA: %s", eta.Round(time.Second))
|
||||
s.ui.Progress("Snapshot file processing: %s/%s files (%s), %s/%s, %s, %.0f files/sec, processing elapsed %s, processing estimated remaining time (%s), finish at %s.",
|
||||
s.ui.Count(filesProcessed),
|
||||
s.ui.Count(totalFiles),
|
||||
s.ui.Percent(pct),
|
||||
s.ui.Size(bytesProcessed),
|
||||
s.ui.Size(totalBytes),
|
||||
s.ui.Speed(byteRate),
|
||||
fileRate,
|
||||
s.ui.Duration(elapsed),
|
||||
s.ui.Duration(eta),
|
||||
s.ui.Time(time.Now().Add(eta)))
|
||||
} else {
|
||||
s.ui.Progress("Snapshot file processing: %s/%s files (%s), %s/%s, %s, %.0f files/sec, processing elapsed %s.",
|
||||
s.ui.Count(filesProcessed),
|
||||
s.ui.Count(totalFiles),
|
||||
s.ui.Percent(pct),
|
||||
s.ui.Size(bytesProcessed),
|
||||
s.ui.Size(totalBytes),
|
||||
s.ui.Speed(byteRate),
|
||||
fileRate,
|
||||
s.ui.Duration(elapsed))
|
||||
}
|
||||
_, _ = fmt.Fprintln(s.output)
|
||||
}
|
||||
|
||||
// finalizeProcessPhase flushes the packer, writes remaining pending files to the database,
|
||||
@@ -1164,14 +1180,13 @@ func (s *Scanner) uploadBlobIfNeeded(ctx context.Context, blobPath string, blobW
|
||||
if _, err := s.storage.Stat(ctx, blobPath); err == nil {
|
||||
log.Info("Blob already exists in storage, skipping upload",
|
||||
"hash", finishedBlob.Hash, "size", humanize.Bytes(uint64(finishedBlob.Compressed)))
|
||||
_, _ = fmt.Fprintf(s.output, "Blob exists: %s (%s, skipped upload)\n",
|
||||
finishedBlob.Hash[:12]+"...", humanize.Bytes(uint64(finishedBlob.Compressed)))
|
||||
s.ui.Info("Blob %s (%s) already exists in backup destination store. Skipping upload.",
|
||||
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(s.output, "Uploading blob: %s (%s)\n",
|
||||
finishedBlob.Hash[:12]+"...",
|
||||
humanize.Bytes(uint64(finishedBlob.Compressed)))
|
||||
s.ui.Begin("Uploading blob %s (%s) to backup destination store.",
|
||||
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed))
|
||||
|
||||
progressCallback := s.makeUploadProgressCallback(ctx, finishedBlob, startTime)
|
||||
|
||||
@@ -1183,11 +1198,11 @@ func (s *Scanner) uploadBlobIfNeeded(ctx context.Context, blobPath string, blobW
|
||||
uploadDuration := time.Since(startTime)
|
||||
uploadSpeedBps := float64(finishedBlob.Compressed) / uploadDuration.Seconds()
|
||||
|
||||
_, _ = fmt.Fprintf(s.output, "Blob stored: %s (%s, %s/sec, %s)\n",
|
||||
finishedBlob.Hash[:12]+"...",
|
||||
humanize.Bytes(uint64(finishedBlob.Compressed)),
|
||||
humanize.Bytes(uint64(uploadSpeedBps)),
|
||||
uploadDuration.Round(time.Millisecond))
|
||||
s.ui.Complete("Uploaded blob %s (%s) in %s at %s.",
|
||||
s.ui.Hex(finishedBlob.Hash),
|
||||
s.ui.Size(finishedBlob.Compressed),
|
||||
s.ui.Duration(uploadDuration),
|
||||
s.ui.Speed(uploadSpeedBps))
|
||||
|
||||
log.Info("Successfully uploaded blob to storage",
|
||||
"path", blobPath,
|
||||
@@ -1236,14 +1251,15 @@ func (s *Scanner) makeUploadProgressCallback(ctx context.Context, finishedBlob *
|
||||
if avgSpeed > 0 {
|
||||
eta = time.Duration(float64(finishedBlob.Compressed-uploaded)/avgSpeed) * time.Second
|
||||
}
|
||||
_, _ = fmt.Fprintf(s.output, " blob upload %s: %s/%s (%.0f%%) at %s/sec, blob upload elapsed %s, blob upload ETA %s\n",
|
||||
finishedBlob.Hash[:12]+"...",
|
||||
humanize.Bytes(uint64(uploaded)),
|
||||
humanize.Bytes(uint64(finishedBlob.Compressed)),
|
||||
pct,
|
||||
humanize.Bytes(uint64(avgSpeed)),
|
||||
totalElapsed.Round(time.Second),
|
||||
eta.Round(time.Second))
|
||||
s.ui.Progress("Blob upload %s: %s / %s (%s) at %s, blob upload elapsed %s, blob upload estimated remaining time (%s), finish at %s.",
|
||||
s.ui.Hex(finishedBlob.Hash),
|
||||
s.ui.Size(uploaded),
|
||||
s.ui.Size(finishedBlob.Compressed),
|
||||
s.ui.Percent(pct),
|
||||
s.ui.Speed(avgSpeed),
|
||||
s.ui.Duration(totalElapsed),
|
||||
s.ui.Duration(eta),
|
||||
s.ui.Time(now.Add(eta)))
|
||||
lastStdoutTime = now
|
||||
}
|
||||
|
||||
@@ -1472,7 +1488,7 @@ func (s *Scanner) detectDeletedFilesFromMap(ctx context.Context, knownFiles map[
|
||||
}
|
||||
|
||||
if result.FilesDeleted > 0 {
|
||||
_, _ = fmt.Fprintf(s.output, "Found %s deleted files\n", formatNumber(result.FilesDeleted))
|
||||
s.ui.Info("Snapshot source files enumeration detected %s deleted files.", s.ui.Count(result.FilesDeleted))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1594,25 +1610,3 @@ func (s *Scanner) shouldExclude(filePath, rootPath string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// formatNumber formats a number with comma separators
|
||||
func formatNumber(n int) string {
|
||||
if n < 1000 {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
return humanize.Comma(int64(n))
|
||||
}
|
||||
|
||||
// formatCompact formats a number compactly with k/M suffixes (e.g., 5.7k, 1.2M)
|
||||
func formatCompact(n int) string {
|
||||
if n < 1000 {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
if n < 10000 {
|
||||
return fmt.Sprintf("%.1fk", float64(n)/1000)
|
||||
}
|
||||
if n < 1000000 {
|
||||
return fmt.Sprintf("%.0fk", float64(n)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.1fM", float64(n)/1000000)
|
||||
}
|
||||
|
||||
204
internal/ui/ui.go
Normal file
204
internal/ui/ui.go
Normal file
@@ -0,0 +1,204 @@
|
||||
// Package ui provides consistent user-facing output formatting for vaultik.
|
||||
// All status updates, banners, errors, and warnings printed to the user
|
||||
// should go through a *Writer from this package.
|
||||
//
|
||||
// Message classes (see Writer methods):
|
||||
//
|
||||
// - Begin — operation start, left-aligned, marker "》" (white)
|
||||
// - Complete— operation completion, left-aligned, marker "》" (green)
|
||||
// - Info — left-aligned neutral status, marker "》" (white)
|
||||
// - Notice — left-aligned important note, marker "》" (cyan)
|
||||
// - Warning — left-aligned warning, full word "Warning: " (orange/yellow)
|
||||
// - Error — left-aligned error, full word "ERROR: " (red)
|
||||
// - Progress— indented heartbeat / per-item update, marker " 》" (white)
|
||||
// - Banner — application banner line, left-aligned, no marker
|
||||
//
|
||||
// Value formatters (Hex, Size, Duration, Time, Path, Snapshot, Speed,
|
||||
// Count, Percent) return ANSI-colored strings the caller composes into
|
||||
// the message body. When color is disabled (non-TTY output or NO_COLOR
|
||||
// set) all formatters return plain text.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// ANSI SGR escape sequences.
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiBold = "\033[1m"
|
||||
ansiRed = "\033[31m"
|
||||
ansiGreen = "\033[32m"
|
||||
ansiYellow = "\033[33m" // used for orange "Warning:" and for durations
|
||||
ansiBlue = "\033[34m"
|
||||
ansiMagenta = "\033[35m"
|
||||
ansiCyan = "\033[36m"
|
||||
ansiWhite = "\033[37m"
|
||||
)
|
||||
|
||||
// Marker is the chevron prefix used for all non-error/warning lines.
|
||||
const Marker = "》"
|
||||
|
||||
// Writer formats and emits user-facing messages with optional ANSI color.
|
||||
type Writer struct {
|
||||
out io.Writer
|
||||
color bool
|
||||
}
|
||||
|
||||
// New returns a Writer that emits to out. Color is enabled when out is a
|
||||
// TTY and the NO_COLOR environment variable is unset.
|
||||
// https://no-color.org/
|
||||
func New(out io.Writer) *Writer {
|
||||
return &Writer{out: out, color: shouldColor(out)}
|
||||
}
|
||||
|
||||
// NewWithColor returns a Writer with an explicit color setting, ignoring
|
||||
// TTY detection. Useful for tests and for piped output that the caller
|
||||
// wants to colorize anyway.
|
||||
func NewWithColor(out io.Writer, color bool) *Writer {
|
||||
return &Writer{out: out, color: color}
|
||||
}
|
||||
|
||||
// Out returns the underlying writer.
|
||||
func (w *Writer) Out() io.Writer { return w.out }
|
||||
|
||||
// Color reports whether color is enabled on this writer.
|
||||
func (w *Writer) Color() bool { return w.color }
|
||||
|
||||
// shouldColor returns true when w is a real TTY and NO_COLOR is unset.
|
||||
func shouldColor(w io.Writer) bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
f, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// paint wraps s in the given ANSI color when color is enabled.
|
||||
func (w *Writer) paint(color, s string) string {
|
||||
if !w.color {
|
||||
return s
|
||||
}
|
||||
return color + s + ansiReset
|
||||
}
|
||||
|
||||
// ───────────────────────── message methods ─────────────────────────
|
||||
|
||||
// Begin prints an operation-start line, left-aligned with a white marker.
|
||||
func (w *Writer) Begin(format string, args ...any) {
|
||||
w.emit(ansiWhite, Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Complete prints an operation-completion line in green, left-aligned.
|
||||
func (w *Writer) Complete(format string, args ...any) {
|
||||
w.emit(ansiGreen, Marker, ansiGreen, format, args)
|
||||
}
|
||||
|
||||
// Info prints a neutral status line, left-aligned with a white marker.
|
||||
func (w *Writer) Info(format string, args ...any) {
|
||||
w.emit(ansiWhite, Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Notice prints an attention-worthy informational line, marker in cyan.
|
||||
func (w *Writer) Notice(format string, args ...any) {
|
||||
w.emit(ansiCyan, Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Warning prints "Warning: " in orange/yellow followed by the message.
|
||||
func (w *Writer) Warning(format string, args ...any) {
|
||||
prefix := w.paint(ansiYellow+ansiBold, "Warning: ")
|
||||
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Error prints "ERROR: " in red followed by the message. Goes to the same
|
||||
// writer as everything else; callers that want stderr should construct a
|
||||
// separate Writer for it.
|
||||
func (w *Writer) Error(format string, args ...any) {
|
||||
prefix := w.paint(ansiRed+ansiBold, "ERROR: ")
|
||||
_, _ = fmt.Fprintln(w.out, prefix+fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Progress prints an indented heartbeat / per-item update, marker in white.
|
||||
func (w *Writer) Progress(format string, args ...any) {
|
||||
w.emit(ansiWhite, " "+Marker, "", format, args)
|
||||
}
|
||||
|
||||
// Banner prints a line with no marker, left-aligned. Used for the
|
||||
// application startup banner only.
|
||||
func (w *Writer) Banner(format string, args ...any) {
|
||||
_, _ = fmt.Fprintln(w.out, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// emit writes "<prefix> <body>\n" with the prefix painted in prefixColor
|
||||
// and the body optionally painted in bodyColor (empty = no body color).
|
||||
func (w *Writer) emit(prefixColor, prefix, bodyColor, format string, args []any) {
|
||||
body := fmt.Sprintf(format, args...)
|
||||
if bodyColor != "" {
|
||||
body = w.paint(bodyColor, body)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w.out, w.paint(prefixColor, prefix)+" "+body)
|
||||
}
|
||||
|
||||
// ───────────────────────── value formatters ─────────────────────────
|
||||
//
|
||||
// These return ANSI-colored strings the caller composes into a message
|
||||
// body. When color is disabled they return plain text.
|
||||
|
||||
// Hex colorizes a hex identifier (blob hash, chunk hash, snapshot id).
|
||||
// Long hashes are abbreviated to first 12 chars with "...".
|
||||
func (w *Writer) Hex(s string) string {
|
||||
short := s
|
||||
if len(s) > 12 {
|
||||
short = s[:12] + "..."
|
||||
}
|
||||
return w.paint(ansiCyan, short)
|
||||
}
|
||||
|
||||
// Snapshot colorizes a snapshot ID (full, no abbreviation).
|
||||
func (w *Writer) Snapshot(id string) string {
|
||||
return w.paint(ansiCyan+ansiBold, id)
|
||||
}
|
||||
|
||||
// Path colorizes a filesystem path.
|
||||
func (w *Writer) Path(p string) string {
|
||||
return w.paint(ansiBlue, p)
|
||||
}
|
||||
|
||||
// Size colorizes a byte count using humanize.Bytes.
|
||||
func (w *Writer) Size(bytes int64) string {
|
||||
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytes)))
|
||||
}
|
||||
|
||||
// Speed colorizes a byte-per-second value as "<size>/sec".
|
||||
func (w *Writer) Speed(bytesPerSec float64) string {
|
||||
return w.paint(ansiMagenta, humanize.Bytes(uint64(bytesPerSec))+"/sec")
|
||||
}
|
||||
|
||||
// Duration colorizes a time.Duration rounded to the nearest second.
|
||||
func (w *Writer) Duration(d time.Duration) string {
|
||||
return w.paint(ansiYellow, d.Round(time.Second).String())
|
||||
}
|
||||
|
||||
// Time colorizes an absolute time (RFC3339, second precision).
|
||||
func (w *Writer) Time(t time.Time) string {
|
||||
return w.paint(ansiYellow, t.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Count colorizes an integer count with thousands separators.
|
||||
func (w *Writer) Count(n int) string {
|
||||
return w.paint(ansiMagenta, humanize.Comma(int64(n)))
|
||||
}
|
||||
|
||||
// Percent colorizes a 0..100 percentage.
|
||||
func (w *Writer) Percent(p float64) string {
|
||||
return w.paint(ansiMagenta, fmt.Sprintf("%.1f%%", p))
|
||||
}
|
||||
86
internal/ui/ui_test.go
Normal file
86
internal/ui/ui_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestWriter(color bool) (*Writer, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return NewWithColor(buf, color), buf
|
||||
}
|
||||
|
||||
func TestMessageMethodsPlain(t *testing.T) {
|
||||
tests := []struct {
|
||||
method string
|
||||
fn func(*Writer)
|
||||
want string
|
||||
}{
|
||||
{"Begin", func(w *Writer) { w.Begin("starting %s", "thing") }, "》 starting thing\n"},
|
||||
{"Complete", func(w *Writer) { w.Complete("done %s", "thing") }, "》 done thing\n"},
|
||||
{"Info", func(w *Writer) { w.Info("status") }, "》 status\n"},
|
||||
{"Notice", func(w *Writer) { w.Notice("note") }, "》 note\n"},
|
||||
{"Warning", func(w *Writer) { w.Warning("oops") }, "Warning: oops\n"},
|
||||
{"Error", func(w *Writer) { w.Error("boom") }, "ERROR: boom\n"},
|
||||
{"Progress", func(w *Writer) { w.Progress("p") }, " 》 p\n"},
|
||||
{"Banner", func(w *Writer) { w.Banner("hello") }, "hello\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method, func(t *testing.T) {
|
||||
w, buf := newTestWriter(false)
|
||||
tt.fn(w)
|
||||
if got := buf.String(); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorOutputContainsANSI(t *testing.T) {
|
||||
w, buf := newTestWriter(true)
|
||||
w.Error("boom")
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "\033[") {
|
||||
t.Errorf("expected ANSI escapes in color output, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "ERROR: ") {
|
||||
t.Errorf("expected 'ERROR: ' text in output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueFormattersPlain(t *testing.T) {
|
||||
w, _ := newTestWriter(false)
|
||||
|
||||
if got := w.Hex("0123456789abcdef0123"); got != "0123456789ab..." {
|
||||
t.Errorf("Hex long: got %q", got)
|
||||
}
|
||||
if got := w.Hex("short"); got != "short" {
|
||||
t.Errorf("Hex short: got %q", got)
|
||||
}
|
||||
if got := w.Size(1024); got != "1.0 kB" {
|
||||
t.Errorf("Size: got %q", got)
|
||||
}
|
||||
if got := w.Duration(90 * time.Second); got != "1m30s" {
|
||||
t.Errorf("Duration: got %q", got)
|
||||
}
|
||||
if got := w.Count(12345); got != "12,345" {
|
||||
t.Errorf("Count: got %q", got)
|
||||
}
|
||||
if got := w.Percent(12.34); got != "12.3%" {
|
||||
t.Errorf("Percent: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueFormattersColored(t *testing.T) {
|
||||
w, _ := newTestWriter(true)
|
||||
hex := w.Hex("0123456789abcdef0123")
|
||||
if !strings.Contains(hex, "\033[") {
|
||||
t.Errorf("expected ANSI in colored Hex output, got %q", hex)
|
||||
}
|
||||
if !strings.Contains(hex, "0123456789ab") {
|
||||
t.Errorf("expected hex content in output, got %q", hex)
|
||||
}
|
||||
}
|
||||
@@ -17,37 +17,6 @@ type SnapshotInfo struct {
|
||||
CompressedSize int64 `json:"compressed_size"`
|
||||
}
|
||||
|
||||
// formatNumber formats a number with commas
|
||||
func formatNumber(n int) string {
|
||||
str := fmt.Sprintf("%d", n)
|
||||
var result []string
|
||||
for i, digit := range str {
|
||||
if i > 0 && (len(str)-i)%3 == 0 {
|
||||
result = append(result, ",")
|
||||
}
|
||||
result = append(result, string(digit))
|
||||
}
|
||||
return strings.Join(result, "")
|
||||
}
|
||||
|
||||
// formatDuration formats a duration in a human-readable way
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Second {
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
}
|
||||
if d < time.Hour {
|
||||
mins := int(d.Minutes())
|
||||
secs := int(d.Seconds()) % 60
|
||||
return fmt.Sprintf("%dm %ds", mins, secs)
|
||||
}
|
||||
hours := int(d.Hours())
|
||||
mins := int(d.Minutes()) % 60
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
}
|
||||
|
||||
// formatBytes formats bytes in a human-readable format
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
|
||||
@@ -83,7 +83,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
|
||||
// Print overall summary if multiple snapshots
|
||||
if len(snapshotNames) > 1 {
|
||||
v.printfStdout("\nAll %d snapshots completed in %s\n", len(snapshotNames), time.Since(overallStartTime).Round(time.Second))
|
||||
v.UI.Complete("All %d snapshots completed in %s.", len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
|
||||
}
|
||||
|
||||
if opts.Prune {
|
||||
@@ -101,7 +101,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
// snapshot of each name is kept.
|
||||
func (v *Vaultik) runPostBackupPrune(snapshotNames []string, keepNewerThan string) error {
|
||||
log.Info("Running post-backup prune", "snapshots", snapshotNames, "keep_newer_than", keepNewerThan)
|
||||
v.printlnStdout("\n=== Post-backup prune ===")
|
||||
v.UI.Begin("Running post-backup prune.")
|
||||
|
||||
purgeOpts := &SnapshotPurgeOptions{
|
||||
Force: true,
|
||||
@@ -146,7 +146,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
|
||||
snapshotStartTime := time.Now()
|
||||
|
||||
if total > 1 {
|
||||
v.printfStdout("\n=== Snapshot %d/%d: %s ===\n", idx, total, snapName)
|
||||
v.UI.Info("Snapshot %d/%d: %s.", idx, total, snapName)
|
||||
}
|
||||
|
||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||
@@ -156,7 +156,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
|
||||
|
||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||
EnableProgress: !opts.Cron,
|
||||
Output: v.Stdout,
|
||||
UI: v.UI,
|
||||
Fs: v.Fs,
|
||||
Exclude: v.Config.GetExcludes(snapName),
|
||||
SkipErrors: opts.SkipErrors,
|
||||
@@ -167,7 +167,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
|
||||
return fmt.Errorf("creating snapshot: %w", err)
|
||||
}
|
||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||
v.printfStdout("Beginning snapshot: %s\n", snapshotID)
|
||||
v.UI.Begin("Creating snapshot %s.", v.UI.Snapshot(snapshotID))
|
||||
|
||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||
if err != nil {
|
||||
@@ -231,7 +231,7 @@ func (v *Vaultik) scanAllDirectories(scanner *snapshot.Scanner, resolvedDirs []s
|
||||
}
|
||||
|
||||
log.Info("Scanning directory", "path", dir)
|
||||
v.printfStdout("Beginning directory scan (%d/%d): %s\n", i+1, len(resolvedDirs), dir)
|
||||
v.UI.Begin("Enumerating snapshot source files in %s (%d of %d).", v.UI.Path(dir), i+1, len(resolvedDirs))
|
||||
result, err := scanner.Scan(v.ctx, dir, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan %s: %w", dir, err)
|
||||
@@ -335,35 +335,36 @@ func (v *Vaultik) printSnapshotSummary(snapshotID string, startTime time.Time, s
|
||||
compressionRatio = 1.0
|
||||
}
|
||||
|
||||
v.printfStdout("=== Snapshot Complete ===\n")
|
||||
v.printfStdout("ID: %s\n", snapshotID)
|
||||
v.printfStdout("Files: %s examined, %s to process, %s unchanged",
|
||||
formatNumber(stats.totalFiles),
|
||||
formatNumber(totalFilesChanged),
|
||||
formatNumber(stats.totalFilesSkipped))
|
||||
v.UI.Complete("Created snapshot %s.", v.UI.Snapshot(snapshotID))
|
||||
filesMsg := fmt.Sprintf("Files: %s examined, %s to process, %s unchanged",
|
||||
v.UI.Count(stats.totalFiles),
|
||||
v.UI.Count(totalFilesChanged),
|
||||
v.UI.Count(stats.totalFilesSkipped))
|
||||
if stats.totalFilesDeleted > 0 {
|
||||
v.printfStdout(", %s deleted", formatNumber(stats.totalFilesDeleted))
|
||||
filesMsg += fmt.Sprintf(", %s deleted", v.UI.Count(stats.totalFilesDeleted))
|
||||
}
|
||||
v.printlnStdout()
|
||||
v.printfStdout("Data: %s total (%s to process)",
|
||||
humanize.Bytes(uint64(totalBytesAll)),
|
||||
humanize.Bytes(uint64(stats.totalBytes)))
|
||||
v.UI.Info("%s.", filesMsg)
|
||||
|
||||
dataMsg := fmt.Sprintf("Data: %s total (%s to process)",
|
||||
v.UI.Size(totalBytesAll),
|
||||
v.UI.Size(stats.totalBytes))
|
||||
if stats.totalBytesDeleted > 0 {
|
||||
v.printfStdout(", %s deleted", humanize.Bytes(uint64(stats.totalBytesDeleted)))
|
||||
dataMsg += fmt.Sprintf(", %s deleted", v.UI.Size(stats.totalBytesDeleted))
|
||||
}
|
||||
v.printlnStdout()
|
||||
v.UI.Info("%s.", dataMsg)
|
||||
|
||||
if stats.totalBlobsUploaded > 0 {
|
||||
v.printfStdout("Storage: %s compressed from %s (%.2fx)\n",
|
||||
humanize.Bytes(uint64(totalBlobSizeCompressed)),
|
||||
humanize.Bytes(uint64(totalBlobSizeUncompressed)),
|
||||
v.UI.Info("Storage: %s compressed from %s (%.2fx ratio).",
|
||||
v.UI.Size(totalBlobSizeCompressed),
|
||||
v.UI.Size(totalBlobSizeUncompressed),
|
||||
compressionRatio)
|
||||
v.printfStdout("Upload: %d blobs, %s in %s (%s)\n",
|
||||
v.UI.Info("Upload: %d blobs, %s in %s (%s).",
|
||||
stats.totalBlobsUploaded,
|
||||
humanize.Bytes(uint64(stats.totalBytesUploaded)),
|
||||
formatDuration(stats.uploadDuration),
|
||||
v.UI.Size(stats.totalBytesUploaded),
|
||||
v.UI.Duration(stats.uploadDuration),
|
||||
formatUploadSpeed(stats.totalBytesUploaded, stats.uploadDuration))
|
||||
}
|
||||
v.printfStdout("Duration: %s\n", formatDuration(snapshotDuration))
|
||||
v.UI.Info("Snapshot create duration: %s.", v.UI.Duration(snapshotDuration))
|
||||
}
|
||||
|
||||
// getSnapshotBlobSizes returns total compressed and uncompressed blob sizes for a snapshot
|
||||
@@ -422,11 +423,11 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
||||
}
|
||||
}
|
||||
if len(stale) > 0 {
|
||||
v.printfStdout("\nWarning: %d local snapshot(s) not found in remote storage:\n", len(stale))
|
||||
v.UI.Warning("%d local snapshot record(s) not found in backup destination store:", len(stale))
|
||||
for _, id := range stale {
|
||||
v.printfStdout(" %s\n", id)
|
||||
v.UI.Info("%s", v.UI.Snapshot(id))
|
||||
}
|
||||
v.printlnStdout("Run 'vaultik snapshot cleanup' to remove stale local records.")
|
||||
v.UI.Info("Run 'vaultik snapshot cleanup' to remove stale local records.")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1272,6 +1273,7 @@ type PruneResult struct {
|
||||
// before starting a new backup or on-demand via the prune command.
|
||||
func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
||||
log.Info("Pruning local database: removing incomplete snapshots and orphaned data")
|
||||
v.UI.Begin("Pruning local index database (removing incomplete snapshots and orphaned data).")
|
||||
|
||||
result := &PruneResult{}
|
||||
|
||||
@@ -1327,12 +1329,8 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
||||
"orphaned_blobs", result.BlobsDeleted,
|
||||
)
|
||||
|
||||
// Print summary
|
||||
v.printfStdout("Local database prune complete:\n")
|
||||
v.printfStdout(" Incomplete snapshots removed: %d\n", result.SnapshotsDeleted)
|
||||
v.printfStdout(" Orphaned files removed: %d\n", result.FilesDeleted)
|
||||
v.printfStdout(" Orphaned chunks removed: %d\n", result.ChunksDeleted)
|
||||
v.printfStdout(" Orphaned blobs removed: %d\n", result.BlobsDeleted)
|
||||
v.UI.Complete("Pruned local index database: %d incomplete snapshots, %d orphaned files, %d orphaned chunks, %d orphaned blobs removed.",
|
||||
result.SnapshotsDeleted, result.FilesDeleted, result.ChunksDeleted, result.BlobsDeleted)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// Vaultik contains all dependencies needed for vaultik operations
|
||||
@@ -37,6 +38,12 @@ type Vaultik struct {
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Stdin io.Reader
|
||||
|
||||
// UI is the writer for user-facing status, progress, warnings, errors.
|
||||
// See package internal/ui for formatting conventions. Defaults to a
|
||||
// writer wrapping Stdout; the cli layer replaces it with a discarding
|
||||
// writer in --cron mode.
|
||||
UI *ui.Writer
|
||||
}
|
||||
|
||||
// VaultikParams contains all parameters for New that can be provided by fx
|
||||
@@ -83,6 +90,7 @@ func New(params VaultikParams) *Vaultik {
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
Stdin: os.Stdin,
|
||||
UI: ui.New(os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user