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:
@@ -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