summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/goprecords/aggregate.go1
-rw-r--r--internal/goprecords/aggregate_test.go3
-rw-r--r--internal/goprecords/db.go7
-rw-r--r--internal/goprecords/report.go27
-rw-r--r--internal/goprecords/report_format.go69
-rw-r--r--internal/goprecords/report_test.go73
-rw-r--r--internal/goprecords/types.go14
-rw-r--r--internal/recordsdir/recordsdir.go43
-rw-r--r--internal/recordsdir/recordsdir_test.go3
-rw-r--r--internal/storage/db.go48
-rw-r--r--internal/storage/db_test.go58
-rw-r--r--internal/version/version.go2
12 files changed, 284 insertions, 64 deletions
diff --git a/internal/goprecords/aggregate.go b/internal/goprecords/aggregate.go
index aa9c3fe..102ab7f 100644
--- a/internal/goprecords/aggregate.go
+++ b/internal/goprecords/aggregate.go
@@ -58,6 +58,7 @@ func (ag *Aggregator) Aggregate(ctx context.Context) (*Aggregates, error) {
return nil, fmt.Errorf("last kernel %s: %w", relPath, err)
}
out.Host[host] = NewHostAggregate(host, lastKernel)
+ out.Host[host].LastUpdated = f.ModTime
if err := processRecordsFile(ctx, ag.fsys, relPath, host, out); err != nil {
return nil, err
}
diff --git a/internal/goprecords/aggregate_test.go b/internal/goprecords/aggregate_test.go
index d675b4f..12fceae 100644
--- a/internal/goprecords/aggregate_test.go
+++ b/internal/goprecords/aggregate_test.go
@@ -118,6 +118,9 @@ func TestAggregateFixturesContent(t *testing.T) {
if host.LastKernel == "" {
t.Error("expected non-empty LastKernel for earth")
}
+ if host.LastUpdated.IsZero() {
+ t.Error("expected non-zero LastUpdated for earth")
+ }
} else {
t.Error("expected earth host in aggregates")
}
diff --git a/internal/goprecords/db.go b/internal/goprecords/db.go
index b2b095b..0746dfa 100644
--- a/internal/goprecords/db.go
+++ b/internal/goprecords/db.go
@@ -14,6 +14,10 @@ func LoadAggregates(ctx context.Context, db *sql.DB) (*Aggregates, error) {
if err != nil {
return nil, fmt.Errorf("load records: %w", err)
}
+ hostMeta, err := storage.LoadHostMeta(ctx, db)
+ if err != nil {
+ return nil, fmt.Errorf("load host meta: %w", err)
+ }
out := &Aggregates{
Host: make(map[string]*HostAggregate),
Kernel: make(map[string]*Aggregate),
@@ -38,6 +42,9 @@ func LoadAggregates(ctx context.Context, db *sql.DB) (*Aggregates, error) {
}
for host, h := range out.Host {
h.LastKernel = hostLastKernel[host]
+ if t, ok := hostMeta[host]; ok {
+ h.LastUpdated = t
+ }
}
return out, nil
}
diff --git a/internal/goprecords/report.go b/internal/goprecords/report.go
index 6300163..12c3951 100644
--- a/internal/goprecords/report.go
+++ b/internal/goprecords/report.go
@@ -186,9 +186,9 @@ func (r *HTMLReporter) Report() string {
func (r reportBuilder) Report(outputFormat OutputFormat) string {
var rows []tableRow
- var hasLastKernel bool
+ var hasLastKernel, hasLastUpdated bool
if r.category == CategoryHost {
- rows, hasLastKernel = r.buildHostTable()
+ rows, hasLastKernel, hasLastUpdated = r.buildHostTable()
} else {
rows, hasLastKernel = r.buildCategoryTable()
}
@@ -196,12 +196,12 @@ func (r reportBuilder) Report(outputFormat OutputFormat) string {
return ""
}
if outputFormat == FormatHTML {
- return r.formatReportHTML(rows, hasLastKernel)
+ return r.formatReportHTML(rows, hasLastKernel, hasLastUpdated)
}
- return r.formatReport(rows, hasLastKernel, outputFormat)
+ return r.formatReport(rows, hasLastKernel, hasLastUpdated, outputFormat)
}
-func (r reportBuilder) buildHostTable() ([]tableRow, bool) {
+func (r reportBuilder) buildHostTable() ([]tableRow, bool, bool) {
type keyVal struct {
agg *HostAggregate
key uint64
@@ -214,6 +214,7 @@ func (r reportBuilder) buildHostTable() ([]tableRow, bool) {
}
sort.Slice(list, func(i, j int) bool { return list[i].key > list[j].key })
var rows []tableRow
+ var hasLastUpdated bool
for i, kv := range list {
if uint(i) >= r.limit {
break
@@ -223,14 +224,20 @@ func (r reportBuilder) buildHostTable() ([]tableRow, bool) {
if h.IsActive(90) {
active = "*"
}
+ lastUpdated := ""
+ if !h.LastUpdated.IsZero() {
+ lastUpdated = h.LastUpdated.UTC().Format("2006-01-02 15:04")
+ hasLastUpdated = true
+ }
rows = append(rows, tableRow{
- Pos: fmt.Sprintf("%d.", i+1),
- Name: active + h.Stats.Name,
- Value: r.humanStrHost(h),
- LastKernel: h.LastKernel,
+ Pos: fmt.Sprintf("%d.", i+1),
+ Name: active + h.Stats.Name,
+ Value: r.humanStrHost(h),
+ LastKernel: h.LastKernel,
+ LastUpdated: lastUpdated,
})
}
- return rows, true
+ return rows, true, hasLastUpdated
}
func (r reportBuilder) buildCategoryTable() ([]tableRow, bool) {
diff --git a/internal/goprecords/report_format.go b/internal/goprecords/report_format.go
index 7630564..8d06b04 100644
--- a/internal/goprecords/report_format.go
+++ b/internal/goprecords/report_format.go
@@ -7,12 +7,12 @@ import (
"strings"
)
-func (r reportBuilder) formatReport(rows []tableRow, hasLastKernel bool, outputFormat OutputFormat) string {
- cW, nW, vW, lkW := r.reportWidths(rows, hasLastKernel)
- border := r.buildBorder(cW, nW, vW, lkW, hasLastKernel)
- header := r.buildReportHeader(cW, nW, vW, lkW, hasLastKernel, border, outputFormat)
- fmtStr := r.buildFormatStr(cW, nW, vW, lkW, hasLastKernel)
- body := r.buildReportBody(rows, fmtStr, hasLastKernel)
+func (r reportBuilder) formatReport(rows []tableRow, hasLastKernel, hasLastUpdated bool, outputFormat OutputFormat) string {
+ cW, nW, vW, lkW, luW := r.reportWidths(rows, hasLastKernel, hasLastUpdated)
+ border := r.buildBorder(cW, nW, vW, lkW, luW, hasLastKernel, hasLastUpdated)
+ header := r.buildReportHeader(cW, nW, vW, lkW, luW, hasLastKernel, hasLastUpdated, border, outputFormat)
+ fmtStr := r.buildFormatStr(cW, nW, vW, lkW, luW, hasLastKernel, hasLastUpdated)
+ body := r.buildReportBody(rows, fmtStr, hasLastKernel, hasLastUpdated)
out := header + body + border
if outputFormat == FormatMarkdown || outputFormat == FormatGemtext {
out += "```\n"
@@ -20,17 +20,21 @@ func (r reportBuilder) formatReport(rows []tableRow, hasLastKernel bool, outputF
return out
}
-func (r reportBuilder) formatReportHTML(rows []tableRow, hasLastKernel bool) string {
- cW, nW, vW, lkW := r.reportWidths(rows, hasLastKernel)
- border := r.buildBorder(cW, nW, vW, lkW, hasLastKernel)
- fmtStr := r.buildFormatStr(cW, nW, vW, lkW, hasLastKernel)
+func (r reportBuilder) formatReportHTML(rows []tableRow, hasLastKernel, hasLastUpdated bool) string {
+ cW, nW, vW, lkW, luW := r.reportWidths(rows, hasLastKernel, hasLastUpdated)
+ border := r.buildBorder(cW, nW, vW, lkW, luW, hasLastKernel, hasLastUpdated)
+ fmtStr := r.buildFormatStr(cW, nW, vW, lkW, luW, hasLastKernel, hasLastUpdated)
var headRow string
- if hasLastKernel {
+ if hasLastKernel && hasLastUpdated {
+ headRow = fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String(), "Last Kernel", "Updated")
+ } else if hasLastKernel {
headRow = fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String(), "Last Kernel")
+ } else if hasLastUpdated {
+ headRow = fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String(), "Updated")
} else {
headRow = fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String())
}
- body := r.buildReportBody(rows, fmtStr, hasLastKernel)
+ body := r.buildReportBody(rows, fmtStr, hasLastKernel, hasLastUpdated)
ascii := border + headRow + border + body + border
hl := int(r.headerIndent)
@@ -66,13 +70,16 @@ func (r reportBuilder) formatReportHTML(rows []tableRow, hasLastKernel bool) str
return b.String()
}
-func (r reportBuilder) reportWidths(rows []tableRow, hasLastKernel bool) (countW, nameW, valueW, lastKernelW int) {
+func (r reportBuilder) reportWidths(rows []tableRow, hasLastKernel, hasLastUpdated bool) (countW, nameW, valueW, lastKernelW, lastUpdatedW int) {
countW = 3
nameW = len(r.category.String())
valueW = len(r.metric.String())
if hasLastKernel {
lastKernelW = len("Last Kernel")
}
+ if hasLastUpdated {
+ lastUpdatedW = len("Updated")
+ }
for _, row := range rows {
if len(row.Pos) > countW {
countW = len(row.Pos)
@@ -86,11 +93,14 @@ func (r reportBuilder) reportWidths(rows []tableRow, hasLastKernel bool) (countW
if len(row.LastKernel) > lastKernelW {
lastKernelW = len(row.LastKernel)
}
+ if len(row.LastUpdated) > lastUpdatedW {
+ lastUpdatedW = len(row.LastUpdated)
+ }
}
- return countW, nameW, valueW, lastKernelW
+ return countW, nameW, valueW, lastKernelW, lastUpdatedW
}
-func (r reportBuilder) buildBorder(countW, nameW, valueW, lastKernelW int, hasLastKernel bool) string {
+func (r reportBuilder) buildBorder(countW, nameW, valueW, lastKernelW, lastUpdatedW int, hasLastKernel, hasLastUpdated bool) string {
parts := []string{
"+" + strings.Repeat("-", 2+countW),
"+" + strings.Repeat("-", 2+nameW),
@@ -99,10 +109,13 @@ func (r reportBuilder) buildBorder(countW, nameW, valueW, lastKernelW int, hasLa
if hasLastKernel {
parts = append(parts, "+"+strings.Repeat("-", 2+lastKernelW))
}
+ if hasLastUpdated {
+ parts = append(parts, "+"+strings.Repeat("-", 2+lastUpdatedW))
+ }
return strings.Join(parts, "") + "+\n"
}
-func (r reportBuilder) buildReportHeader(countW, nameW, valueW, lastKernelW int, hasLastKernel bool, border string, outputFormat OutputFormat) string {
+func (r reportBuilder) buildReportHeader(countW, nameW, valueW, lastKernelW, lastUpdatedW int, hasLastKernel, hasLastUpdated bool, border string, outputFormat OutputFormat) string {
var b strings.Builder
if outputFormat == FormatMarkdown || outputFormat == FormatGemtext {
b.WriteString(strings.Repeat("#", int(r.headerIndent)))
@@ -120,9 +133,13 @@ func (r reportBuilder) buildReportHeader(countW, nameW, valueW, lastKernelW int,
b.WriteString("```\n")
}
b.WriteString(border)
- fmtStr := r.buildFormatStr(countW, nameW, valueW, lastKernelW, hasLastKernel)
- if hasLastKernel {
+ fmtStr := r.buildFormatStr(countW, nameW, valueW, lastKernelW, lastUpdatedW, hasLastKernel, hasLastUpdated)
+ if hasLastKernel && hasLastUpdated {
+ b.WriteString(fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String(), "Last Kernel", "Updated"))
+ } else if hasLastKernel {
b.WriteString(fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String(), "Last Kernel"))
+ } else if hasLastUpdated {
+ b.WriteString(fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String(), "Updated"))
} else {
b.WriteString(fmt.Sprintf(fmtStr+"\n", "Pos", r.category.String(), r.metric.String()))
}
@@ -130,18 +147,28 @@ func (r reportBuilder) buildReportHeader(countW, nameW, valueW, lastKernelW int,
return b.String()
}
-func (r reportBuilder) buildFormatStr(countW, nameW, valueW, lastKernelW int, hasLastKernel bool) string {
+func (r reportBuilder) buildFormatStr(countW, nameW, valueW, lastKernelW, lastUpdatedW int, hasLastKernel, hasLastUpdated bool) string {
+ if hasLastKernel && hasLastUpdated {
+ return fmt.Sprintf("| %%%ds | %%%ds | %%%ds | %%%ds | %%%ds |", countW, nameW, valueW, lastKernelW, lastUpdatedW)
+ }
if hasLastKernel {
return fmt.Sprintf("| %%%ds | %%%ds | %%%ds | %%%ds |", countW, nameW, valueW, lastKernelW)
}
+ if hasLastUpdated {
+ return fmt.Sprintf("| %%%ds | %%%ds | %%%ds | %%%ds |", countW, nameW, valueW, lastUpdatedW)
+ }
return fmt.Sprintf("| %%%ds | %%%ds | %%%ds |", countW, nameW, valueW)
}
-func (r reportBuilder) buildReportBody(rows []tableRow, fmtStr string, hasLastKernel bool) string {
+func (r reportBuilder) buildReportBody(rows []tableRow, fmtStr string, hasLastKernel, hasLastUpdated bool) string {
var b strings.Builder
for _, row := range rows {
- if hasLastKernel {
+ if hasLastKernel && hasLastUpdated {
+ b.WriteString(fmt.Sprintf(fmtStr+"\n", row.Pos, row.Name, row.Value, row.LastKernel, row.LastUpdated))
+ } else if hasLastKernel {
b.WriteString(fmt.Sprintf(fmtStr+"\n", row.Pos, row.Name, row.Value, row.LastKernel))
+ } else if hasLastUpdated {
+ b.WriteString(fmt.Sprintf(fmtStr+"\n", row.Pos, row.Name, row.Value, row.LastUpdated))
} else {
b.WriteString(fmt.Sprintf(fmtStr+"\n", row.Pos, row.Name, row.Value))
}
diff --git a/internal/goprecords/report_test.go b/internal/goprecords/report_test.go
index 1469942..fccb0d2 100644
--- a/internal/goprecords/report_test.go
+++ b/internal/goprecords/report_test.go
@@ -6,6 +6,7 @@ import (
"net/url"
"strings"
"testing"
+ "time"
)
func TestNewReporter(t *testing.T) {
@@ -489,3 +490,75 @@ func hostName(i int) string {
return "host9"
}
}
+
+func TestReportWithLastUpdated(t *testing.T) {
+ aggs := &Aggregates{
+ Host: make(map[string]*HostAggregate),
+ Kernel: make(map[string]*Aggregate),
+ KernelMajor: make(map[string]*Aggregate),
+ KernelName: make(map[string]*Aggregate),
+ }
+
+ hagg := NewHostAggregate("host1", "Linux 5.10")
+ hagg.Stats.Uptime = 86400000
+ hagg.Stats.Boots = 10
+ hagg.Stats.FirstBoot = 1000
+ hagg.Stats.LastSeen = 86401000
+ hagg.LastUpdated = time.Date(2024, 1, 15, 9, 30, 0, 0, time.UTC)
+ aggs.Host["host1"] = hagg
+
+ reporter := NewReporter(aggs, CategoryHost, 20, MetricUptime, FormatPlaintext, 1)
+ report := reporter.Report()
+
+ if !strings.Contains(report, "Updated") {
+ t.Error("expected report to contain Updated header")
+ }
+ if !strings.Contains(report, "2024-01-15 09:30") {
+ t.Error("expected report to contain formatted LastUpdated")
+ }
+}
+
+func TestReportWithoutLastUpdated(t *testing.T) {
+ aggs := &Aggregates{
+ Host: make(map[string]*HostAggregate),
+ Kernel: make(map[string]*Aggregate),
+ KernelMajor: make(map[string]*Aggregate),
+ KernelName: make(map[string]*Aggregate),
+ }
+
+ hagg := NewHostAggregate("host1", "Linux 5.10")
+ hagg.Stats.Uptime = 86400000
+ hagg.Stats.Boots = 10
+ hagg.Stats.FirstBoot = 1000
+ hagg.Stats.LastSeen = 86401000
+ // LastUpdated is zero
+ aggs.Host["host1"] = hagg
+
+ reporter := NewReporter(aggs, CategoryHost, 20, MetricUptime, FormatPlaintext, 1)
+ report := reporter.Report()
+
+ if strings.Contains(report, "Updated") {
+ t.Error("expected report NOT to contain Updated header when LastUpdated is zero")
+ }
+}
+
+func TestReportKernelNoUpdatedColumn(t *testing.T) {
+ aggs := &Aggregates{
+ Host: make(map[string]*HostAggregate),
+ Kernel: make(map[string]*Aggregate),
+ KernelMajor: make(map[string]*Aggregate),
+ KernelName: make(map[string]*Aggregate),
+ }
+
+ kernel := NewAggregate("Linux 5.10.0")
+ kernel.Uptime = 86400000
+ kernel.Boots = 5
+ aggs.Kernel["Linux 5.10.0"] = kernel
+
+ reporter := NewReporter(aggs, CategoryKernel, 20, MetricUptime, FormatPlaintext, 1)
+ report := reporter.Report()
+
+ if strings.Contains(report, "Updated") {
+ t.Error("expected Kernel report NOT to contain Updated column")
+ }
+}
diff --git a/internal/goprecords/types.go b/internal/goprecords/types.go
index 418d269..487119f 100644
--- a/internal/goprecords/types.go
+++ b/internal/goprecords/types.go
@@ -64,8 +64,9 @@ func NewAggregate(name string) *Aggregate {
// HostAggregate adds last-kernel and lifespan/downtime for host reports.
type HostAggregate struct {
- Stats Aggregate
- LastKernel string
+ Stats Aggregate
+ LastKernel string
+ LastUpdated time.Time
}
// NewHostAggregate constructs a HostAggregate.
@@ -86,10 +87,11 @@ func (h *HostAggregate) IsActive(limitDays uint) bool {
// tableRow is one row in the report table.
type tableRow struct {
- Pos string
- Name string
- Value string
- LastKernel string
+ Pos string
+ Name string
+ Value string
+ LastKernel string
+ LastUpdated string
}
// String returns the category name.
diff --git a/internal/recordsdir/recordsdir.go b/internal/recordsdir/recordsdir.go
index 94f7f0e..6ebf491 100644
--- a/internal/recordsdir/recordsdir.go
+++ b/internal/recordsdir/recordsdir.go
@@ -6,11 +6,13 @@ import (
"path"
"path/filepath"
"strings"
+ "time"
)
type Entry struct {
- Path string
- Host string
+ Path string
+ Host string
+ ModTime time.Time
}
func HostFromFileName(name string) string {
@@ -21,12 +23,12 @@ func HostFromFileName(name string) string {
return host
}
-func listRecordsFileNames(fsys fs.FS, root string) ([]string, error) {
+func listRecordsFileNames(fsys fs.FS, root string) ([]Entry, error) {
entries, err := fs.ReadDir(fsys, root)
if err != nil {
return nil, err
}
- var names []string
+ var out []Entry
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".records") {
continue
@@ -36,38 +38,27 @@ func listRecordsFileNames(fsys fs.FS, root string) ([]string, error) {
if err != nil || info.Size() == 0 {
continue
}
- names = append(names, e.Name())
+ out = append(out, Entry{
+ Path: rel,
+ Host: HostFromFileName(e.Name()),
+ ModTime: info.ModTime(),
+ })
}
- return names, nil
+ return out, nil
}
// ListNonEmptyFilesFS returns non-empty .records files under root within fsys.
func ListNonEmptyFilesFS(fsys fs.FS, root string) ([]Entry, error) {
- names, err := listRecordsFileNames(fsys, root)
- if err != nil {
- return nil, err
- }
- var out []Entry
- for _, name := range names {
- out = append(out, Entry{
- Path: path.Join(root, name),
- Host: HostFromFileName(name),
- })
- }
- return out, nil
+ return listRecordsFileNames(fsys, root)
}
func ListNonEmptyFiles(dir string) ([]Entry, error) {
- names, err := listRecordsFileNames(os.DirFS(dir), ".")
+ entries, err := listRecordsFileNames(os.DirFS(dir), ".")
if err != nil {
return nil, err
}
- var out []Entry
- for _, name := range names {
- out = append(out, Entry{
- Path: filepath.Join(dir, name),
- Host: HostFromFileName(name),
- })
+ for i := range entries {
+ entries[i].Path = filepath.Join(dir, filepath.Base(entries[i].Path))
}
- return out, nil
+ return entries, nil
}
diff --git a/internal/recordsdir/recordsdir_test.go b/internal/recordsdir/recordsdir_test.go
index 5e72d9d..40028ac 100644
--- a/internal/recordsdir/recordsdir_test.go
+++ b/internal/recordsdir/recordsdir_test.go
@@ -49,6 +49,9 @@ func TestListNonEmptyFiles(t *testing.T) {
if entries[0].Host != "h1" || filepath.Base(entries[0].Path) != "h1.records" {
t.Fatalf("unexpected entry: %#v", entries[0])
}
+ if entries[0].ModTime.IsZero() {
+ t.Fatal("expected non-zero ModTime")
+ }
}
func TestListNonEmptyFiles_ReadError(t *testing.T) {
diff --git a/internal/storage/db.go b/internal/storage/db.go
index d500509..22e0cd6 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io/fs"
"os"
+ "time"
"codeberg.org/snonux/goprecords/internal/recordline"
"codeberg.org/snonux/goprecords/internal/recordsdir"
@@ -26,6 +27,10 @@ CREATE INDEX IF NOT EXISTS idx_record_host ON record(host);
CREATE INDEX IF NOT EXISTS idx_record_os ON record(os);
CREATE INDEX IF NOT EXISTS idx_record_os_kernel_name ON record(os_kernel_name);
CREATE INDEX IF NOT EXISTS idx_record_os_kernel_major ON record(os_kernel_major);
+CREATE TABLE IF NOT EXISTS host_meta (
+ host TEXT NOT NULL PRIMARY KEY,
+ last_updated INTEGER NOT NULL
+);
CREATE TABLE IF NOT EXISTS excluded_host (
host TEXT NOT NULL PRIMARY KEY,
reason TEXT NOT NULL DEFAULT '',
@@ -72,6 +77,43 @@ func ResetRecords(ctx context.Context, db *sql.DB) error {
return err
}
+// ResetHostMeta deletes all rows from the host_meta table.
+func ResetHostMeta(ctx context.Context, db *sql.DB) error {
+ _, err := db.ExecContext(ctx, "DELETE FROM host_meta")
+ return err
+}
+
+// AddHostMeta inserts a host_meta row.
+func AddHostMeta(ctx context.Context, tx *sql.Tx, host string, lastUpdated int64) error {
+ _, err := tx.ExecContext(ctx, "INSERT INTO host_meta (host, last_updated) VALUES (?, ?)", host, lastUpdated)
+ if err != nil {
+ return fmt.Errorf("insert host meta: %w", err)
+ }
+ return nil
+}
+
+// LoadHostMeta returns a map of host to last-updated time from the host_meta table.
+func LoadHostMeta(ctx context.Context, db *sql.DB) (map[string]time.Time, error) {
+ rows, err := db.QueryContext(ctx, "SELECT host, last_updated FROM host_meta")
+ if err != nil {
+ return nil, fmt.Errorf("query host meta: %w", err)
+ }
+ defer rows.Close()
+ out := make(map[string]time.Time)
+ for rows.Next() {
+ var host string
+ var lu int64
+ if err := rows.Scan(&host, &lu); err != nil {
+ return nil, fmt.Errorf("scan host meta: %w", err)
+ }
+ out[host] = time.Unix(lu, 0).UTC()
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("rows host meta: %w", err)
+ }
+ return out, nil
+}
+
// ImportFromDir imports non-empty .records files from statsDir into the database,
// replacing existing rows. It is equivalent to ImportFromFS with os.DirFS(statsDir).
func ImportFromDir(ctx context.Context, db *sql.DB, statsDir string) error {
@@ -83,6 +125,9 @@ func ImportFromFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
if err := ResetRecords(ctx, db); err != nil {
return fmt.Errorf("reset records: %w", err)
}
+ if err := ResetHostMeta(ctx, db); err != nil {
+ return fmt.Errorf("reset host meta: %w", err)
+ }
files, err := recordsdir.ListNonEmptyFilesFS(fsys, ".")
if err != nil {
return fmt.Errorf("read dir: %w", err)
@@ -101,6 +146,9 @@ func ImportFromFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
if err := importFile(ctx, insert, fsys, f.Path, f.Host); err != nil {
return err
}
+ if err := AddHostMeta(ctx, tx, f.Host, f.ModTime.Unix()); err != nil {
+ return err
+ }
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go
index e34f88d..74aa30d 100644
--- a/internal/storage/db_test.go
+++ b/internal/storage/db_test.go
@@ -7,6 +7,7 @@ import (
"path/filepath"
"testing"
"testing/fstest"
+ "time"
)
func TestOpen_ContextCanceled(t *testing.T) {
@@ -464,3 +465,60 @@ func TestImportFromDir_pathIsFileNotDirectory(t *testing.T) {
t.Fatal("expected error")
}
}
+
+func TestHostMetaRoundTrip(t *testing.T) {
+ tmpDir := t.TempDir()
+ dbPath := filepath.Join(tmpDir, "test.db")
+ db, err := Open(context.Background(), dbPath)
+ if err != nil {
+ t.Fatalf("open DB: %v", err)
+ }
+ defer db.Close()
+ ctx := context.Background()
+ if err := CreateSchema(ctx, db); err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+
+ if err := ResetHostMeta(ctx, db); err != nil {
+ t.Fatalf("reset: %v", err)
+ }
+
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := AddHostMeta(ctx, tx, "host1", 1705312200); err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ if err := AddHostMeta(ctx, tx, "host2", 1705312300); err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatal(err)
+ }
+
+ meta, err := LoadHostMeta(ctx, db)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if len(meta) != 2 {
+ t.Fatalf("len=%d, want 2", len(meta))
+ }
+ if meta["host1"] != time.Unix(1705312200, 0).UTC() {
+ t.Fatalf("host1 time mismatch: %v", meta["host1"])
+ }
+ if meta["host2"] != time.Unix(1705312300, 0).UTC() {
+ t.Fatalf("host2 time mismatch: %v", meta["host2"])
+ }
+
+ if err := ResetHostMeta(ctx, db); err != nil {
+ t.Fatalf("reset2: %v", err)
+ }
+ meta, err = LoadHostMeta(ctx, db)
+ if err != nil {
+ t.Fatalf("load after reset: %v", err)
+ }
+ if len(meta) != 0 {
+ t.Fatalf("len after reset=%d, want 0", len(meta))
+ }
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index c847147..7ee9a7c 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -1,4 +1,4 @@
package version
// Tag is the application release version.
-const Tag = "0.5.1"
+const Tag = "0.5.2"