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
|
- -s -w
|
||||||
- -X 'sneak.berlin/go/vaultik/internal/globals.Version={{ .Version }}'
|
- -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.Commit={{ .Commit }}'
|
||||||
|
- -X 'sneak.berlin/go/vaultik/internal/globals.CommitDate={{ .CommitDate }}'
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: default
|
- id: default
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ COPY . .
|
|||||||
# Run tests
|
# Run tests
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|
||||||
# Build with CGO enabled (required for mattn/go-sqlite3)
|
# 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)'" -o /vaultik ./cmd/vaultik
|
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
|
# Runtime stage
|
||||||
# alpine:3.21, 2026-02-25
|
# alpine:3.21, 2026-02-25
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -5,10 +5,12 @@ VERSION := 1.0.0-rc.1
|
|||||||
|
|
||||||
# Build variables
|
# Build variables
|
||||||
GIT_REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
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
|
# Linker flags
|
||||||
LDFLAGS := -X 'sneak.berlin/go/vaultik/internal/globals.Version=$(VERSION)' \
|
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
|
# Default target
|
||||||
all: vaultik
|
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
|
## requirements
|
||||||
|
|
||||||
* Go 1.26 or later
|
* Go 1.26 or later
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,11 +35,21 @@ type AppOptions struct {
|
|||||||
Invokes []fx.Option
|
Invokes []fx.Option
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupGlobals sets up the globals with application startup time
|
// setupGlobals records the startup time and prints the startup banner.
|
||||||
func setupGlobals(lc fx.Lifecycle, g *globals.Globals) {
|
// 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{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(ctx context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
g.StartTime = time.Now().UTC()
|
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
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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 {
|
OnStart: func(ctx context.Context) error {
|
||||||
// Start the snapshot creation in a goroutine
|
// Start the snapshot creation in a goroutine
|
||||||
go func() {
|
go func() {
|
||||||
if opts.Cron {
|
// --cron suppression is wired through v.UI by setupGlobals.
|
||||||
v.Stdout = io.Discard
|
|
||||||
}
|
|
||||||
if err := v.CreateSnapshot(opts); err != nil {
|
if err := v.CreateSnapshot(opts); err != nil {
|
||||||
if err != context.Canceled {
|
if err != context.Canceled {
|
||||||
log.Error("Snapshot creation failed", "error", err)
|
log.Error("Snapshot creation failed", "error", err)
|
||||||
|
|||||||
@@ -13,11 +13,18 @@ var Version string = "dev"
|
|||||||
// Commit is the git commit hash, populated from main().
|
// Commit is the git commit hash, populated from main().
|
||||||
var Commit string = "unknown"
|
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.
|
// Globals contains application-wide configuration and metadata.
|
||||||
type Globals struct {
|
type Globals struct {
|
||||||
Appname string
|
Appname string
|
||||||
Version string
|
Version string
|
||||||
Commit string
|
Commit string
|
||||||
|
CommitDate string
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,5 +34,15 @@ func New() (*Globals, error) {
|
|||||||
Appname: Appname,
|
Appname: Appname,
|
||||||
Version: Version,
|
Version: Version,
|
||||||
Commit: Commit,
|
Commit: Commit,
|
||||||
|
CommitDate: CommitDate,
|
||||||
}, nil
|
}, 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
|
package snapshot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/vaultik/internal/config"
|
"sneak.berlin/go/vaultik/internal/config"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScannerParams holds parameters for scanner creation
|
// ScannerParams holds parameters for scanner creation
|
||||||
type ScannerParams struct {
|
type ScannerParams struct {
|
||||||
EnableProgress bool
|
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
|
Fs afero.Fs
|
||||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
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,
|
CompressionLevel: cfg.CompressionLevel,
|
||||||
AgeRecipients: cfg.AgeRecipients,
|
AgeRecipients: cfg.AgeRecipients,
|
||||||
EnableProgress: params.EnableProgress,
|
EnableProgress: params.EnableProgress,
|
||||||
Output: params.Output,
|
UI: params.UI,
|
||||||
Exclude: excludes,
|
Exclude: excludes,
|
||||||
SkipErrors: params.SkipErrors,
|
SkipErrors: params.SkipErrors,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileToProcess holds information about a file that needs processing
|
// FileToProcess holds information about a file that needs processing
|
||||||
@@ -61,7 +62,7 @@ type Scanner struct {
|
|||||||
compiledExclude []compiledPattern // Compiled glob patterns
|
compiledExclude []compiledPattern // Compiled glob patterns
|
||||||
progress *ProgressReporter
|
progress *ProgressReporter
|
||||||
skipErrors bool // Skip file read errors (log loudly but continue)
|
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
|
// In-memory cache of known chunk hashes for fast existence checks
|
||||||
knownChunks map[string]struct{}
|
knownChunks map[string]struct{}
|
||||||
@@ -94,7 +95,7 @@ type ScannerConfig struct {
|
|||||||
CompressionLevel int
|
CompressionLevel int
|
||||||
AgeRecipients []string // Optional, empty means no encryption
|
AgeRecipients []string // Optional, empty means no encryption
|
||||||
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
|
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
|
Exclude []string // Glob patterns for files/directories to exclude
|
||||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||||
}
|
}
|
||||||
@@ -143,9 +144,9 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
// Compile exclude patterns
|
// Compile exclude patterns
|
||||||
compiledExclude := compileExcludePatterns(cfg.Exclude)
|
compiledExclude := compileExcludePatterns(cfg.Exclude)
|
||||||
|
|
||||||
output := cfg.Output
|
uiw := cfg.UI
|
||||||
if output == nil {
|
if uiw == nil {
|
||||||
output = io.Discard
|
uiw = ui.NewWithColor(io.Discard, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Scanner{
|
return &Scanner{
|
||||||
@@ -161,7 +162,7 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
compiledExclude: compiledExclude,
|
compiledExclude: compiledExclude,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
skipErrors: cfg.SkipErrors,
|
skipErrors: cfg.SkipErrors,
|
||||||
output: output,
|
ui: uiw,
|
||||||
pendingChunkHashes: make(map[string]struct{}),
|
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)
|
// Phase 1c: Associate unchanged files with this snapshot (no new records needed)
|
||||||
if len(scanResult.UnchangedFileIDs) > 0 {
|
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 {
|
if err := s.batchAddFilesToSnapshot(ctx, scanResult.UnchangedFileIDs); err != nil {
|
||||||
return nil, fmt.Errorf("associating unchanged files: %w", err)
|
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
|
// Phase 2: Process files and create chunks
|
||||||
if len(filesToProcess) > 0 {
|
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)")
|
log.Info("Phase 2/3: Creating snapshot (chunking, compressing, encrypting, and uploading blobs)")
|
||||||
if err := s.processPhase(ctx, filesToProcess, result); err != nil {
|
if err := s.processPhase(ctx, filesToProcess, result); err != nil {
|
||||||
return nil, fmt.Errorf("process phase failed: %w", err)
|
return nil, fmt.Errorf("process phase failed: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} 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)")
|
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
|
// 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
|
// 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) {
|
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)
|
knownFiles, err := s.loadKnownFiles(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("loading known files: %w", err)
|
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 {
|
if err := s.loadKnownChunks(ctx); err != nil {
|
||||||
return nil, fmt.Errorf("loading known chunks: %w", err)
|
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
|
return knownFiles, nil
|
||||||
}
|
}
|
||||||
@@ -277,17 +278,17 @@ func (s *Scanner) summarizeScanPhase(result *ScanResult, filesToProcess []*FileT
|
|||||||
"files_skipped", result.FilesSkipped,
|
"files_skipped", result.FilesSkipped,
|
||||||
"bytes_skipped", humanize.Bytes(uint64(result.BytesSkipped)))
|
"bytes_skipped", humanize.Bytes(uint64(result.BytesSkipped)))
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(s.output, "Scan complete: %s examined (%s), %s to process (%s)",
|
msg := fmt.Sprintf("Enumerated %s snapshot source files (%s total), %s to process (%s)",
|
||||||
formatNumber(result.FilesScanned),
|
s.ui.Count(result.FilesScanned),
|
||||||
humanize.Bytes(uint64(totalSizeToProcess+result.BytesSkipped)),
|
s.ui.Size(totalSizeToProcess+result.BytesSkipped),
|
||||||
formatNumber(len(filesToProcess)),
|
s.ui.Count(len(filesToProcess)),
|
||||||
humanize.Bytes(uint64(totalSizeToProcess)))
|
s.ui.Size(totalSizeToProcess))
|
||||||
if result.FilesDeleted > 0 {
|
if result.FilesDeleted > 0 {
|
||||||
_, _ = fmt.Fprintf(s.output, ", %s deleted (%s)",
|
msg += fmt.Sprintf(", %s deleted (%s)",
|
||||||
formatNumber(result.FilesDeleted),
|
s.ui.Count(result.FilesDeleted),
|
||||||
humanize.Bytes(uint64(result.BytesDeleted)))
|
s.ui.Size(result.BytesDeleted))
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintln(s.output)
|
s.ui.Complete("%s.", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeScanResult populates final blob statistics in the scan result
|
// 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 {
|
err := afero.Walk(s.fs, path, func(filePath string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.skipErrors {
|
if s.skipErrors {
|
||||||
log.Error("ERROR: Failed to access file (skipping due to --skip-errors)", "path", filePath, "error", err)
|
log.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)
|
s.ui.Error("Failed to access %s: %v. Skipping (--skip-errors).", s.ui.Path(filePath), err)
|
||||||
return nil // Continue scanning
|
return nil // Continue scanning
|
||||||
}
|
}
|
||||||
log.Debug("Error accessing filesystem entry", "path", filePath, "error", err)
|
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 {
|
if rate > 0 && remaining > 0 {
|
||||||
eta = time.Duration(float64(remaining)/rate) * time.Second
|
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 {
|
if eta > 0 {
|
||||||
_, _ = fmt.Fprintf(s.output, ", ETA %s", eta.Round(time.Second))
|
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)),
|
||||||
_, _ = fmt.Fprintln(s.output)
|
s.ui.Percent(pct),
|
||||||
} else {
|
s.ui.Count(changedCount),
|
||||||
// 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),
|
|
||||||
rate,
|
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)
|
elapsed := time.Since(startTime)
|
||||||
rate := float64(end) / elapsed.Seconds()
|
rate := float64(end) / elapsed.Seconds()
|
||||||
pct := float64(end) / float64(len(fileIDs)) * 100
|
pct := float64(end) / float64(len(fileIDs)) * 100
|
||||||
_, _ = fmt.Fprintf(s.output, "Associating files: %s/%s (%.1f%%), %.0f files/sec\n",
|
s.ui.Progress("Snapshot unchanged-file association: %s/%s (%s), %.0f files/sec.",
|
||||||
formatNumber(end), formatNumber(len(fileIDs)), pct, rate)
|
s.ui.Count(end), s.ui.Count(len(fileIDs)), s.ui.Percent(pct), rate)
|
||||||
lastStatusTime = time.Now()
|
lastStatusTime = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(startTime)
|
elapsed := time.Since(startTime)
|
||||||
rate := float64(len(fileIDs)) / elapsed.Seconds()
|
rate := float64(len(fileIDs)) / elapsed.Seconds()
|
||||||
_, _ = fmt.Fprintf(s.output, "Associated %s unchanged files in %s (%.0f files/sec)\n",
|
s.ui.Complete("Associated %s unchanged files with the snapshot in %s (%.0f files/sec).",
|
||||||
formatNumber(len(fileIDs)), elapsed.Round(time.Second), rate)
|
s.ui.Count(len(fileIDs)), s.ui.Duration(elapsed), rate)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1034,8 +1041,8 @@ func (s *Scanner) processFileWithErrorHandling(ctx context.Context, fileToProces
|
|||||||
}
|
}
|
||||||
// Skip file read errors if --skip-errors is enabled
|
// Skip file read errors if --skip-errors is enabled
|
||||||
if s.skipErrors {
|
if s.skipErrors {
|
||||||
log.Error("ERROR: Failed to process file (skipping due to --skip-errors)", "path", fileToProcess.Path, "error", err)
|
log.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)
|
s.ui.Error("Failed to process %s: %v. Skipping (--skip-errors).", s.ui.Path(fileToProcess.Path), err)
|
||||||
result.FilesSkipped++
|
result.FilesSkipped++
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -1059,20 +1066,29 @@ func (s *Scanner) printProcessingProgress(filesProcessed, totalFiles int, bytesP
|
|||||||
eta = time.Duration(float64(remainingBytes)/byteRate) * time.Second
|
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 {
|
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,
|
// 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 {
|
if _, err := s.storage.Stat(ctx, blobPath); err == nil {
|
||||||
log.Info("Blob already exists in storage, skipping upload",
|
log.Info("Blob already exists in storage, skipping upload",
|
||||||
"hash", finishedBlob.Hash, "size", humanize.Bytes(uint64(finishedBlob.Compressed)))
|
"hash", finishedBlob.Hash, "size", humanize.Bytes(uint64(finishedBlob.Compressed)))
|
||||||
_, _ = fmt.Fprintf(s.output, "Blob exists: %s (%s, skipped upload)\n",
|
s.ui.Info("Blob %s (%s) already exists in backup destination store. Skipping upload.",
|
||||||
finishedBlob.Hash[:12]+"...", humanize.Bytes(uint64(finishedBlob.Compressed)))
|
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed))
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(s.output, "Uploading blob: %s (%s)\n",
|
s.ui.Begin("Uploading blob %s (%s) to backup destination store.",
|
||||||
finishedBlob.Hash[:12]+"...",
|
s.ui.Hex(finishedBlob.Hash), s.ui.Size(finishedBlob.Compressed))
|
||||||
humanize.Bytes(uint64(finishedBlob.Compressed)))
|
|
||||||
|
|
||||||
progressCallback := s.makeUploadProgressCallback(ctx, finishedBlob, startTime)
|
progressCallback := s.makeUploadProgressCallback(ctx, finishedBlob, startTime)
|
||||||
|
|
||||||
@@ -1183,11 +1198,11 @@ func (s *Scanner) uploadBlobIfNeeded(ctx context.Context, blobPath string, blobW
|
|||||||
uploadDuration := time.Since(startTime)
|
uploadDuration := time.Since(startTime)
|
||||||
uploadSpeedBps := float64(finishedBlob.Compressed) / uploadDuration.Seconds()
|
uploadSpeedBps := float64(finishedBlob.Compressed) / uploadDuration.Seconds()
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(s.output, "Blob stored: %s (%s, %s/sec, %s)\n",
|
s.ui.Complete("Uploaded blob %s (%s) in %s at %s.",
|
||||||
finishedBlob.Hash[:12]+"...",
|
s.ui.Hex(finishedBlob.Hash),
|
||||||
humanize.Bytes(uint64(finishedBlob.Compressed)),
|
s.ui.Size(finishedBlob.Compressed),
|
||||||
humanize.Bytes(uint64(uploadSpeedBps)),
|
s.ui.Duration(uploadDuration),
|
||||||
uploadDuration.Round(time.Millisecond))
|
s.ui.Speed(uploadSpeedBps))
|
||||||
|
|
||||||
log.Info("Successfully uploaded blob to storage",
|
log.Info("Successfully uploaded blob to storage",
|
||||||
"path", blobPath,
|
"path", blobPath,
|
||||||
@@ -1236,14 +1251,15 @@ func (s *Scanner) makeUploadProgressCallback(ctx context.Context, finishedBlob *
|
|||||||
if avgSpeed > 0 {
|
if avgSpeed > 0 {
|
||||||
eta = time.Duration(float64(finishedBlob.Compressed-uploaded)/avgSpeed) * time.Second
|
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",
|
s.ui.Progress("Blob upload %s: %s / %s (%s) at %s, blob upload elapsed %s, blob upload estimated remaining time (%s), finish at %s.",
|
||||||
finishedBlob.Hash[:12]+"...",
|
s.ui.Hex(finishedBlob.Hash),
|
||||||
humanize.Bytes(uint64(uploaded)),
|
s.ui.Size(uploaded),
|
||||||
humanize.Bytes(uint64(finishedBlob.Compressed)),
|
s.ui.Size(finishedBlob.Compressed),
|
||||||
pct,
|
s.ui.Percent(pct),
|
||||||
humanize.Bytes(uint64(avgSpeed)),
|
s.ui.Speed(avgSpeed),
|
||||||
totalElapsed.Round(time.Second),
|
s.ui.Duration(totalElapsed),
|
||||||
eta.Round(time.Second))
|
s.ui.Duration(eta),
|
||||||
|
s.ui.Time(now.Add(eta)))
|
||||||
lastStdoutTime = now
|
lastStdoutTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,7 +1488,7 @@ func (s *Scanner) detectDeletedFilesFromMap(ctx context.Context, knownFiles map[
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.FilesDeleted > 0 {
|
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
|
return nil
|
||||||
@@ -1594,25 +1610,3 @@ func (s *Scanner) shouldExclude(filePath, rootPath string) bool {
|
|||||||
|
|
||||||
return false
|
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"`
|
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
|
// formatBytes formats bytes in a human-readable format
|
||||||
func formatBytes(bytes int64) string {
|
func formatBytes(bytes int64) string {
|
||||||
const unit = 1024
|
const unit = 1024
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
|
|
||||||
// Print overall summary if multiple snapshots
|
// Print overall summary if multiple snapshots
|
||||||
if len(snapshotNames) > 1 {
|
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 {
|
if opts.Prune {
|
||||||
@@ -101,7 +101,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
|||||||
// snapshot of each name is kept.
|
// snapshot of each name is kept.
|
||||||
func (v *Vaultik) runPostBackupPrune(snapshotNames []string, keepNewerThan string) error {
|
func (v *Vaultik) runPostBackupPrune(snapshotNames []string, keepNewerThan string) error {
|
||||||
log.Info("Running post-backup prune", "snapshots", snapshotNames, "keep_newer_than", keepNewerThan)
|
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{
|
purgeOpts := &SnapshotPurgeOptions{
|
||||||
Force: true,
|
Force: true,
|
||||||
@@ -146,7 +146,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
|
|||||||
snapshotStartTime := time.Now()
|
snapshotStartTime := time.Now()
|
||||||
|
|
||||||
if total > 1 {
|
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)
|
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||||
@@ -156,7 +156,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
|
|||||||
|
|
||||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||||
EnableProgress: !opts.Cron,
|
EnableProgress: !opts.Cron,
|
||||||
Output: v.Stdout,
|
UI: v.UI,
|
||||||
Fs: v.Fs,
|
Fs: v.Fs,
|
||||||
Exclude: v.Config.GetExcludes(snapName),
|
Exclude: v.Config.GetExcludes(snapName),
|
||||||
SkipErrors: opts.SkipErrors,
|
SkipErrors: opts.SkipErrors,
|
||||||
@@ -167,7 +167,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
|
|||||||
return fmt.Errorf("creating snapshot: %w", err)
|
return fmt.Errorf("creating snapshot: %w", err)
|
||||||
}
|
}
|
||||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
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)
|
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -231,7 +231,7 @@ func (v *Vaultik) scanAllDirectories(scanner *snapshot.Scanner, resolvedDirs []s
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Scanning directory", "path", dir)
|
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)
|
result, err := scanner.Scan(v.ctx, dir, snapshotID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan %s: %w", dir, err)
|
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
|
compressionRatio = 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
v.printfStdout("=== Snapshot Complete ===\n")
|
v.UI.Complete("Created snapshot %s.", v.UI.Snapshot(snapshotID))
|
||||||
v.printfStdout("ID: %s\n", snapshotID)
|
filesMsg := fmt.Sprintf("Files: %s examined, %s to process, %s unchanged",
|
||||||
v.printfStdout("Files: %s examined, %s to process, %s unchanged",
|
v.UI.Count(stats.totalFiles),
|
||||||
formatNumber(stats.totalFiles),
|
v.UI.Count(totalFilesChanged),
|
||||||
formatNumber(totalFilesChanged),
|
v.UI.Count(stats.totalFilesSkipped))
|
||||||
formatNumber(stats.totalFilesSkipped))
|
|
||||||
if stats.totalFilesDeleted > 0 {
|
if stats.totalFilesDeleted > 0 {
|
||||||
v.printfStdout(", %s deleted", formatNumber(stats.totalFilesDeleted))
|
filesMsg += fmt.Sprintf(", %s deleted", v.UI.Count(stats.totalFilesDeleted))
|
||||||
}
|
}
|
||||||
v.printlnStdout()
|
v.UI.Info("%s.", filesMsg)
|
||||||
v.printfStdout("Data: %s total (%s to process)",
|
|
||||||
humanize.Bytes(uint64(totalBytesAll)),
|
dataMsg := fmt.Sprintf("Data: %s total (%s to process)",
|
||||||
humanize.Bytes(uint64(stats.totalBytes)))
|
v.UI.Size(totalBytesAll),
|
||||||
|
v.UI.Size(stats.totalBytes))
|
||||||
if stats.totalBytesDeleted > 0 {
|
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 {
|
if stats.totalBlobsUploaded > 0 {
|
||||||
v.printfStdout("Storage: %s compressed from %s (%.2fx)\n",
|
v.UI.Info("Storage: %s compressed from %s (%.2fx ratio).",
|
||||||
humanize.Bytes(uint64(totalBlobSizeCompressed)),
|
v.UI.Size(totalBlobSizeCompressed),
|
||||||
humanize.Bytes(uint64(totalBlobSizeUncompressed)),
|
v.UI.Size(totalBlobSizeUncompressed),
|
||||||
compressionRatio)
|
compressionRatio)
|
||||||
v.printfStdout("Upload: %d blobs, %s in %s (%s)\n",
|
v.UI.Info("Upload: %d blobs, %s in %s (%s).",
|
||||||
stats.totalBlobsUploaded,
|
stats.totalBlobsUploaded,
|
||||||
humanize.Bytes(uint64(stats.totalBytesUploaded)),
|
v.UI.Size(stats.totalBytesUploaded),
|
||||||
formatDuration(stats.uploadDuration),
|
v.UI.Duration(stats.uploadDuration),
|
||||||
formatUploadSpeed(stats.totalBytesUploaded, 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
|
// 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 {
|
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 {
|
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
|
return nil
|
||||||
@@ -1272,6 +1273,7 @@ type PruneResult struct {
|
|||||||
// before starting a new backup or on-demand via the prune command.
|
// before starting a new backup or on-demand via the prune command.
|
||||||
func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
||||||
log.Info("Pruning local database: removing incomplete snapshots and orphaned data")
|
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{}
|
result := &PruneResult{}
|
||||||
|
|
||||||
@@ -1327,12 +1329,8 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
|||||||
"orphaned_blobs", result.BlobsDeleted,
|
"orphaned_blobs", result.BlobsDeleted,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Print summary
|
v.UI.Complete("Pruned local index database: %d incomplete snapshots, %d orphaned files, %d orphaned chunks, %d orphaned blobs removed.",
|
||||||
v.printfStdout("Local database prune complete:\n")
|
result.SnapshotsDeleted, result.FilesDeleted, result.ChunksDeleted, result.BlobsDeleted)
|
||||||
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)
|
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/globals"
|
"sneak.berlin/go/vaultik/internal/globals"
|
||||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
|
"sneak.berlin/go/vaultik/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Vaultik contains all dependencies needed for vaultik operations
|
// Vaultik contains all dependencies needed for vaultik operations
|
||||||
@@ -37,6 +38,12 @@ type Vaultik struct {
|
|||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
Stdin io.Reader
|
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
|
// VaultikParams contains all parameters for New that can be provided by fx
|
||||||
@@ -83,6 +90,7 @@ func New(params VaultikParams) *Vaultik {
|
|||||||
Stdout: os.Stdout,
|
Stdout: os.Stdout,
|
||||||
Stderr: os.Stderr,
|
Stderr: os.Stderr,
|
||||||
Stdin: os.Stdin,
|
Stdin: os.Stdin,
|
||||||
|
UI: ui.New(os.Stdout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user