Skip to content

Commit 5ac1466

Browse files
authored
check for new versions (#64)
* check for new versions
1 parent 7bff094 commit 5ac1466

File tree

5 files changed

+267
-25
lines changed

5 files changed

+267
-25
lines changed

cmd/gonzo/app.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/control-theory/gonzo/internal/otlplog"
1717
"github.com/control-theory/gonzo/internal/otlpreceiver"
1818
"github.com/control-theory/gonzo/internal/tui"
19+
versioncheck "github.com/control-theory/gonzo/internal/version"
1920
"github.com/control-theory/gonzo/internal/vmlogs"
2021

2122
tea "github.com/charmbracelet/bubbletea"
@@ -30,6 +31,14 @@ func runApp(cmd *cobra.Command, args []string) error {
3031
return nil
3132
}
3233

34+
// Start version checking in background (if not disabled)
35+
var versionChecker *versioncheck.Checker
36+
if !cfg.DisableVersionCheck {
37+
currentVersion, currentCommit := GetVersionInfo()
38+
versionChecker = versioncheck.NewChecker(currentVersion, currentCommit)
39+
versionChecker.CheckInBackground()
40+
}
41+
3342
// Initialize skin/color scheme
3443
configDir := os.Getenv("HOME") + "/.config/gonzo"
3544
if err := tui.InitializeSkin(cfg.Skin, configDir); err != nil {
@@ -81,16 +90,22 @@ func runApp(cmd *cobra.Command, args []string) error {
8190
freqMemory := memory.NewFrequencyMemory(cfg.MemorySize)
8291

8392
// Initialize TUI model with components
93+
dashboard := tui.NewDashboardModel(cfg.LogBuffer, cfg.UpdateInterval, cfg.AIModel, textAnalyzer.GetStopWords())
94+
if versionChecker != nil {
95+
dashboard.SetVersionChecker(versionChecker)
96+
}
97+
8498
tuiModel := &simpleTuiModel{
8599
formatDetector: formatDetector,
86100
logConverter: logConverter,
87101
customParser: customParser,
88102
textAnalyzer: textAnalyzer,
89103
otlpAnalyzer: otlpAnalyzer,
90104
freqMemory: freqMemory,
91-
dashboard: tui.NewDashboardModel(cfg.LogBuffer, cfg.UpdateInterval, cfg.AIModel, textAnalyzer.GetStopWords()),
105+
dashboard: dashboard,
92106
updateInterval: cfg.UpdateInterval,
93107
testMode: cfg.TestMode,
108+
versionChecker: versionChecker,
94109
}
95110

96111
var p *tea.Program
@@ -143,6 +158,7 @@ type simpleTuiModel struct {
143158
testMode bool
144159
ctx context.Context
145160
cancelFunc context.CancelFunc
161+
versionChecker *versioncheck.Checker
146162

147163
// Internal state
148164
finished bool

cmd/gonzo/main.go

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,32 @@ var (
1919
goVersion = "unknown"
2020
)
2121

22+
// GetVersionInfo returns the current version and commit information
23+
func GetVersionInfo() (string, string) {
24+
return version, commit
25+
}
26+
2227
// Config struct for application configuration
2328
type Config struct {
24-
MemorySize int `mapstructure:"memory-size"`
25-
UpdateInterval time.Duration `mapstructure:"update-interval"`
26-
LogBuffer int `mapstructure:"log-buffer"`
27-
TestMode bool `mapstructure:"test-mode"`
28-
ConfigFile string `mapstructure:"config"`
29-
AIModel string `mapstructure:"ai-model"`
30-
Files []string `mapstructure:"files"`
31-
Follow bool `mapstructure:"follow"`
32-
OTLPEnabled bool `mapstructure:"otlp-enabled"`
33-
OTLPGRPCPort int `mapstructure:"otlp-grpc-port"`
34-
OTLPHTTPPort int `mapstructure:"otlp-http-port"`
35-
VmlogsURL string `mapstructure:"vmlogs-url"`
36-
VmlogsUser string `mapstructure:"vmlogs-user"`
37-
VmlogsPassword string `mapstructure:"vmlogs-password"`
38-
VmlogsQuery string `mapstructure:"vmlogs-query"`
39-
Skin string `mapstructure:"skin"`
40-
StopWords []string `mapstructure:"stop-words"`
41-
Format string `mapstructure:"format"`
29+
MemorySize int `mapstructure:"memory-size"`
30+
UpdateInterval time.Duration `mapstructure:"update-interval"`
31+
LogBuffer int `mapstructure:"log-buffer"`
32+
TestMode bool `mapstructure:"test-mode"`
33+
ConfigFile string `mapstructure:"config"`
34+
AIModel string `mapstructure:"ai-model"`
35+
Files []string `mapstructure:"files"`
36+
Follow bool `mapstructure:"follow"`
37+
OTLPEnabled bool `mapstructure:"otlp-enabled"`
38+
OTLPGRPCPort int `mapstructure:"otlp-grpc-port"`
39+
OTLPHTTPPort int `mapstructure:"otlp-http-port"`
40+
VmlogsURL string `mapstructure:"vmlogs-url"`
41+
VmlogsUser string `mapstructure:"vmlogs-user"`
42+
VmlogsPassword string `mapstructure:"vmlogs-password"`
43+
VmlogsQuery string `mapstructure:"vmlogs-query"`
44+
Skin string `mapstructure:"skin"`
45+
StopWords []string `mapstructure:"stop-words"`
46+
Format string `mapstructure:"format"`
47+
DisableVersionCheck bool `mapstructure:"disable-version-check"`
4248
}
4349

4450
var (
@@ -149,6 +155,7 @@ func init() {
149155
rootCmd.Flags().StringP("skin", "s", "default", "Color scheme/skin to use (default, or name of a skin file in ~/.config/gonzo/skins/)")
150156
rootCmd.Flags().StringSlice("stop-words", []string{}, "Additional stop words to filter out from analysis (adds to built-in list)")
151157
rootCmd.Flags().String("format", "", "Log format to use (auto-detect if not specified). Can be: otlp, json, text, or a custom format name from ~/.config/gonzo/formats/")
158+
rootCmd.Flags().Bool("disable-version-check", false, "Disable automatic version checking on startup")
152159

153160
// Bind flags to viper
154161
viper.BindPFlag("memory-size", rootCmd.Flags().Lookup("memory-size"))
@@ -168,6 +175,7 @@ func init() {
168175
viper.BindPFlag("skin", rootCmd.Flags().Lookup("skin"))
169176
viper.BindPFlag("stop-words", rootCmd.Flags().Lookup("stop-words"))
170177
viper.BindPFlag("format", rootCmd.Flags().Lookup("format"))
178+
viper.BindPFlag("disable-version-check", rootCmd.Flags().Lookup("disable-version-check"))
171179

172180
// Add version command
173181
rootCmd.AddCommand(versionCmd)

internal/tui/components.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tui
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/charmbracelet/lipgloss"
78
)
@@ -110,6 +111,15 @@ func (m *DashboardModel) renderStatusLine() string {
110111

111112
// Build right section (status info and branding)
112113
var statusInfo string
114+
115+
// Check for version updates (only if version checker is enabled)
116+
var versionUpdateInfo string
117+
if m.versionChecker != nil {
118+
if updateInfo := m.versionChecker.GetUpdateInfoNonBlocking(); updateInfo != nil && updateInfo.UpdateAvailable {
119+
versionUpdateInfo = fmt.Sprintf("🔄 v%s available", updateInfo.LatestVersion)
120+
}
121+
}
122+
113123
if !m.filterActive && !m.searchActive && !m.showModal && !m.showHelp {
114124
if m.viewPaused {
115125
statusInfo = "⏸"
@@ -129,12 +139,20 @@ func (m *DashboardModel) renderStatusLine() string {
129139
branding = m.renderGonzoBranding()
130140
}
131141

132-
if statusInfo != "" && branding != "" {
133-
rightText = fmt.Sprintf("%s %s", statusInfo, branding)
134-
} else if statusInfo != "" {
135-
rightText = statusInfo
136-
} else {
137-
rightText = branding
142+
// Combine status info, version update, and branding
143+
var rightParts []string
144+
if statusInfo != "" {
145+
rightParts = append(rightParts, statusInfo)
146+
}
147+
if versionUpdateInfo != "" {
148+
rightParts = append(rightParts, versionUpdateInfo)
149+
}
150+
if branding != "" && m.width >= 30 {
151+
rightParts = append(rightParts, branding)
152+
}
153+
154+
if len(rightParts) > 0 {
155+
rightText = strings.Join(rightParts, " ")
138156
}
139157

140158
// Calculate dynamic widths based on available space using visible width

internal/tui/model.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/control-theory/gonzo/internal/ai"
99
"github.com/control-theory/gonzo/internal/memory"
10+
versioncheck "github.com/control-theory/gonzo/internal/version"
1011

1112
"github.com/charmbracelet/bubbles/textarea"
1213
"github.com/charmbracelet/bubbles/textinput"
@@ -166,6 +167,9 @@ type DashboardModel struct {
166167
lifetimeWordCounts map[string]int64 // Total count per word (for charts)
167168
lifetimeAttrKeyCounts map[string]map[string]int64 // Per attribute key: value -> count (for charts)
168169
stopWords map[string]bool // Stop words to filter from word counting
170+
171+
// Version checking
172+
versionChecker *versioncheck.Checker // Version checker for update notifications
169173
}
170174

171175
// UpdateMsg contains data updates for the dashboard
@@ -381,6 +385,11 @@ func (m *DashboardModel) getLifetimeAttributeEntries() []*memory.AttributeStatsE
381385
return entries
382386
}
383387

388+
// SetVersionChecker sets the version checker for update notifications
389+
func (m *DashboardModel) SetVersionChecker(checker *versioncheck.Checker) {
390+
m.versionChecker = checker
391+
}
392+
384393
// Init initializes the model
385394
func (m *DashboardModel) Init() tea.Cmd {
386395
var cmds []tea.Cmd

internal/version/check.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package version
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net"
11+
"net/http"
12+
"os"
13+
"runtime"
14+
"strings"
15+
"time"
16+
)
17+
18+
// UpdateInfo contains information about available updates
19+
type UpdateInfo struct {
20+
UpdateAvailable bool
21+
LatestVersion string
22+
CurrentVersion string
23+
ReleaseURL string
24+
Severity string
25+
}
26+
27+
// VersionResponse represents the API response from the version check endpoint
28+
type VersionResponse struct {
29+
CurrentVersion string `json:"current_version"`
30+
Latest Latest `json:"latest"`
31+
UpdateAvailable bool `json:"update_available"`
32+
Severity string `json:"severity"`
33+
CacheLastUpdate string `json:"cache_last_updated"`
34+
}
35+
36+
// Latest contains information about the latest release
37+
type Latest struct {
38+
Tag string `json:"tag"`
39+
PublishedAt string `json:"published_at"`
40+
URL string `json:"url"`
41+
Notes string `json:"notes"`
42+
Assets map[string]string `json:"assets"`
43+
}
44+
45+
// Checker handles version checking in the background
46+
type Checker struct {
47+
currentVersion string
48+
commit string
49+
updateInfo *UpdateInfo
50+
checkComplete chan bool
51+
}
52+
53+
// NewChecker creates a new version checker
54+
func NewChecker(currentVersion, commit string) *Checker {
55+
return &Checker{
56+
currentVersion: currentVersion,
57+
commit: commit,
58+
checkComplete: make(chan bool, 1),
59+
}
60+
}
61+
62+
// CheckInBackground starts a background goroutine to check for updates
63+
func (c *Checker) CheckInBackground() {
64+
go func() {
65+
defer func() {
66+
// Ensure we always signal completion, even if panic occurs
67+
select {
68+
case c.checkComplete <- true:
69+
default:
70+
}
71+
}()
72+
73+
// Don't check for updates in development builds
74+
if c.currentVersion == "dev" || c.currentVersion == "" {
75+
return
76+
}
77+
78+
// Create context with timeout for the HTTP request
79+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
80+
defer cancel()
81+
82+
// Get anonymous ID for caching uniqueness
83+
anonID := getOrCreateAnonID()
84+
85+
// Determine channel (stable for tagged releases, edge for local builds)
86+
channel := "stable"
87+
if strings.Contains(c.currentVersion, "-dirty") || strings.Contains(c.currentVersion, "-g") {
88+
channel = "edge"
89+
}
90+
91+
// Build the API URL with parameters
92+
url := fmt.Sprintf(
93+
"https://gonzo-version.controltheory.com/v1/check?app=gonzo&version=%s&platform=%s&arch=%s&commit=%s&channel=%s&anon_id=%s",
94+
c.currentVersion,
95+
runtime.GOOS,
96+
runtime.GOARCH,
97+
c.commit,
98+
channel,
99+
anonID,
100+
)
101+
102+
// Create HTTP request with context
103+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
104+
if err != nil {
105+
return // Silently ignore errors
106+
}
107+
108+
// Make the HTTP request
109+
client := &http.Client{Timeout: 10 * time.Second}
110+
resp, err := client.Do(req)
111+
if err != nil {
112+
return // Silently ignore errors
113+
}
114+
defer resp.Body.Close()
115+
116+
// Read response body
117+
body, err := io.ReadAll(resp.Body)
118+
if err != nil {
119+
return // Silently ignore errors
120+
}
121+
122+
// Parse JSON response
123+
var versionResp VersionResponse
124+
if err := json.Unmarshal(body, &versionResp); err != nil {
125+
return // Silently ignore errors
126+
}
127+
128+
// Store update information
129+
c.updateInfo = &UpdateInfo{
130+
UpdateAvailable: versionResp.UpdateAvailable,
131+
LatestVersion: strings.TrimPrefix(versionResp.Latest.Tag, "v"),
132+
CurrentVersion: c.currentVersion,
133+
ReleaseURL: versionResp.Latest.URL,
134+
Severity: versionResp.Severity,
135+
}
136+
}()
137+
}
138+
139+
// GetUpdateInfo returns the update information if available
140+
// It waits up to 100ms for the check to complete, then returns whatever is available
141+
func (c *Checker) GetUpdateInfo() *UpdateInfo {
142+
select {
143+
case <-c.checkComplete:
144+
// Check completed, return result
145+
return c.updateInfo
146+
case <-time.After(100 * time.Millisecond):
147+
// Don't wait too long, return whatever we have
148+
return c.updateInfo
149+
}
150+
}
151+
152+
// GetUpdateInfoNonBlocking returns the update information without waiting
153+
func (c *Checker) GetUpdateInfoNonBlocking() *UpdateInfo {
154+
return c.updateInfo
155+
}
156+
157+
// getOrCreateAnonID creates a consistent anonymous ID based on machine characteristics
158+
func getOrCreateAnonID() string {
159+
// Create a machine-specific but anonymous identifier
160+
// This will be the same for each machine but doesn't identify the user
161+
162+
hostname, _ := os.Hostname()
163+
macAddr := getMACAddress()
164+
osArch := runtime.GOOS + "-" + runtime.GOARCH
165+
166+
// Combine machine characteristics
167+
machineInfo := hostname + "|" + macAddr + "|" + osArch
168+
169+
// Hash to create anonymous but consistent ID
170+
h := sha256.Sum256([]byte(machineInfo))
171+
return hex.EncodeToString(h[:8]) // Use first 8 bytes for shorter ID
172+
}
173+
174+
// getMACAddress attempts to get a MAC address from network interfaces
175+
func getMACAddress() string {
176+
interfaces, err := net.Interfaces()
177+
if err != nil {
178+
return "unknown"
179+
}
180+
181+
for _, iface := range interfaces {
182+
// Skip loopback and down interfaces
183+
if iface.Flags&net.FlagLoopback == 0 && iface.Flags&net.FlagUp != 0 {
184+
if iface.HardwareAddr != nil {
185+
return iface.HardwareAddr.String()
186+
}
187+
}
188+
}
189+
190+
return "unknown"
191+
}

0 commit comments

Comments
 (0)